diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d7b5c9f..b91e287 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { implementation(project(":core:designsystem")) implementation(project(":core:data")) implementation(project(":core:datastore")) + implementation(project(":core:notifications")) implementation(project(":domain:cycle")) implementation(project(":domain:prediction")) @@ -79,7 +80,10 @@ dependencies { implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) + implementation(libs.androidx.hilt.work) + implementation(libs.androidx.work.runtime) ksp(libs.hilt.compiler) + ksp(libs.androidx.hilt.compiler) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 867619b..8bd8e5d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,13 @@ - + + + + + + + + = 33) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else if (needsPermission) { + viewModel.permissionHandled() + } + } + + NotificationSettingsContent(prefs, viewModel) +} + +@Composable +private fun NotificationSettingsContent( + prefs: UserPreferences, + viewModel: NotificationSettingsViewModel?, +) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Text( + "Batch 05 · working surface — the designed Settings screen is Batch 06", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + + Text("How reminders appear", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + "This is what someone standing next to you can read on your lock screen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + + Column(Modifier.selectableGroup()) { + PrivacyRow("Discreet", "\"Quick check-in\"", prefs.notificationPrivacy == NotificationPrivacy.DISCREET) { + viewModel?.setPrivacy(NotificationPrivacy.DISCREET) + } + PrivacyRow("Maximum privacy", "\"Reminder\"", prefs.notificationPrivacy == NotificationPrivacy.MAXIMUM_PRIVACY) { + viewModel?.setPrivacy(NotificationPrivacy.MAXIMUM_PRIVACY) + } + PrivacyRow("Direct", "\"Your period may start soon\"", prefs.notificationPrivacy == NotificationPrivacy.DIRECT) { + viewModel?.setPrivacy(NotificationPrivacy.DIRECT) + } + } + + Spacer(Modifier.height(20.dp)) + HorizontalDivider() + Spacer(Modifier.height(20.dp)) + + Text("What to remind me about", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + + // Six switches, not one. §29 makes them independent, and "period + // reminders yes, fertility no" is the common case rather than an edge. + Toggle("Period approaching", prefs.periodApproachingEnabled) { viewModel?.setPeriodApproaching(it) } + Toggle("Period expected today", prefs.periodExpectedTodayEnabled) { viewModel?.setPeriodExpectedToday(it) } + Toggle("Did it start?", prefs.didItStartEnabled) { viewModel?.setDidItStart(it) } + Toggle("Period end check-in", prefs.periodEndCheckInEnabled) { viewModel?.setPeriodEndCheckIn(it) } + Toggle("Fertile window approaching", prefs.fertileWindowReminderEnabled) { viewModel?.setFertileWindow(it) } + Toggle("Estimated ovulation", prefs.ovulationReminderEnabled) { viewModel?.setOvulation(it) } + + Spacer(Modifier.height(20.dp)) + HorizontalDivider() + Spacer(Modifier.height(20.dp)) + + Text("When", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + "Morning" to LocalTime.of(9, 0), + "Afternoon" to LocalTime.of(14, 0), + "Evening" to LocalTime.of(19, 0), + ).forEach { (label, time) -> + TextButton(onClick = { viewModel?.setReminderTime(time) }) { Text(label) } + } + } + Text( + "Currently ${prefs.reminderTime.format(timeFormat)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(24.dp)) + Text( + "Reminders are scheduled with WorkManager and may arrive a few minutes either " + + "side of this time. Nothing about your cycle leaves the device.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun PrivacyRow(title: String, example: String, selected: Boolean, onSelect: () -> Unit) { + Card(Modifier.fillMaxWidth().padding(vertical = 3.dp)) { + Row( + Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onSelect) + Spacer(Modifier.height(8.dp)) + Column(Modifier.padding(start = 8.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text( + example, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun Toggle(label: String, checked: Boolean, onChange: (Boolean) -> Unit) { + Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyLarge) + Switch(checked = checked, onCheckedChange = onChange) + } +} + +private val timeFormat = DateTimeFormatter.ofPattern("HH:mm") + +@Preview(name = "Notification settings", showBackground = true, heightDp = 900) +@Preview( + name = "Notification settings dark", + showBackground = true, + heightDp = 900, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Composable +private fun PreviewSettings() = PeriodTheme { + NotificationSettingsContent(UserPreferences.Defaults, null) +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/NotificationSettingsViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/NotificationSettingsViewModel.kt new file mode 100644 index 0000000..3e19b2c --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/NotificationSettingsViewModel.kt @@ -0,0 +1,106 @@ +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.datastore.NotificationPrivacy +import dev.privacyllc.period.core.datastore.UserPreferences +import dev.privacyllc.period.core.datastore.UserPreferencesRepository +import dev.privacyllc.period.core.notifications.ReminderScheduler +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.LocalTime +import javax.inject.Inject + +/** + * §29's six independent toggles, the reminder time, and §28's privacy mode. + * + * The designed Settings screen is Batch 06. This is the plumbing plus enough + * surface to change a setting and see the reminder change — deliberately plain, + * and it says so on screen. + */ +@HiltViewModel +class NotificationSettingsViewModel @Inject constructor( + private val preferences: UserPreferencesRepository, + private val scheduler: ReminderScheduler, +) : ViewModel() { + + val state: StateFlow = + preferences.preferences.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + UserPreferences.Defaults, + ) + + /** + * Set when a toggle is switched on without the notification permission. + * + * §31: ask at an appropriate moment, not blindly on first launch. This is + * that moment — the user has just said they want a reminder, so the dialog + * is an answer to something they did rather than an interruption before + * they have seen the app. + */ + private val _needsPermission = MutableStateFlow(false) + val needsPermission: StateFlow = _needsPermission.asStateFlow() + + fun permissionHandled() { _needsPermission.value = false } + + private val handler = CoroutineExceptionHandler { _, _ -> } + + private fun update(requestPermissionIfEnabling: Boolean = true, block: suspend () -> Unit) = + viewModelScope.launch(handler) { + block() + if (requestPermissionIfEnabling) _needsPermission.value = true + reschedule() + } + + fun setPrivacy(value: NotificationPrivacy) = + update(requestPermissionIfEnabling = false) { preferences.setNotificationPrivacy(value) } + + fun setPeriodApproaching(on: Boolean) = update(on) { preferences.setPeriodApproachingEnabled(on) } + fun setPeriodExpectedToday(on: Boolean) = update(on) { preferences.setPeriodExpectedTodayEnabled(on) } + fun setDidItStart(on: Boolean) = update(on) { preferences.setDidItStartEnabled(on) } + fun setPeriodEndCheckIn(on: Boolean) = update(on) { preferences.setPeriodEndCheckInEnabled(on) } + fun setFertileWindow(on: Boolean) = update(on) { preferences.setFertileWindowReminderEnabled(on) } + fun setOvulation(on: Boolean) = update(on) { preferences.setOvulationReminderEnabled(on) } + + fun setReminderTime(time: LocalTime) = + update(requestPermissionIfEnabling = false) { preferences.setReminderTime(time) } + + /** + * Re-enqueue whenever anything that decides a reminder changes. + * + * §31 asks for rescheduling after a confirmation or a forecast change, and + * the same argument covers a settings change: work already queued was + * queued against the old answer. + * + * Cancelled outright when every toggle is off, rather than left running to + * wake up and decide there is nothing to do — a background job that exists + * only to do nothing is a battery cost with no user. + */ + private suspend fun reschedule() { + val prefs = preferences.preferences.first() + val anyEnabled = prefs.periodApproachingEnabled || + prefs.periodExpectedTodayEnabled || + prefs.didItStartEnabled || + prefs.periodEndCheckInEnabled || + prefs.fertileWindowReminderEnabled || + prefs.ovulationReminderEnabled + + if (anyEnabled) scheduler.schedule(prefs.reminderTime) else scheduler.cancel() + } + + /** Whether anything at all is switched on, for the screen's summary line. */ + val anyReminderOn: StateFlow = + preferences.preferences.map { + it.periodApproachingEnabled || it.periodExpectedTodayEnabled || it.didItStartEnabled || + it.periodEndCheckInEnabled || it.fertileWindowReminderEnabled || it.ovulationReminderEnabled + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true) +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt index b9257bf..7b1d9d1 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt @@ -38,6 +38,7 @@ import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.feature.calendar.CalendarScreen import dev.privacyllc.period.feature.insights.InsightsScreen import dev.privacyllc.period.feature.onboarding.OnboardingScreen +import dev.privacyllc.period.feature.settings.NotificationSettingsScreen import dev.privacyllc.period.feature.today.TodayScreen /** @@ -66,9 +67,18 @@ enum class PeriodDestination( * onboarded again, and somebody who abandoned onboarding halfway has. */ @Composable -fun PeriodRoot(viewModel: RootViewModel = hiltViewModel()) { +fun PeriodRoot( + notificationAction: String? = null, + viewModel: RootViewModel = hiltViewModel(), +) { val state by viewModel.state.collectAsStateWithLifecycle() + // Applied once per delivered intent. Keyed on the action so a rotation does + // not record a second "Not yet" the user never tapped. + androidx.compose.runtime.LaunchedEffect(notificationAction) { + viewModel.onNotificationAction(notificationAction) + } + when (state) { RootState.Loading -> Unit // one frame; a spinner here flashes and reads as jank RootState.Onboarding -> OnboardingScreen(onFinished = viewModel::onboardingFinished) @@ -112,15 +122,10 @@ fun PeriodApp() { composable(PeriodDestination.CALENDAR.route) { CalendarScreen() } composable(PeriodDestination.INSIGHTS.route) { InsightsScreen() } - // Settings arrives in Batch 06. It says "not built yet" rather than - // showing a convincing mock. - PeriodDestination.entries - .filter { it == PeriodDestination.SETTINGS } - .forEach { destination -> - composable(destination.route) { - PlaceholderScreen(stringResource(destination.labelRes)) - } - } + // Settings shows the notification plumbing from Batch 05. The + // designed screen — cycle, privacy and security, appearance, + // premium, about — is Batch 06, and this surface says so on itself. + composable(PeriodDestination.SETTINGS.route) { NotificationSettingsScreen() } } } } diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt index ec50276..a5c188d 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject enum class RootState { Loading, Onboarding, Ready } @@ -17,8 +18,23 @@ enum class RootState { Loading, Onboarding, Ready } @HiltViewModel class RootViewModel @Inject constructor( preferences: UserPreferencesRepository, + private val notificationActions: dev.privacyllc.period.notifications.NotificationActionHandler, ) : ViewModel() { + /** + * Apply an action tapped on a notification. + * + * Handled once per intent by the caller. Doing it here rather than in the + * activity keeps it off the main thread and, more importantly, routes it + * through the same repository call the in-app button uses — two paths into + * one piece of state is how a lock-screen "Not yet" and an in-app "Not yet" + * come to mean slightly different things. + */ + fun onNotificationAction(action: String?) { + if (action == null) return + viewModelScope.launch { notificationActions.handle(action) } + } + /** * Set when onboarding finishes, so the switch happens immediately rather * than waiting for the preference write to travel back through DataStore — diff --git a/app/src/main/kotlin/dev/privacyllc/period/notifications/NotificationActionHandler.kt b/app/src/main/kotlin/dev/privacyllc/period/notifications/NotificationActionHandler.kt new file mode 100644 index 0000000..384183d --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/notifications/NotificationActionHandler.kt @@ -0,0 +1,55 @@ +package dev.privacyllc.period.notifications + +import dev.privacyllc.period.core.data.CycleRepository +import dev.privacyllc.period.core.datastore.UserPreferencesRepository +import dev.privacyllc.period.core.notifications.PeriodNotifier +import java.time.Clock +import java.time.LocalDate +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Turns a notification action into the same thing the in-app button does. + * + * That equivalence is the whole point, and it is easy to get wrong: two paths + * into one piece of state usually means two slightly different pieces of state. + * Answering "Not yet" from a lock screen must produce exactly the + * `NotYetObservation` the Today screen's button produces, or the forecast the + * user sees when they open the app will not match the one the notification was + * about. + * + * §31 also asks for sensible handling before details are exposed: tapping an + * action opens the app, which is behind whatever device lock the user has. The + * action itself records the answer; nothing is displayed until the app is + * actually in front of them. + */ +@Singleton +class NotificationActionHandler @Inject constructor( + private val repository: CycleRepository, + private val preferences: UserPreferencesRepository, + private val clock: Clock, +) { + + /** Returns true when the action was recognised and applied. */ + suspend fun handle(action: String?): Boolean { + val today = LocalDate.now(clock) + return when (action) { + PeriodNotifier.ACTION_STARTED -> { + repository.confirmPeriodStart(today) + // The question is answered, so the app is willing to ask again + // next cycle rather than staying permanently quiet. + preferences.resetCheckIns() + true + } + + PeriodNotifier.ACTION_NOT_YET -> { + // Exactly what the Today screen's "Not yet" does — same call, + // same censoring observation, same re-conditioned forecast. + repository.recordNotYet(today) + true + } + + else -> false + } + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/notifications/ReminderCoordinator.kt b/app/src/main/kotlin/dev/privacyllc/period/notifications/ReminderCoordinator.kt new file mode 100644 index 0000000..57e42e4 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/notifications/ReminderCoordinator.kt @@ -0,0 +1,76 @@ +package dev.privacyllc.period.notifications + +import dev.privacyllc.period.core.data.CycleRepository +import dev.privacyllc.period.core.datastore.UserPreferencesRepository +import dev.privacyllc.period.core.notifications.ReminderScheduler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import java.time.LocalDate +import java.time.LocalTime +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Keeps the scheduled reminder pointed at the current forecast. + * + * §31 lists rescheduling after a confirmation or a forecast change explicitly, + * and it is the requirement most likely to be missed — everything works, right + * up until a "Not yet" moves the forecast and the queued reminder is aimed at a + * day that no longer means anything. + * + * It lives in `app` rather than in the repository on purpose. `core:data` does + * not depend on `core:datastore` or `core:notifications`, and it should not: + * storing cycle history and deciding when to buzz a phone are different jobs, + * and the module boundary guard would refuse the dependency anyway. + */ +@Singleton +class ReminderCoordinator @Inject constructor( + private val repository: CycleRepository, + private val preferences: UserPreferencesRepository, + private val scheduler: ReminderScheduler, +) { + + /** + * Watch for anything that changes what a reminder should say or when. + * + * `distinctUntilChanged` on a small key rather than on the whole state: the + * forecast object is recreated on every recalculation even when it says the + * same thing, and rescheduling on each of those would churn WorkManager for + * no reason. + */ + fun start(scope: CoroutineScope) { + combine( + repository.forecast.map { it?.mostLikelyStartDate }, + preferences.preferences.map { it.reminderTime to anyEnabled(it) }, + ) { forecastDate, (time, enabled) -> Triple(forecastDate, time, enabled) } + .distinctUntilChanged() + .onEach { (_, time, enabled) -> apply(time, enabled) } + .launchIn(scope) + + // A confirmed period answers the question the check-ins were asking, so + // the count resets and the app is willing to ask again next cycle. + // Without this, somebody who ignored three check-ins once would never be + // asked again — the stopping rule would become permanent. + repository.confirmedPeriods + .map { periods -> periods.maxOfOrNull { it.startDate } } + .distinctUntilChanged() + .onEach { latest -> if (latest != null) onPeriodConfirmed(latest) } + .launchIn(scope) + } + + private suspend fun apply(time: LocalTime, enabled: Boolean) { + if (enabled) scheduler.schedule(time) else scheduler.cancel() + } + + private suspend fun onPeriodConfirmed(@Suppress("UNUSED_PARAMETER") start: LocalDate) { + preferences.resetCheckIns() + } + + private fun anyEnabled(p: dev.privacyllc.period.core.datastore.UserPreferences) = + p.periodApproachingEnabled || p.periodExpectedTodayEnabled || p.didItStartEnabled || + p.periodEndCheckInEnabled || p.fertileWindowReminderEnabled || p.ovulationReminderEnabled +} diff --git a/build.gradle.kts b/build.gradle.kts index d48a3d6..488098c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,11 +42,15 @@ plugins { /** Project dependencies each module is permitted. Anything else fails. */ val allowedProjectDependencies: Map> = mapOf( - ":app" to setOf(":core:designsystem", ":core:data", ":core:datastore", ":domain:cycle", ":domain:prediction"), + ":app" to setOf( + ":core:designsystem", ":core:data", ":core:datastore", ":core:notifications", + ":domain:cycle", ":domain:prediction", + ), ":core:designsystem" to emptySet(), ":core:database" to setOf(":domain:cycle", ":domain:prediction"), ":core:datastore" to emptySet(), ":core:data" to setOf(":core:database", ":domain:cycle", ":domain:prediction"), + ":core:notifications" to setOf(":core:data", ":core:datastore", ":domain:cycle", ":domain:prediction"), ":domain:cycle" to emptySet(), ":domain:prediction" to setOf(":domain:cycle"), // Batch 07. Empty, and that is the whole point: the ads module may reach @@ -182,9 +186,130 @@ tasks.register("checkModuleBoundaries") { } } +// =========================================================================== +// Permissions +// =========================================================================== +// +// The Play listing shows this list, the Data Safety form has to describe it, and +// a privacy-first period tracker is judged on it before anybody opens the app. +// +// It is also the list most likely to grow without anyone deciding to grow it: a +// dependency added for one feature brings its own , the merge +// is silent, and it appears in the store listing months later. Adding +// WorkManager to this project added four in one line — WAKE_LOCK, +// ACCESS_NETWORK_STATE, RECEIVE_BOOT_COMPLETED and FOREGROUND_SERVICE — none of +// them typed by anybody. +// +// So the set is declared here and checked. Growing it is allowed; growing it by +// accident is not. + +val allowedPermissions: Set = setOf( + // Asked for when a reminder is switched on, never on first launch (§31). + "android.permission.POST_NOTIFICATIONS", + + // The four WorkManager brings. None is requested by this project's own code. + // RECEIVE_BOOT_COMPLETED is the one that earns its place: it is how a + // reminder survives a restart. + "android.permission.WAKE_LOCK", + "android.permission.ACCESS_NETWORK_STATE", + "android.permission.RECEIVE_BOOT_COMPLETED", + "android.permission.FOREGROUND_SERVICE", +) + +/** + * Permissions this app must NEVER declare, whatever else changes. + * + * Separate from "not in the allowlist" because these deserve their own message. + * §31 rules out exact alarms specifically: a period reminder does not need + * alarm-clock precision, and the permission costs Play scrutiny for nothing. + */ +val forbiddenPermissions: Set = setOf( + "android.permission.SCHEDULE_EXACT_ALARM", + "android.permission.USE_EXACT_ALARM", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.READ_CONTACTS", + "android.permission.READ_CALENDAR", + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO", +) + +tasks.register("checkPermissions") { + group = "verification" + description = "The merged manifest may declare only the permissions listed in build.gradle.kts." + + val allowed = allowedPermissions + val forbidden = forbiddenPermissions + val intermediates = layout.projectDirectory.dir("app/build/intermediates").asFile + + // The manifest has to exist and be CURRENT before this reads it. + // + // Without this the task ran happily against whatever was left on disk from + // a previous build. prove-guard.sh caught it: a deliberate + // SCHEDULE_EXACT_ALARM was added to the manifest and the check stayed green, + // because it read the merged file from before the edit. The second guard in + // this project to be confidently green over exactly its own target. + // Both variants, and release is the one that matters: the Play listing and + // the Data Safety form describe the shipped manifest, not the debug one. + dependsOn(":app:processDebugMainManifest", ":app:processReleaseMainManifest") + + doLast { + // Walked at execution time, not configuration time — a file tree + // resolved during configuration does not see a manifest written later + // in the same build. + // Only the outputs of the tasks above. AGP also leaves a legacy + // `merged_manifests` (plural) tree that nothing here regenerates, and + // reading it meant a stale file from an earlier build failing the check + // — a guard that cries wolf gets switched off. + val files = intermediates.resolve("merged_manifest").walkTopDown() + .filter { it.isFile && it.name == "AndroidManifest.xml" } + .toList() + if (files.isEmpty()) { + // Never a silent pass: no manifest means the check did not run. + throw GradleException( + "no merged manifest found, so no permission was checked. Build :app first.", + ) + } + + // Comments are stripped before parsing. This file's own comment names + // SCHEDULE_EXACT_ALARM to explain why it is absent, and a naive grep + // reported the explanation as the violation. + val commentRe = Regex("", RegexOption.DOT_MATCHES_ALL) + val permissionRe = Regex("""]*android:name="([^"]+)"""") + + val found = files.flatMap { file -> + permissionRe.findAll(commentRe.replace(file.readText(), "")) + .map { it.groupValues[1] } + }.toSet().filterNot { it.endsWith("DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION") } + + val bad = found.filter { it in forbidden } + val unexpected = found.filterNot { it in allowed || it in forbidden } + + if (bad.isNotEmpty() || unexpected.isNotEmpty()) { + logger.error("") + bad.forEach { + logger.error(" FORBIDDEN permission in the merged manifest: $it") + } + unexpected.forEach { + logger.error(" Undeclared permission in the merged manifest: $it") + } + logger.error("") + logger.error("A permission nobody typed usually arrives with a dependency. Find which,") + logger.error("decide whether this app should have it, and either remove it with") + logger.error("tools:node='remove' or add it to allowedPermissions with a reason.") + logger.error("Whatever you do, update docs/security/SECURITY.md — the Play listing and") + logger.error("the Data Safety form both describe this list.") + throw GradleException("${bad.size + unexpected.size} unapproved permission(s).") + } + + logger.lifecycle("permissions: ${found.size} declared, all approved.") + } +} + // Wired into `check` so it runs with the tests rather than only when remembered. subprojects { tasks.matching { it.name == "check" }.configureEach { dependsOn(rootProject.tasks.named("checkModuleBoundaries")) + if (project.path == ":app") dependsOn(rootProject.tasks.named("checkPermissions")) } } 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 index e1f776a..187612a 100644 --- 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 @@ -36,7 +36,13 @@ enum class AppTheme { LIGHT, DARK, SYSTEM } data class UserPreferences( val notificationPrivacy: NotificationPrivacy = NotificationPrivacy.DISCREET, val reminderTime: LocalTime = DEFAULT_REMINDER_TIME, - val periodReminderEnabled: Boolean = true, + // §29 gives six types an INDEPENDENT toggle each. Folding them into one + // "reminders" switch would make the common case — a period reminder and no + // fertility notifications — impossible to express. + val periodApproachingEnabled: Boolean = true, + val periodExpectedTodayEnabled: Boolean = true, + val didItStartEnabled: Boolean = true, + val periodEndCheckInEnabled: Boolean = true, val fertileWindowReminderEnabled: Boolean = false, val ovulationReminderEnabled: Boolean = false, val biometricLockEnabled: Boolean = false, @@ -59,7 +65,8 @@ data class UserPreferences( * [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. + * and an unrequested ovulation notification is an unpleasant surprise — + * doubly so on a lock screen somebody else can see. */ 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 index 99176c6..d68e775 100644 --- 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 @@ -44,13 +44,32 @@ class UserPreferencesRepository( it[Keys.ReminderMinuteOfDay] = value.hour * 60 + value.minute } - suspend fun setPeriodReminderEnabled(value: Boolean) = edit { it[Keys.PeriodReminder] = value } + suspend fun setPeriodApproachingEnabled(value: Boolean) = edit { it[Keys.PeriodApproaching] = value } + suspend fun setPeriodExpectedTodayEnabled(value: Boolean) = edit { it[Keys.PeriodExpectedToday] = value } + suspend fun setDidItStartEnabled(value: Boolean) = edit { it[Keys.DidItStart] = value } + suspend fun setPeriodEndCheckInEnabled(value: Boolean) = edit { it[Keys.PeriodEndCheckIn] = 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 } + /** + * How many times the app has asked "did it start?" without being told yes. + * + * State rather than a setting, and it lives here because it has to survive a + * reboot — §30's stopping rule is meaningless if the count resets whenever + * the process dies. Reset when a period is confirmed, which is the event + * that makes the question moot. + */ + val checkInCount: Flow = dataStore.data + .catch { cause -> if (cause is IOException) emit(EMPTY) else throw cause } + .map { it[Keys.CheckInCount] ?: 0 } + + suspend fun recordCheckIn() = edit { it[Keys.CheckInCount] = (it[Keys.CheckInCount] ?: 0) + 1 } + + suspend fun resetCheckIns() = edit { it[Keys.CheckInCount] = 0 } + /** * Entitlement, mirrored from Google Play. * @@ -81,7 +100,10 @@ class UserPreferencesRepository( ?.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, + periodApproachingEnabled = p[Keys.PeriodApproaching] ?: UserPreferences.Defaults.periodApproachingEnabled, + periodExpectedTodayEnabled = p[Keys.PeriodExpectedToday] ?: UserPreferences.Defaults.periodExpectedTodayEnabled, + didItStartEnabled = p[Keys.DidItStart] ?: UserPreferences.Defaults.didItStartEnabled, + periodEndCheckInEnabled = p[Keys.PeriodEndCheckIn] ?: UserPreferences.Defaults.periodEndCheckInEnabled, fertileWindowReminderEnabled = p[Keys.FertileReminder] ?: UserPreferences.Defaults.fertileWindowReminderEnabled, ovulationReminderEnabled = p[Keys.OvulationReminder] ?: UserPreferences.Defaults.ovulationReminderEnabled, biometricLockEnabled = p[Keys.BiometricLock] ?: UserPreferences.Defaults.biometricLockEnabled, @@ -95,7 +117,11 @@ class UserPreferencesRepository( private object Keys { val NotificationPrivacy = stringPreferencesKey("notification_privacy") val ReminderMinuteOfDay = intPreferencesKey("reminder_minute_of_day") - val PeriodReminder = booleanPreferencesKey("period_reminder_enabled") + val PeriodApproaching = booleanPreferencesKey("reminder_period_approaching") + val PeriodExpectedToday = booleanPreferencesKey("reminder_period_expected_today") + val DidItStart = booleanPreferencesKey("reminder_did_it_start") + val PeriodEndCheckIn = booleanPreferencesKey("reminder_period_end_check_in") + val CheckInCount = intPreferencesKey("check_in_count") val FertileReminder = booleanPreferencesKey("fertile_window_reminder_enabled") val OvulationReminder = booleanPreferencesKey("ovulation_reminder_enabled") val BiometricLock = booleanPreferencesKey("biometric_lock_enabled") 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 index 53b34f8..35284a0 100644 --- 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 @@ -64,7 +64,9 @@ class UserPreferencesRepositoryTest { 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) + assertTrue("the period reminder is the one the user came for", p.periodApproachingEnabled) + assertTrue(p.periodExpectedTodayEnabled) + assertTrue(p.didItStartEnabled) } @Test @@ -85,7 +87,7 @@ class UserPreferencesRepositoryTest { 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.setPeriodApproachingEnabled(false) repo.setFertileWindowReminderEnabled(true) repo.setOvulationReminderEnabled(true) repo.setBiometricLockEnabled(true) @@ -97,7 +99,7 @@ class UserPreferencesRepositoryTest { UserPreferences( notificationPrivacy = NotificationPrivacy.MAXIMUM_PRIVACY, reminderTime = LocalTime.of(21, 45), - periodReminderEnabled = false, + periodApproachingEnabled = false, fertileWindowReminderEnabled = true, ovulationReminderEnabled = true, biometricLockEnabled = true, diff --git a/core/notifications/build.gradle.kts b/core/notifications/build.gradle.kts new file mode 100644 index 0000000..b38257f --- /dev/null +++ b/core/notifications/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "dev.privacyllc.period.core.notifications" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + api(project(":core:datastore")) + implementation(project(":core:data")) + implementation(project(":domain:cycle")) + implementation(project(":domain:prediction")) + implementation(libs.androidx.work.runtime) + implementation(libs.androidx.core.ktx) + implementation(libs.hilt.android) + implementation(libs.androidx.hilt.work) + ksp(libs.hilt.compiler) + ksp(libs.androidx.hilt.compiler) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) +} diff --git a/core/notifications/src/androidTest/kotlin/dev/privacyllc/period/core/notifications/NotificationPrivacyTest.kt b/core/notifications/src/androidTest/kotlin/dev/privacyllc/period/core/notifications/NotificationPrivacyTest.kt new file mode 100644 index 0000000..fed023d --- /dev/null +++ b/core/notifications/src/androidTest/kotlin/dev/privacyllc/period/core/notifications/NotificationPrivacyTest.kt @@ -0,0 +1,157 @@ +package dev.privacyllc.period.core.notifications + +import android.app.Notification +import android.app.NotificationManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule +import androidx.test.platform.app.InstrumentationRegistry +import dev.privacyllc.period.core.datastore.NotificationPrivacy +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * What a locked phone would actually show, checked on a real device. + * + * The unit tests prove the *copy* is right. This proves the copy reaches Android + * intact — that the public version is attached, that the visibility flag is set, + * and that the thing a stranger could read is the public one. + * + * Those are different claims, and only the second one catches the mistake that + * matters: a notification marked private with no public version does not show a + * blank lock screen, it shows the private text. + */ +@RunWith(AndroidJUnit4::class) +class NotificationPrivacyTest { + + /** + * The test APK is its own package, so the app's grant does not cover it. + * Granted here rather than with `adb shell pm grant` out of band, because a + * test that only passes after a manual command is a test nobody can re-run. + */ + @get:org.junit.Rule + val permission: GrantPermissionRule = + GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS) + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val manager = context.getSystemService(NotificationManager::class.java) + private val notifier = PeriodNotifier(context) + + @Before fun clear() = manager.cancelAll() + + @After fun tearDown() = manager.cancelAll() + + /** + * Posting is asynchronous. Polled rather than read once — a bare read + * failed intermittently and the failure looked like "nothing was posted", + * which is the same symptom as a missing permission and sent me looking in + * the wrong place. + */ + private fun postedNotification(): Notification? { + repeat(50) { + manager.activeNotifications + .firstOrNull { it.id == PeriodNotifier.NOTIFICATION_ID } + ?.let { return it.notification } + Thread.sleep(20) + } + return null + } + + private fun Notification.textOf(): String { + val e = extras + return listOfNotNull( + e.getCharSequence(Notification.EXTRA_TITLE)?.toString(), + e.getCharSequence(Notification.EXTRA_TEXT)?.toString(), + ).joinToString(" ").lowercase() + } + + @Test + fun everyPrivateModeAttachesAPublicVersionThatLeaksNothing() { + listOf(NotificationPrivacy.DISCREET, NotificationPrivacy.MAXIMUM_PRIVACY).forEach { mode -> + ReminderKind.entries.forEach { kind -> + manager.cancelAll() + + val text = NotificationCopy.textFor(kind, mode, daysUntil = 2) + val posted = notifier.notify(text, mode, contentIntent = null) + assertTrue("nothing was posted for $kind/$mode — is the permission granted?", posted) + + val notification = postedNotification() + assertNotNull("$kind/$mode was not delivered", notification) + + // The whole point: a private notification with no public version + // falls back to showing its private text on the lock screen. + val public = notification!!.publicVersion + assertNotNull("$kind/$mode attached no public version", public) + + assertEquals( + "$kind/$mode was not marked private", + Notification.VISIBILITY_PRIVATE, + notification.visibility, + ) + + val visible = public!!.textOf() + NotificationCopy.SENSITIVE_WORDS.forEach { word -> + assertTrue( + "$kind/$mode would have shown \"$word\" on a lock screen: \"$visible\"", + !visible.contains(word), + ) + } + } + } + } + + @Test + fun directModeIsTheOnlyOneMarkedPublic() { + val text = NotificationCopy.textFor( + ReminderKind.PERIOD_APPROACHING, NotificationPrivacy.DIRECT, 2, + ) + notifier.notify(text, NotificationPrivacy.DIRECT, contentIntent = null) + + val notification = postedNotification() + assertNotNull(notification) + assertEquals(Notification.VISIBILITY_PUBLIC, notification!!.visibility) + assertTrue(notification.textOf().contains("period")) + } + + @Test + fun noChannelNameSaysAnythingAboutPeriods() { + // Visible in Settings > Apps > Notifications, on a phone somebody else + // may be holding. + NotificationPrivacy.entries.forEach { mode -> + notifier.ensureChannel(mode) + val channel = manager.getNotificationChannel(PeriodNotifier.channelId(mode)) + assertNotNull("no channel for $mode", channel) + val name = channel!!.name.toString().lowercase() + NotificationCopy.SENSITIVE_WORDS.forEach { + assertTrue("the $mode channel is called \"$name\"", !name.contains(it)) + } + } + } + + @Test + fun discreetModeNeverPopsOverTheScreen() { + // A heads-up notification draws its content over whatever is showing, + // lock or no lock. High importance and Discreet are a contradiction. + notifier.ensureChannel(NotificationPrivacy.DISCREET) + val channel = manager.getNotificationChannel( + PeriodNotifier.channelId(NotificationPrivacy.DISCREET), + )!! + assertTrue( + "importance was ${channel.importance}", + channel.importance <= NotificationManager.IMPORTANCE_DEFAULT, + ) + + // And the separate channel per mode is what makes this stay true: a + // single channel keeps its first importance forever, so switching to + // Direct and back would leave Discreet popping over the screen. + notifier.ensureChannel(NotificationPrivacy.DIRECT) + assertEquals( + NotificationManager.IMPORTANCE_HIGH, + manager.getNotificationChannel(PeriodNotifier.channelId(NotificationPrivacy.DIRECT))!!.importance, + ) + } +} diff --git a/core/notifications/src/main/AndroidManifest.xml b/core/notifications/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c883d3c --- /dev/null +++ b/core/notifications/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/NotificationCopy.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/NotificationCopy.kt new file mode 100644 index 0000000..e16a959 --- /dev/null +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/NotificationCopy.kt @@ -0,0 +1,183 @@ +package dev.privacyllc.period.core.notifications + +import dev.privacyllc.period.core.datastore.NotificationPrivacy + +/** + * The six notification types §29 gives independent toggles to. + * + * Named for what they are *for* rather than when they fire, because the timing + * is `ReminderRules`' problem and the wording is this file's. + */ +enum class ReminderKind { + PERIOD_APPROACHING, + PERIOD_EXPECTED_TODAY, + DID_IT_START, + PERIOD_END_CHECK_IN, + FERTILE_WINDOW_APPROACHING, + OVULATION_ESTIMATED, +} + +/** What Android does with the notification on a locked screen. */ +enum class LockScreenVisibility { + /** Shown in full. Only ever used for [NotificationPrivacy.DIRECT]. */ + PUBLIC, + + /** The public version is shown instead, and it must be supplied. */ + PRIVATE, +} + +/** + * One notification, in two versions. + * + * The split is the whole feature. [publicTitle] and [publicBody] are what a + * person standing next to the phone can read; [privateTitle] and [privateBody] + * are what the owner sees once it is unlocked. + * + * Both are always produced, even in [NotificationPrivacy.DIRECT] where they are + * identical — because a nullable public version is one forgotten branch away + * from Android falling back to showing the private text, which is the exact + * failure this type exists to prevent. + */ +data class NotificationText( + val publicTitle: String, + val publicBody: String?, + val privateTitle: String, + val privateBody: String, + val visibility: LockScreenVisibility, +) + +/** + * §28's three modes, as a pure function. + * + * Pure on purpose. This is the one surface in the app whose mistakes are visible + * to somebody who is not the user, and a pure function is the only version of it + * that can be exhaustively tested without an emulator — so the test asserts, for + * every kind and both private modes, that no health word survives to the lock + * screen. + * + * ## What the words are chosen against + * + * Not just "does it name a period". A notification that says *"Checking in about + * your cycle"* leaks in a shoulder-glance exactly as much as one that says + * period, and one that arrives every 28 days leaks by its rhythm no matter what + * it says. Discreet copy is therefore deliberately dull and deliberately + * unspecific — "Quick check-in" could be anything, which is the point. + */ +object NotificationCopy { + + /** Words that must never reach a lock screen except in [NotificationPrivacy.DIRECT]. */ + val SENSITIVE_WORDS = listOf( + "period", "cycle", "fertile", "fertility", "ovulation", "ovulating", + "menstrual", "bleeding", "flow", "pms", + ) + + fun textFor( + kind: ReminderKind, + privacy: NotificationPrivacy, + daysUntil: Int? = null, + ): NotificationText { + val private = privateTextFor(kind, daysUntil) + + return when (privacy) { + // §28's default. Something is happening; nothing says what. + NotificationPrivacy.DISCREET -> NotificationText( + publicTitle = discreetTitle(kind), + publicBody = discreetBody(kind), + privateTitle = private.first, + privateBody = private.second, + visibility = LockScreenVisibility.PRIVATE, + ) + + // §28: no health information. Not "vague health information" — none. + // A body is omitted entirely rather than made bland, because a body + // is a second chance to leak and this mode exists for people who + // cannot afford one. + NotificationPrivacy.MAXIMUM_PRIVACY -> NotificationText( + publicTitle = "Reminder", + publicBody = null, + privateTitle = private.first, + privateBody = private.second, + visibility = LockScreenVisibility.PRIVATE, + ) + + // The only mode where the two versions are the same, and the only + // one the user has to choose deliberately. + NotificationPrivacy.DIRECT -> NotificationText( + publicTitle = private.first, + publicBody = private.second, + privateTitle = private.first, + privateBody = private.second, + visibility = LockScreenVisibility.PUBLIC, + ) + } + } + + /** + * Action labels, which are visible text on a lock screen too. + * + * §31 is explicit about this and it is easy to miss: the notification body + * can be perfectly discreet while a button underneath it says "Started my + * period". These are chosen to mean nothing out of context. + */ + fun actionLabels(kind: ReminderKind, privacy: NotificationPrivacy): List = + when (kind) { + ReminderKind.DID_IT_START, ReminderKind.PERIOD_EXPECTED_TODAY -> + if (privacy == NotificationPrivacy.DIRECT) listOf("Started", "Not yet") + else listOf("Yes", "Not yet") + + ReminderKind.PERIOD_END_CHECK_IN -> + if (privacy == NotificationPrivacy.DIRECT) listOf("Ended", "Still going") + else listOf("Done", "Not yet") + + else -> emptyList() + } + + private fun discreetTitle(kind: ReminderKind) = when (kind) { + ReminderKind.PERIOD_APPROACHING -> "A heads-up" + ReminderKind.PERIOD_EXPECTED_TODAY -> "Quick check-in" + ReminderKind.DID_IT_START -> "Checking in" + ReminderKind.PERIOD_END_CHECK_IN -> "Quick check-in" + ReminderKind.FERTILE_WINDOW_APPROACHING -> "A heads-up" + ReminderKind.OVULATION_ESTIMATED -> "A heads-up" + } + + private fun discreetBody(kind: ReminderKind) = when (kind) { + ReminderKind.PERIOD_APPROACHING -> "Something may be coming up later this week." + ReminderKind.PERIOD_EXPECTED_TODAY -> "Something may be coming up." + ReminderKind.DID_IT_START -> "Tap when you have a moment." + ReminderKind.PERIOD_END_CHECK_IN -> "Tap when you have a moment." + ReminderKind.FERTILE_WINDOW_APPROACHING -> "Something to note this week." + ReminderKind.OVULATION_ESTIMATED -> "Something to note today." + } + + /** Title and body for the unlocked device. This is where the app can be plain. */ + private fun privateTextFor(kind: ReminderKind, daysUntil: Int?): Pair = + when (kind) { + ReminderKind.PERIOD_APPROACHING -> "Period approaching" to + when { + daysUntil == null -> "Your period is likely soon." + daysUntil <= 1 -> "Your period is likely tomorrow." + else -> "Your period is likely in about $daysUntil days." + } + + ReminderKind.PERIOD_EXPECTED_TODAY -> "Did your period start?" to + "Your period may start today." + + ReminderKind.DID_IT_START -> "Did your period start?" to + "It has been a couple of days since your forecast." + + ReminderKind.PERIOD_END_CHECK_IN -> "Is your period over?" to + "Let us know so the estimate stays accurate." + + // "Estimated" is load-bearing in both of these — §17. + ReminderKind.FERTILE_WINDOW_APPROACHING -> "Estimated fertile window" to + when { + daysUntil == null -> "Your estimated fertile window is coming up." + daysUntil <= 1 -> "Your estimated fertile window starts tomorrow." + else -> "Your estimated fertile window starts in about $daysUntil days." + } + + ReminderKind.OVULATION_ESTIMATED -> "Estimated ovulation" to + "Today is your estimated ovulation day." + } +} diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/PeriodNotifier.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/PeriodNotifier.kt new file mode 100644 index 0000000..121e017 --- /dev/null +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/PeriodNotifier.kt @@ -0,0 +1,174 @@ +package dev.privacyllc.period.core.notifications + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import dev.privacyllc.period.core.datastore.NotificationPrivacy + +/** + * Posts a reminder, applying §28's privacy modes to the actual Android APIs. + * + * The copy is decided by [NotificationCopy]; this file's only job is to put the + * two versions in the right places, and getting that wrong is how a discreet + * app leaks. + * + * ## The three ways this leaks if you are careless + * + * 1. **A private notification with no public version.** Android does not blank + * it — it falls back to showing the private content. `setPublicVersion` is + * not optional, which is why [NotificationText] has no nullable title. + * 2. **High importance.** A heads-up notification draws its content over + * whatever is on screen, lock or no lock. Importance is therefore capped + * below heads-up unless the user chose DIRECT. + * 3. **The channel name.** It shows in system settings and in some launchers. + * "Reminders" says nothing; "Period reminders" says everything, to anyone who + * opens the notification settings on a shared phone. + */ +class PeriodNotifier(private val context: Context) { + + /** + * One channel per privacy mode, and that is not tidiness. + * + * **Android will not change a channel's importance or lock-screen + * visibility after it has been created.** A single channel therefore keeps + * whatever settings it had on the day it was made, so a user switching from + * Direct to Maximum privacy would keep getting the old behaviour forever — + * the setting would appear to work and change nothing. + * + * Found by an instrumented test that created the channel in one mode and + * asserted the next mode's visibility; the second assertion failed against + * a channel that had quietly kept the first mode's settings. + * + * Separate channels also give the user something better: three switches in + * system settings they can mute independently. + */ + fun ensureChannel(privacy: NotificationPrivacy) { + val manager = context.getSystemService(NotificationManager::class.java) ?: return + + val channel = NotificationChannel( + channelId(privacy), + channelName(privacy), + // DEFAULT never pops over the screen. Only DIRECT, which the user + // chose explicitly, gets the more insistent behaviour. + if (privacy == NotificationPrivacy.DIRECT) { + NotificationManager.IMPORTANCE_HIGH + } else { + NotificationManager.IMPORTANCE_DEFAULT + }, + ).apply { + description = "Reminders you have turned on." + lockscreenVisibility = when (privacy) { + NotificationPrivacy.DIRECT -> Notification.VISIBILITY_PUBLIC + else -> Notification.VISIBILITY_PRIVATE + } + setShowBadge(false) + } + manager.createNotificationChannel(channel) + } + + /** + * Post [text]. Returns false when the permission is absent — silently doing + * nothing would look identical to a scheduling bug. + */ + fun notify( + text: NotificationText, + privacy: NotificationPrivacy, + contentIntent: android.app.PendingIntent?, + actions: List = emptyList(), + ): Boolean { + // Checked inline rather than through hasPermission(), so lint can see + // it. A helper method is equivalent to a human reader and invisible to + // the analyser, and MissingPermission is one of the few lint errors + // worth taking literally: getting it wrong is a silent no-op in + // production, which looks exactly like a scheduling bug. + if (android.os.Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + return false + } + ensureChannel(privacy) + + // The public version is a whole second notification, and it is what a + // locked screen renders. Built first so it cannot be forgotten. + val public = NotificationCompat.Builder(context, channelId(privacy)) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(text.publicTitle) + .apply { text.publicBody?.let { setContentText(it) } } + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .build() + + val notification = NotificationCompat.Builder(context, channelId(privacy)) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(text.privateTitle) + .setContentText(text.privateBody) + .setStyle(NotificationCompat.BigTextStyle().bigText(text.privateBody)) + .setAutoCancel(true) + .setVisibility( + when (text.visibility) { + LockScreenVisibility.PUBLIC -> NotificationCompat.VISIBILITY_PUBLIC + LockScreenVisibility.PRIVATE -> NotificationCompat.VISIBILITY_PRIVATE + }, + ) + .setPublicVersion(public) + .apply { + contentIntent?.let { setContentIntent(it) } + actions.forEach { addAction(it) } + } + .build() + + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) + return true + } + + fun hasPermission(): Boolean = + android.os.Build.VERSION.SDK_INT < 33 || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + + companion object { + /** + * Channel ids, and the names beside them. + * + * Every name is deliberately dull. These strings appear in + * Settings > Apps > Notifications, on a phone somebody else may be + * holding — "Period reminders" would say everything the rest of this + * class works to keep quiet. + */ + fun channelId(privacy: NotificationPrivacy): String = when (privacy) { + NotificationPrivacy.DISCREET -> "reminders_discreet" + NotificationPrivacy.MAXIMUM_PRIVACY -> "reminders_minimal" + NotificationPrivacy.DIRECT -> "reminders_direct" + } + + fun channelName(privacy: NotificationPrivacy): String = when (privacy) { + NotificationPrivacy.DISCREET -> "Reminders" + NotificationPrivacy.MAXIMUM_PRIVACY -> "Reminders (minimal)" + NotificationPrivacy.DIRECT -> "Reminders (detailed)" + } + + /** One id: a reminder replaces the previous one rather than stacking. */ + const val NOTIFICATION_ID = 1001 + + /** Actions come back through the app, so the tap lands on the check-in screen. */ + const val ACTION_STARTED = "dev.privacyllc.period.action.STARTED" + const val ACTION_NOT_YET = "dev.privacyllc.period.action.NOT_YET" + const val EXTRA_FROM_NOTIFICATION = "from_notification" + + @Suppress("UNUSED_PARAMETER") + fun launchIntent(context: Context, action: String? = null): Intent = + Intent().apply { + setClassName(context, "dev.privacyllc.period.MainActivity") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(EXTRA_FROM_NOTIFICATION, true) + action?.let { putExtra("reminder_action", it) } + } + } +} diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderRules.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderRules.kt new file mode 100644 index 0000000..6bdf2d3 --- /dev/null +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderRules.kt @@ -0,0 +1,141 @@ +package dev.privacyllc.period.core.notifications + +import dev.privacyllc.period.domain.prediction.CycleStatus +import dev.privacyllc.period.domain.prediction.FertilityEstimate +import java.time.LocalDate + +/** The six toggles from §29, as the rules see them. */ +data class ReminderPreferences( + val periodApproaching: Boolean = true, + val periodExpectedToday: Boolean = true, + val didItStart: Boolean = true, + val periodEndCheckIn: Boolean = true, + val fertileWindowApproaching: Boolean = false, + val ovulationEstimated: Boolean = false, +) + +/** What a wake-up should do. */ +sealed interface ReminderDecision { + data class Send(val kind: ReminderKind, val daysUntil: Int? = null) : ReminderDecision + + /** + * §30's last word: *"We'll stop checking for now. Log your period whenever + * it begins."* + * + * Sent once, and then nothing until something changes. The sentence after it + * in §30 is the design — *the internal prediction may continue updating* — + * so this stops the app **asking**, never the engine learning. + */ + data object StopAsking : ReminderDecision + + data object Nothing : ReminderDecision +} + +/** + * When a reminder is due, decided from state the rest of the app already + * computed. + * + * Pure, and deliberately does no cycle arithmetic of its own. `CycleStatusRules` + * already decides which of §22's states the user is in, and a second copy of + * that logic living inside a background worker is the classic way for a + * notification to contradict the screen it deep-links to. + */ +object ReminderRules { + + /** How many days before the forecast the heads-up goes out. §30's example uses three. */ + const val APPROACHING_LEAD_DAYS = 3 + + /** Days into an unclosed period before asking whether it is over. */ + const val END_CHECK_IN_AFTER_DAYS = 5 + + /** + * How many times the app asks before it stops. + * + * §30: *do not nag forever*. Three is a judgement, not a measurement — the + * predicted day, and two follow-ups. What matters is that a number exists + * and the app says something kind when it reaches it, rather than asking a + * woman every morning whether her period has started. + */ + const val MAX_CHECK_INS = 3 + + /** How close to the fertile window the heads-up fires. */ + const val FERTILE_LEAD_DAYS = 2 + + fun decide( + status: CycleStatus, + preferences: ReminderPreferences, + checkInsSoFar: Int, + fertility: FertilityEstimate?, + today: LocalDate, + ): ReminderDecision { + // Period first, always. Fertility is an estimate about something that + // might happen; a period arriving is the thing the user opened the app + // for, and two notifications in one morning is how both get muted. + periodDecision(status, preferences, checkInsSoFar)?.let { return it } + fertilityDecision(preferences, fertility, today)?.let { return it } + return ReminderDecision.Nothing + } + + private fun periodDecision( + status: CycleStatus, + preferences: ReminderPreferences, + checkInsSoFar: Int, + ): ReminderDecision? = when (status) { + is CycleStatus.DuringPeriod -> + if (preferences.periodEndCheckIn && + status.endedOn == null && + status.dayOfPeriod >= END_CHECK_IN_AFTER_DAYS + ) { + ReminderDecision.Send(ReminderKind.PERIOD_END_CHECK_IN) + } else { + null + } + + is CycleStatus.PeriodApproaching -> + if (preferences.periodApproaching && status.daysUntil == APPROACHING_LEAD_DAYS) { + ReminderDecision.Send(ReminderKind.PERIOD_APPROACHING, status.daysUntil) + } else { + null + } + + is CycleStatus.PredictedDay -> + if (preferences.periodExpectedToday) { + ReminderDecision.Send(ReminderKind.PERIOD_EXPECTED_TODAY, 0) + } else { + null + } + + is CycleStatus.BeyondForecast -> when { + !preferences.didItStart -> null + // The stopping message goes out exactly once, on the wake-up that + // crosses the limit. After that the count keeps rising and this + // returns null, so nothing more is sent. + checkInsSoFar == MAX_CHECK_INS -> ReminderDecision.StopAsking + checkInsSoFar > MAX_CHECK_INS -> null + else -> ReminderDecision.Send(ReminderKind.DID_IT_START) + } + + else -> null + } + + private fun fertilityDecision( + preferences: ReminderPreferences, + fertility: FertilityEstimate?, + today: LocalDate, + ): ReminderDecision? { + fertility ?: return null + + if (preferences.ovulationEstimated && today == fertility.ovulationDate) { + return ReminderDecision.Send(ReminderKind.OVULATION_ESTIMATED) + } + + if (preferences.fertileWindowApproaching) { + val daysToWindow = (fertility.fertileWindowStart.toEpochDay() - today.toEpochDay()).toInt() + if (daysToWindow in 1..FERTILE_LEAD_DAYS) { + return ReminderDecision.Send(ReminderKind.FERTILE_WINDOW_APPROACHING, daysToWindow) + } + } + + return null + } +} diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderScheduler.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderScheduler.kt new file mode 100644 index 0000000..51a5cbd --- /dev/null +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderScheduler.kt @@ -0,0 +1,93 @@ +package dev.privacyllc.period.core.notifications + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.util.concurrent.TimeUnit + +/** + * Schedules the daily reminder check with WorkManager. + * + * §31: **no exact alarms.** A period reminder does not need alarm-clock + * precision, and `SCHEDULE_EXACT_ALARM` is Play scrutiny bought for nothing — + * so this is a periodic work request with a flex window, which the system is + * free to move by minutes. Nobody notices a reminder arriving at 10:07 instead + * of 10:00; everybody notices an app asking for an alarm permission. + * + * The work is `KEEP`-replaced on every reschedule rather than cancelled and + * re-enqueued, so a forecast that moves twice in a minute does not produce two + * pending workers. + */ +class ReminderScheduler(private val context: Context) { + + /** + * (Re)schedule the daily check for [reminderTime]. + * + * Called on every change that could move a reminder: the forecast moving, a + * toggle changing, the reminder time changing. §31 lists rescheduling after + * a confirmation or a forecast change explicitly, and it is the requirement + * most likely to be missed — a "Not yet" moves the forecast, so a reminder + * aimed at the old one is now aimed at the wrong day. + */ + fun schedule(reminderTime: LocalTime, zone: ZoneId = ZoneId.systemDefault()) { + val request = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .setInitialDelay(delayUntil(reminderTime, zone).toMinutes(), TimeUnit.MINUTES) + .setConstraints( + // No network, no charging, no idle. A reminder that waits for + // Wi-Fi is a reminder that arrives on Tuesday. + Constraints.Builder().build(), + ) + .addTag(TAG) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + } + + fun cancel() { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } + + companion object { + const val WORK_NAME = "period-daily-reminder" + const val TAG = "reminder" + + /** + * Time from now until the next [target] o'clock. + * + * Pure and internal so it can be tested without a clock or a device — + * off-by-a-day here means a user's first reminder arrives tomorrow + * instead of today, which is exactly the kind of thing nobody notices + * until they are the user. + */ + internal fun delayUntil( + target: LocalTime, + zone: ZoneId, + now: LocalDateTime = LocalDateTime.now(zone), + ): Duration { + val todayAt = LocalDateTime.of(now.toLocalDate(), target) + val next = if (todayAt.isAfter(now)) todayAt else LocalDateTime.of( + now.toLocalDate().plusDays(1), + target, + ) + return Duration.between(now, next) + } + + internal fun nextRunDate(target: LocalTime, now: LocalDateTime): LocalDate = + if (LocalDateTime.of(now.toLocalDate(), target).isAfter(now)) { + now.toLocalDate() + } else { + now.toLocalDate().plusDays(1) + } + } +} diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderWorker.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderWorker.kt new file mode 100644 index 0000000..9413528 --- /dev/null +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderWorker.kt @@ -0,0 +1,142 @@ +package dev.privacyllc.period.core.notifications + +import android.app.PendingIntent +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import dev.privacyllc.period.core.data.CycleRepository +import dev.privacyllc.period.core.datastore.UserPreferences +import dev.privacyllc.period.core.datastore.UserPreferencesRepository +import dev.privacyllc.period.domain.prediction.CycleStatusRules +import dev.privacyllc.period.domain.prediction.FertilityEstimate +import kotlinx.coroutines.flow.first +import java.time.Clock +import java.time.LocalDate + +/** + * The daily wake-up. + * + * It reads state and asks [ReminderRules] what to do — it does **no cycle + * arithmetic of its own**, deliberately. A worker that recomputes "is a period + * due" is a second implementation of `CycleStatusRules`, and the first time the + * two disagree the user gets a notification that contradicts the screen it opens. + * + * Nothing here logs a cycle date. §45's rule applies to background work exactly + * as much as to the UI, and a `Log.d` in a worker is the kind that survives to + * production because nobody sees it in testing. + */ +@HiltWorker +class ReminderWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val repository: CycleRepository, + private val preferences: UserPreferencesRepository, + private val clock: Clock, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val prefs = preferences.preferences.first() + val today = LocalDate.now(clock) + + val periods = repository.confirmedPeriods.first() + val forecast = repository.forecast.first() + val notYet = repository.notYetObservations.first() + val fertility = FertilityEstimate.from(forecast) + + val status = CycleStatusRules.statusFor( + periods = periods, + forecast = forecast, + today = today, + fertileWindow = fertility?.fertileWindow, + ovulation = fertility?.ovulationDate, + originalForecastDate = notYet.minOfOrNull { it.date }, + ) + + val decision = ReminderRules.decide( + status = status, + preferences = prefs.toReminderPreferences(), + checkInsSoFar = preferences.checkInCount.first(), + fertility = fertility, + today = today, + ) + + val notifier = PeriodNotifier(applicationContext) + + when (decision) { + ReminderDecision.Nothing -> return Result.success() + + ReminderDecision.StopAsking -> { + // §30's exact sentiment, and it is the only notification in the + // app that exists to say the app will now be quiet. + notifier.notify( + text = NotificationText( + publicTitle = if (prefs.notificationPrivacy == + dev.privacyllc.period.core.datastore.NotificationPrivacy.DIRECT + ) "We'll stop checking for now" else "Reminder", + publicBody = null, + privateTitle = "We'll stop checking for now", + privateBody = "Log your period whenever it begins.", + visibility = LockScreenVisibility.PRIVATE, + ), + privacy = prefs.notificationPrivacy, + contentIntent = openApp(), + ) + // Counted, so the next wake-up sees a number past the limit and + // sends nothing. + preferences.recordCheckIn() + return Result.success() + } + + is ReminderDecision.Send -> { + val text = NotificationCopy.textFor(decision.kind, prefs.notificationPrivacy, decision.daysUntil) + val labels = NotificationCopy.actionLabels(decision.kind, prefs.notificationPrivacy) + + notifier.notify( + text = text, + privacy = prefs.notificationPrivacy, + contentIntent = openApp(), + actions = labels.mapIndexed { index, label -> + NotificationCompat.Action.Builder( + 0, + label, + openApp( + if (index == 0) PeriodNotifier.ACTION_STARTED else PeriodNotifier.ACTION_NOT_YET, + ), + ).build() + }, + ) + + // Only the "did it start" family counts toward the stopping + // rule. A heads-up three days out is not the app pestering. + if (decision.kind == ReminderKind.DID_IT_START || + decision.kind == ReminderKind.PERIOD_EXPECTED_TODAY + ) { + preferences.recordCheckIn() + } + return Result.success() + } + } + } + + private fun openApp(action: String? = null): PendingIntent = + PendingIntent.getActivity( + applicationContext, + action?.hashCode() ?: 0, + PeriodNotifier.launchIntent(applicationContext, action), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) +} + +/** The six toggles, as the rules want them. */ +fun UserPreferences.toReminderPreferences() = ReminderPreferences( + periodApproaching = periodApproachingEnabled, + periodExpectedToday = periodExpectedTodayEnabled, + didItStart = didItStartEnabled, + periodEndCheckIn = periodEndCheckInEnabled, + fertileWindowApproaching = fertileWindowReminderEnabled, + ovulationEstimated = ovulationReminderEnabled, +) diff --git a/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/NotificationCopyTest.kt b/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/NotificationCopyTest.kt new file mode 100644 index 0000000..100f6ce --- /dev/null +++ b/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/NotificationCopyTest.kt @@ -0,0 +1,158 @@ +package dev.privacyllc.period.core.notifications + +import dev.privacyllc.period.core.datastore.NotificationPrivacy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The privacy modes, checked exhaustively. + * + * This is the only surface in the app whose mistakes are visible to somebody who + * is not the user, so the tests are exhaustive rather than representative: every + * kind × every mode, not a couple of examples. It runs as a plain JUnit test — + * no Robolectric, no emulator — which is the reason the copy is a pure function + * in the first place. + */ +class NotificationCopyTest { + + private val privateModes = listOf(NotificationPrivacy.DISCREET, NotificationPrivacy.MAXIMUM_PRIVACY) + + private fun NotificationText.lockScreenText() = (publicTitle + " " + (publicBody ?: "")).lowercase() + + // ----------------------------------------------------------------------- + // The rule the whole feature exists for + // ----------------------------------------------------------------------- + + @Test fun `no health word ever reaches a lock screen except in Direct`() { + ReminderKind.entries.forEach { kind -> + privateModes.forEach { mode -> + val text = NotificationCopy.textFor(kind, mode, daysUntil = 2).lockScreenText() + NotificationCopy.SENSITIVE_WORDS.forEach { word -> + assertTrue( + "$kind in $mode put \"$word\" on the lock screen: \"$text\"", + !text.contains(word), + ) + } + } + } + } + + @Test fun `maximum privacy says nothing but the word Reminder`() { + // §28: no health information. Not vague health information — none. The + // body is omitted rather than made bland, because a body is a second + // chance to leak and this mode exists for people who cannot afford one. + ReminderKind.entries.forEach { kind -> + val text = NotificationCopy.textFor(kind, NotificationPrivacy.MAXIMUM_PRIVACY, 2) + assertEquals("Reminder", text.publicTitle) + assertNull("$kind supplied a body in maximum privacy", text.publicBody) + } + } + + @Test fun `Direct is the only mode that says anything on the lock screen`() { + val direct = NotificationCopy.textFor( + ReminderKind.PERIOD_APPROACHING, NotificationPrivacy.DIRECT, 2, + ) + assertTrue(direct.lockScreenText().contains("period")) + assertEquals(LockScreenVisibility.PUBLIC, direct.visibility) + + privateModes.forEach { + assertEquals( + LockScreenVisibility.PRIVATE, + NotificationCopy.textFor(ReminderKind.PERIOD_APPROACHING, it, 2).visibility, + ) + } + } + + @Test fun `every kind and mode supplies a public version`() { + // Android falls back to showing the PRIVATE text when a notification is + // marked private and no public version was set. A missing public title + // is therefore not an empty lock screen — it is a full one. + ReminderKind.entries.forEach { kind -> + NotificationPrivacy.entries.forEach { mode -> + val text = NotificationCopy.textFor(kind, mode, 2) + assertNotNull("$kind/$mode", text.publicTitle) + assertTrue("$kind/$mode public title was blank", text.publicTitle.isNotBlank()) + } + } + } + + // ----------------------------------------------------------------------- + // Action labels are visible text too — §31 + // ----------------------------------------------------------------------- + + @Test fun `action labels carry no health information outside Direct`() { + // The body can be perfectly discreet while a button underneath says + // "Started my period". §31 calls this out and it is easy to miss. + ReminderKind.entries.forEach { kind -> + privateModes.forEach { mode -> + NotificationCopy.actionLabels(kind, mode).forEach { label -> + NotificationCopy.SENSITIVE_WORDS.forEach { word -> + assertTrue( + "$kind in $mode had action label \"$label\"", + !label.lowercase().contains(word), + ) + } + } + } + } + } + + @Test fun `the check-in offers a yes and a not yet`() { + val labels = NotificationCopy.actionLabels( + ReminderKind.PERIOD_EXPECTED_TODAY, NotificationPrivacy.DISCREET, + ) + assertEquals(2, labels.size) + assertTrue(labels.any { it.lowercase().contains("not yet") }) + } + + @Test fun `kinds with nothing to answer have no actions`() { + assertTrue( + NotificationCopy.actionLabels( + ReminderKind.OVULATION_ESTIMATED, NotificationPrivacy.DISCREET, + ).isEmpty(), + ) + } + + // ----------------------------------------------------------------------- + // The private side is allowed to be plain, and still has rules + // ----------------------------------------------------------------------- + + @Test fun `the in-app text is specific about days`() { + val two = NotificationCopy.textFor(ReminderKind.PERIOD_APPROACHING, NotificationPrivacy.DISCREET, 2) + assertTrue(two.privateBody.contains("2 days")) + + val one = NotificationCopy.textFor(ReminderKind.PERIOD_APPROACHING, NotificationPrivacy.DISCREET, 1) + assertTrue("one day should read as tomorrow", one.privateBody.contains("tomorrow")) + + val unknown = NotificationCopy.textFor(ReminderKind.PERIOD_APPROACHING, NotificationPrivacy.DISCREET, null) + assertTrue(unknown.privateBody.isNotBlank()) + } + + @Test fun `fertility notifications always say estimated`() { + // §17. The word is load-bearing, and a notification is the easiest place + // to drop it for brevity. + listOf(ReminderKind.FERTILE_WINDOW_APPROACHING, ReminderKind.OVULATION_ESTIMATED).forEach { kind -> + val text = NotificationCopy.textFor(kind, NotificationPrivacy.DIRECT, 3) + assertTrue( + "$kind said it without \"estimated\": ${text.privateTitle} / ${text.privateBody}", + (text.privateTitle + text.privateBody).lowercase().contains("estimated"), + ) + } + } + + @Test fun `no copy anywhere claims certainty about fertility`() { + ReminderKind.entries.forEach { kind -> + NotificationPrivacy.entries.forEach { mode -> + val t = NotificationCopy.textFor(kind, mode, 2) + val all = (t.publicTitle + " " + (t.publicBody ?: "") + " " + t.privateTitle + " " + t.privateBody) + .lowercase() + listOf("you are ovulating", "safe", "unsafe", "guaranteed").forEach { phrase -> + assertTrue("$kind/$mode contained \"$phrase\"", !all.contains(phrase)) + } + } + } + } +} diff --git a/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/ReminderRulesTest.kt b/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/ReminderRulesTest.kt new file mode 100644 index 0000000..de72d78 --- /dev/null +++ b/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/ReminderRulesTest.kt @@ -0,0 +1,166 @@ +package dev.privacyllc.period.core.notifications + +import dev.privacyllc.period.domain.prediction.ConfidenceLabel +import dev.privacyllc.period.domain.prediction.CycleStatus +import dev.privacyllc.period.domain.prediction.FertilityEstimate +import dev.privacyllc.period.domain.prediction.Prediction +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * §29 and §30. The test that matters most is the one about stopping. + */ +class ReminderRulesTest { + + private val today = LocalDate.of(2026, 8, 18) + + private fun forecast(on: LocalDate) = Prediction( + mostLikelyStartDate = on, + windowStart = on.minusDays(1), + windowEnd = on.plusDays(1), + confidenceScore = 0.6, + confidenceLabel = ConfidenceLabel.MEDIUM, + modelVersion = "test", + ) + + private fun decide( + status: CycleStatus, + prefs: ReminderPreferences = ReminderPreferences(), + checkIns: Int = 0, + fertility: FertilityEstimate? = null, + ) = ReminderRules.decide(status, prefs, checkIns, fertility, today) + + private fun approaching(days: Int) = + CycleStatus.PeriodApproaching(20, days, forecast(today.plusDays(days.toLong()))) + + private fun beyond() = CycleStatus.BeyondForecast( + cycleDay = 32, + daysPast = 2, + originalForecast = today.minusDays(2), + updated = forecast(today.plusDays(1)), + ) + + // ----------------------------------------------------------------------- + // §30 — do not nag forever + // ----------------------------------------------------------------------- + + @Test fun `the app asks a bounded number of times and then says it will stop`() { + // The whole reason this rule exists: asking a woman every morning + // whether her period has started is the app being anxious at her. + repeat(ReminderRules.MAX_CHECK_INS) { n -> + val d = decide(beyond(), checkIns = n) + assertTrue("check-in $n should still ask, got $d", d is ReminderDecision.Send) + assertEquals(ReminderKind.DID_IT_START, (d as ReminderDecision.Send).kind) + } + + assertEquals( + ReminderDecision.StopAsking, + decide(beyond(), checkIns = ReminderRules.MAX_CHECK_INS), + ) + } + + @Test fun `after the stopping message nothing further is sent`() { + // Exactly once. A "we'll stop checking" that arrives three days running + // is worse than the nagging it replaced. + (ReminderRules.MAX_CHECK_INS + 1..ReminderRules.MAX_CHECK_INS + 5).forEach { n -> + assertEquals("at $n", ReminderDecision.Nothing, decide(beyond(), checkIns = n)) + } + } + + // ----------------------------------------------------------------------- + // §30's flow + // ----------------------------------------------------------------------- + + @Test fun `the heads-up goes out a few days before and not every day`() { + assertTrue(decide(approaching(ReminderRules.APPROACHING_LEAD_DAYS)) is ReminderDecision.Send) + // Not on the days either side, or the user gets four notifications for + // one period. + assertEquals(ReminderDecision.Nothing, decide(approaching(ReminderRules.APPROACHING_LEAD_DAYS + 1))) + assertEquals(ReminderDecision.Nothing, decide(approaching(ReminderRules.APPROACHING_LEAD_DAYS - 1))) + } + + @Test fun `the predicted day asks whether it started`() { + val d = decide(CycleStatus.PredictedDay(28, forecast(today))) + assertEquals(ReminderKind.PERIOD_EXPECTED_TODAY, (d as ReminderDecision.Send).kind) + } + + @Test fun `a period that has run a few days asks whether it is over`() { + val early = CycleStatus.DuringPeriod(2, today.minusDays(1), null, null) + assertEquals(ReminderDecision.Nothing, decide(early)) + + val later = CycleStatus.DuringPeriod( + ReminderRules.END_CHECK_IN_AFTER_DAYS, today.minusDays(4), null, null, + ) + assertEquals(ReminderKind.PERIOD_END_CHECK_IN, (decide(later) as ReminderDecision.Send).kind) + } + + @Test fun `a period already marked ended is not asked about`() { + val ended = CycleStatus.DuringPeriod(6, today.minusDays(5), today, null) + assertEquals(ReminderDecision.Nothing, decide(ended)) + } + + // ----------------------------------------------------------------------- + // §29 — the toggles are independent + // ----------------------------------------------------------------------- + + @Test fun `every type can be switched off on its own`() { + assertEquals( + ReminderDecision.Nothing, + decide(approaching(3), ReminderPreferences(periodApproaching = false)), + ) + assertEquals( + ReminderDecision.Nothing, + decide(CycleStatus.PredictedDay(28, forecast(today)), ReminderPreferences(periodExpectedToday = false)), + ) + assertEquals( + ReminderDecision.Nothing, + decide(beyond(), ReminderPreferences(didItStart = false), checkIns = 0), + ) + } + + @Test fun `fertility reminders are off unless asked for`() { + val fertility = FertilityEstimate.from(forecast(today.plusDays(14)))!! + val onOvulation = fertility.ovulationDate + + // Default preferences: fertility off. Somebody not tracking fertility + // should never be told about ovulation. + assertEquals( + ReminderDecision.Nothing, + ReminderRules.decide( + CycleStatus.BetweenPeriodAndFertile(14, 14, null), + ReminderPreferences(), + 0, + fertility, + onOvulation, + ), + ) + + val d = ReminderRules.decide( + CycleStatus.BetweenPeriodAndFertile(14, 14, null), + ReminderPreferences(ovulationEstimated = true), + 0, + fertility, + onOvulation, + ) + assertEquals(ReminderKind.OVULATION_ESTIMATED, (d as ReminderDecision.Send).kind) + } + + @Test fun `a period notification outranks a fertility one on the same day`() { + // Two notifications in one morning is how both get muted. + val fertility = FertilityEstimate.from(forecast(today.plusDays(14)))!! + val d = ReminderRules.decide( + CycleStatus.PredictedDay(28, forecast(today)), + ReminderPreferences(ovulationEstimated = true), + 0, + fertility, + fertility.ovulationDate, + ) + assertEquals(ReminderKind.PERIOD_EXPECTED_TODAY, (d as ReminderDecision.Send).kind) + } + + @Test fun `no history means no reminders at all`() { + assertEquals(ReminderDecision.Nothing, decide(CycleStatus.NoData)) + } +} diff --git a/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/ReminderSchedulerTest.kt b/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/ReminderSchedulerTest.kt new file mode 100644 index 0000000..bec2814 --- /dev/null +++ b/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/ReminderSchedulerTest.kt @@ -0,0 +1,52 @@ +package dev.privacyllc.period.core.notifications + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId + +/** + * The arithmetic behind "wake me at ten". + * + * Off by a day here means somebody's first reminder arrives tomorrow instead of + * today — the kind of thing nobody notices until they are the user with the + * missed period. + */ +class ReminderSchedulerTest { + + private val zone = ZoneId.of("UTC") + + @Test fun `a time later today waits until today`() { + val now = LocalDateTime.of(2026, 8, 18, 8, 0) + val d = ReminderScheduler.delayUntil(LocalTime.of(10, 0), zone, now) + assertEquals(120, d.toMinutes()) + assertEquals(LocalDate.of(2026, 8, 18), ReminderScheduler.nextRunDate(LocalTime.of(10, 0), now)) + } + + @Test fun `a time already past today waits until tomorrow`() { + val now = LocalDateTime.of(2026, 8, 18, 11, 0) + val d = ReminderScheduler.delayUntil(LocalTime.of(10, 0), zone, now) + assertEquals(23 * 60, d.toMinutes()) + assertEquals(LocalDate.of(2026, 8, 19), ReminderScheduler.nextRunDate(LocalTime.of(10, 0), now)) + } + + @Test fun `the exact minute counts as past, not now`() { + // Scheduling a zero delay for "right now" fires immediately and then + // again in 24 hours, which is two reminders on the first day. + val now = LocalDateTime.of(2026, 8, 18, 10, 0) + assertEquals(24 * 60, ReminderScheduler.delayUntil(LocalTime.of(10, 0), zone, now).toMinutes()) + } + + @Test fun `the delay is never negative and never beyond a day`() { + listOf(LocalTime.MIDNIGHT, LocalTime.of(6, 30), LocalTime.of(23, 59)).forEach { target -> + (0..23).forEach { hour -> + val now = LocalDateTime.of(2026, 8, 18, hour, 17) + val minutes = ReminderScheduler.delayUntil(target, zone, now).toMinutes() + assertTrue("$target at $hour gave $minutes", minutes in 0..(24 * 60)) + } + } + } +} diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c089ed1..28d2620 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -42,6 +42,7 @@ way, with the batch that needs it. | `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 | | `core/data` | Android library | `CycleRepository`, entity⇄domain mapping, accuracy — the only module that touches a DAO | `core/database`, `domain/cycle`, `domain/prediction` | +| `core/notifications` | Android library | reminder copy, the privacy modes, WorkManager scheduling | `core/data`, `core/datastore`, `domain/*` | | `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` | @@ -219,6 +220,39 @@ 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. +### Permissions are a declared set, not whatever the build produces + +The Play listing shows them, the Data Safety form describes them, and a +privacy-first period tracker is judged on them before anybody opens the app. It +is also the list most likely to grow without anyone deciding to: adding +WorkManager brought four permissions in one line — `WAKE_LOCK`, +`ACCESS_NETWORK_STATE`, `RECEIVE_BOOT_COMPLETED`, `FOREGROUND_SERVICE` — none +typed by anybody. + +`checkPermissions` in the root `build.gradle.kts` holds the allowed set and a +forbidden set, checks the **release** manifest as well as debug, and fails on +anything outside either. §31's exact-alarm permissions are in the forbidden list +by name. + +It failed its own first proof too: without a `dependsOn` on the manifest task it +read whatever was left from a previous build, so an injected +`SCHEDULE_EXACT_ALARM` went unnoticed. Both directions are proved now. + +### Notifications, and the two ways privacy leaks through Android + +`NotificationCopy` is a pure function — mode plus kind plus day count in, two +versions of the text out — so every combination is tested without an emulator. +`PeriodNotifier` maps that onto Android, and the mapping is where the leaks are: + +- **A private notification with no public version** does not blank the lock + screen, it shows the private text. `setPublicVersion` is mandatory, which is + why `NotificationText` has no nullable title. +- **A channel is immutable after creation.** Importance and lock-screen + visibility cannot be changed, so one shared channel would keep whatever the + user's first privacy mode set forever — the setting would appear to work and + change nothing. There is one channel per mode. Found by an instrumented test, + not by reading the docs. + ### The prediction engine `PersonalPredictionEngine` (`modelVersion` `personal-1`) is what the app ships. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae5a5cc..bc672a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ robolectric = "4.16.1" androidxTestCore = "1.7.0" testRunner = "1.7.0" datastore = "1.2.1" +work = "2.11.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -44,12 +45,15 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltNavigationCompose" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } 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-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled", version.ref = "sqlite" } @@ -58,6 +62,7 @@ androidx-test-core = { group = "androidx.test", name = "core", version.ref = "an junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "testRunner" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "testRunner" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 83df26c..10d8cff 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,5 +29,6 @@ include(":core:designsystem") include(":core:database") include(":core:datastore") include(":core:data") +include(":core:notifications") include(":domain:cycle") include(":domain:prediction")