feat: reminders that stay quiet on a lock screen

§28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus
kind plus day count in, two versions of the text out — so every combination is
tested exhaustively without an emulator. This is the one surface whose mistakes
are visible to somebody who is not the user, so the tests are exhaustive rather
than representative: every kind × every mode asserts that no health word reaches
a lock screen outside Direct, and that includes the ACTION LABELS, which §31
points out are visible text too. A perfectly discreet body under a button
reading "Started my period" leaks anyway.

TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS

A private notification with no public version does not blank the lock screen —
it shows the private text. NotificationText therefore has no nullable title and
an instrumented test asserts every kind attaches one.

And a notification channel is IMMUTABLE after creation: importance and
lock-screen visibility cannot be changed. One shared channel would have kept
whatever the user's first privacy mode set, forever — switching from Direct to
Maximum privacy would have appeared to work and changed nothing. There is now
one channel per mode. Found by an instrumented test on a device; nothing in the
unit tests could have seen it.

§30's stopping rule is a test of its own: the app asks a bounded number of times,
says "We'll stop checking for now. Log your period whenever it begins.", and
then says nothing more — while the engine keeps learning, which is the sentence
§30 puts right after it.

WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions
task fails the build if one ever appears in the merged manifest — from here or
from a dependency. That guard also failed its own first proof, reading a stale
manifest because it did not depend on the task that writes one.

ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for
and is the requirement most likely to be missed: a "Not yet" moves the forecast,
so work queued against the old one is aimed at a day that no longer means
anything.

188 unit tests and 6 instrumented, all passing. ./gradlew check green.

closes #24
closes #25
closes #26
closes #27
This commit is contained in:
null 2026-08-18 15:26:59 -05:00
parent 270de7b90f
commit 99dbc36802
29 changed files with 2084 additions and 22 deletions

View File

@ -58,6 +58,7 @@ dependencies {
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":core:datastore")) implementation(project(":core:datastore"))
implementation(project(":core:notifications"))
implementation(project(":domain:cycle")) implementation(project(":domain:cycle"))
implementation(project(":domain:prediction")) implementation(project(":domain:prediction"))
@ -79,7 +80,10 @@ dependencies {
implementation(libs.hilt.android) implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose) implementation(libs.hilt.navigation.compose)
implementation(libs.androidx.hilt.work)
implementation(libs.androidx.work.runtime)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
ksp(libs.androidx.hilt.compiler)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)

View File

@ -1,5 +1,13 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
Asked for at the moment a reminder is switched on, never on first launch —
§31. A permission dialog before the user has seen the app is a dialog
answered "deny" out of reflex.
-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- <!--
No INTERNET permission. The core tracker is offline by design No INTERNET permission. The core tracker is offline by design
@ -22,6 +30,22 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Period"> android:theme="@style/Theme.Period">
<!--
WorkManager is initialised by PeriodApplication (Configuration.Provider)
so Hilt can construct the reminder worker. The default initializer has
to be removed or it runs first and the custom factory never applies.
-->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@ -15,7 +15,11 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
PeriodTheme { PeriodTheme {
PeriodRoot() // The action tapped on a notification, if the app was opened by
// one. Nothing is shown differently because of it — the answer
// is recorded and the user lands on the normal screen, behind
// whatever device lock they have (§31).
PeriodRoot(notificationAction = intent?.getStringExtra("reminder_action"))
} }
} }
} }

View File

@ -1,7 +1,50 @@
package dev.privacyllc.period package dev.privacyllc.period
import android.app.Application import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
/**
* The application, and WorkManager's entry point.
*
* `Configuration.Provider` replaces WorkManager's default initializer so the
* reminder worker can be constructed by Hilt with the repository it needs. The
* default initializer is removed in the manifest leaving both in place gives
* you a WorkManager that was already initialised before this ever runs, and a
* worker that fails to instantiate with a message about a missing no-arg
* constructor.
*/
@HiltAndroidApp @HiltAndroidApp
class PeriodApplication : Application() class PeriodApplication : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var reminderCoordinator: dev.privacyllc.period.notifications.ReminderCoordinator
/**
* Lives as long as the process, because what it watches does.
*
* A forecast can move while no screen is open the engine re-conditions on
* a "Not yet" recorded from a notification action and a reminder queued
* against the old date is wrong from that moment. Tying this to a ViewModel
* would mean it only ran while somebody was looking.
*/
private val applicationScope =
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Default)
override fun onCreate() {
super.onCreate()
reminderCoordinator.start(applicationScope)
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
// Reminders must never write a cycle date to logcat — §45 applies to
// background work exactly as much as to the UI, and a worker's log
// is the kind nobody sees in testing.
.setMinimumLoggingLevel(android.util.Log.WARN)
.build()
}

View File

@ -13,6 +13,7 @@ import dagger.hilt.components.SingletonComponent
import dev.privacyllc.period.core.data.CycleData import dev.privacyllc.period.core.data.CycleData
import dev.privacyllc.period.core.data.CycleRepository import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.core.datastore.UserPreferencesRepository import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.core.notifications.ReminderScheduler
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
import dev.privacyllc.period.domain.prediction.PredictionEngine import dev.privacyllc.period.domain.prediction.PredictionEngine
import java.time.Clock import java.time.Clock
@ -76,4 +77,9 @@ object DataModule {
@Provides @Provides
@Singleton @Singleton
fun clock(): Clock = Clock.systemDefaultZone() fun clock(): Clock = Clock.systemDefaultZone()
@Provides
@Singleton
fun reminderScheduler(@ApplicationContext context: Context): ReminderScheduler =
ReminderScheduler(context)
} }

View File

@ -0,0 +1,202 @@
package dev.privacyllc.period.feature.settings
import android.Manifest
import android.content.res.Configuration
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.privacyllc.period.core.datastore.NotificationPrivacy
import dev.privacyllc.period.core.datastore.UserPreferences
import dev.privacyllc.period.designsystem.PeriodTheme
import java.time.LocalTime
import java.time.format.DateTimeFormatter
/**
* Notification settings §28's privacy mode and §29's six toggles.
*
* The designed Settings screen is Batch 06; this is the working surface for the
* plumbing, and it says so at the top for the same reason the Batch 01 Today
* screen did: a convincing mock is how a screen comes to be believed finished.
*/
@Composable
fun NotificationSettingsScreen(viewModel: NotificationSettingsViewModel = hiltViewModel()) {
val prefs by viewModel.state.collectAsStateWithLifecycle()
val needsPermission by viewModel.needsPermission.collectAsStateWithLifecycle()
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { viewModel.permissionHandled() }
// §31: asked when a reminder is switched on, never on first launch. A
// permission dialog before the user has seen the app is one answered "deny"
// out of reflex, and there is no second chance.
LaunchedEffect(needsPermission) {
if (needsPermission && Build.VERSION.SDK_INT >= 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)
}

View File

@ -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<UserPreferences> =
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<Boolean> = _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<Boolean> =
preferences.preferences.map {
it.periodApproachingEnabled || it.periodExpectedTodayEnabled || it.didItStartEnabled ||
it.periodEndCheckInEnabled || it.fertileWindowReminderEnabled || it.ovulationReminderEnabled
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
}

View File

@ -38,6 +38,7 @@ import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.feature.calendar.CalendarScreen import dev.privacyllc.period.feature.calendar.CalendarScreen
import dev.privacyllc.period.feature.insights.InsightsScreen import dev.privacyllc.period.feature.insights.InsightsScreen
import dev.privacyllc.period.feature.onboarding.OnboardingScreen import dev.privacyllc.period.feature.onboarding.OnboardingScreen
import dev.privacyllc.period.feature.settings.NotificationSettingsScreen
import dev.privacyllc.period.feature.today.TodayScreen import dev.privacyllc.period.feature.today.TodayScreen
/** /**
@ -66,9 +67,18 @@ enum class PeriodDestination(
* onboarded again, and somebody who abandoned onboarding halfway has. * onboarded again, and somebody who abandoned onboarding halfway has.
*/ */
@Composable @Composable
fun PeriodRoot(viewModel: RootViewModel = hiltViewModel()) { fun PeriodRoot(
notificationAction: String? = null,
viewModel: RootViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle() 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) { when (state) {
RootState.Loading -> Unit // one frame; a spinner here flashes and reads as jank RootState.Loading -> Unit // one frame; a spinner here flashes and reads as jank
RootState.Onboarding -> OnboardingScreen(onFinished = viewModel::onboardingFinished) RootState.Onboarding -> OnboardingScreen(onFinished = viewModel::onboardingFinished)
@ -112,15 +122,10 @@ fun PeriodApp() {
composable(PeriodDestination.CALENDAR.route) { CalendarScreen() } composable(PeriodDestination.CALENDAR.route) { CalendarScreen() }
composable(PeriodDestination.INSIGHTS.route) { InsightsScreen() } composable(PeriodDestination.INSIGHTS.route) { InsightsScreen() }
// Settings arrives in Batch 06. It says "not built yet" rather than // Settings shows the notification plumbing from Batch 05. The
// showing a convincing mock. // designed screen — cycle, privacy and security, appearance,
PeriodDestination.entries // premium, about — is Batch 06, and this surface says so on itself.
.filter { it == PeriodDestination.SETTINGS } composable(PeriodDestination.SETTINGS.route) { NotificationSettingsScreen() }
.forEach { destination ->
composable(destination.route) {
PlaceholderScreen(stringResource(destination.labelRes))
}
}
} }
} }
} }

View File

@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
enum class RootState { Loading, Onboarding, Ready } enum class RootState { Loading, Onboarding, Ready }
@ -17,8 +18,23 @@ enum class RootState { Loading, Onboarding, Ready }
@HiltViewModel @HiltViewModel
class RootViewModel @Inject constructor( class RootViewModel @Inject constructor(
preferences: UserPreferencesRepository, preferences: UserPreferencesRepository,
private val notificationActions: dev.privacyllc.period.notifications.NotificationActionHandler,
) : ViewModel() { ) : 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 * Set when onboarding finishes, so the switch happens immediately rather
* than waiting for the preference write to travel back through DataStore * than waiting for the preference write to travel back through DataStore

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -42,11 +42,15 @@ plugins {
/** Project dependencies each module is permitted. Anything else fails. */ /** Project dependencies each module is permitted. Anything else fails. */
val allowedProjectDependencies: Map<String, Set<String>> = mapOf( val allowedProjectDependencies: Map<String, Set<String>> = 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:designsystem" to emptySet(),
":core:database" to setOf(":domain:cycle", ":domain:prediction"), ":core:database" to setOf(":domain:cycle", ":domain:prediction"),
":core:datastore" to emptySet(), ":core:datastore" to emptySet(),
":core:data" to setOf(":core:database", ":domain:cycle", ":domain:prediction"), ":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:cycle" to emptySet(),
":domain:prediction" to setOf(":domain:cycle"), ":domain:prediction" to setOf(":domain:cycle"),
// Batch 07. Empty, and that is the whole point: the ads module may reach // 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 <uses-permission>, 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<String> = 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<String> = 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("""<uses-permission[^>]*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. // Wired into `check` so it runs with the tests rather than only when remembered.
subprojects { subprojects {
tasks.matching { it.name == "check" }.configureEach { tasks.matching { it.name == "check" }.configureEach {
dependsOn(rootProject.tasks.named("checkModuleBoundaries")) dependsOn(rootProject.tasks.named("checkModuleBoundaries"))
if (project.path == ":app") dependsOn(rootProject.tasks.named("checkPermissions"))
} }
} }

View File

@ -36,7 +36,13 @@ enum class AppTheme { LIGHT, DARK, SYSTEM }
data class UserPreferences( data class UserPreferences(
val notificationPrivacy: NotificationPrivacy = NotificationPrivacy.DISCREET, val notificationPrivacy: NotificationPrivacy = NotificationPrivacy.DISCREET,
val reminderTime: LocalTime = DEFAULT_REMINDER_TIME, 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 fertileWindowReminderEnabled: Boolean = false,
val ovulationReminderEnabled: Boolean = false, val ovulationReminderEnabled: Boolean = false,
val biometricLockEnabled: Boolean = false, val biometricLockEnabled: Boolean = false,
@ -59,7 +65,8 @@ data class UserPreferences(
* [NotificationPrivacy.DISCREET] because a default of DIRECT would leak * [NotificationPrivacy.DISCREET] because a default of DIRECT would leak
* on a lock screen before the user has been asked anything, and the * on a lock screen before the user has been asked anything, and the
* fertility reminders off because most users are not tracking fertility * 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() val Defaults = UserPreferences()
} }

View File

@ -44,13 +44,32 @@ class UserPreferencesRepository(
it[Keys.ReminderMinuteOfDay] = value.hour * 60 + value.minute 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 setFertileWindowReminderEnabled(value: Boolean) = edit { it[Keys.FertileReminder] = value }
suspend fun setOvulationReminderEnabled(value: Boolean) = edit { it[Keys.OvulationReminder] = value } suspend fun setOvulationReminderEnabled(value: Boolean) = edit { it[Keys.OvulationReminder] = value }
suspend fun setBiometricLockEnabled(value: Boolean) = edit { it[Keys.BiometricLock] = value } suspend fun setBiometricLockEnabled(value: Boolean) = edit { it[Keys.BiometricLock] = value }
suspend fun setTheme(value: AppTheme) = edit { it[Keys.Theme] = value.name } suspend fun setTheme(value: AppTheme) = edit { it[Keys.Theme] = value.name }
suspend fun setOnboardingCompleted(value: Boolean) = edit { it[Keys.OnboardingCompleted] = value } 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<Int> = 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. * Entitlement, mirrored from Google Play.
* *
@ -81,7 +100,10 @@ class UserPreferencesRepository(
?.takeIf { it in 0 until MINUTES_PER_DAY } ?.takeIf { it in 0 until MINUTES_PER_DAY }
?.let { LocalTime.of(it / 60, it % 60) } ?.let { LocalTime.of(it / 60, it % 60) }
?: UserPreferences.Defaults.reminderTime, ?: 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, fertileWindowReminderEnabled = p[Keys.FertileReminder] ?: UserPreferences.Defaults.fertileWindowReminderEnabled,
ovulationReminderEnabled = p[Keys.OvulationReminder] ?: UserPreferences.Defaults.ovulationReminderEnabled, ovulationReminderEnabled = p[Keys.OvulationReminder] ?: UserPreferences.Defaults.ovulationReminderEnabled,
biometricLockEnabled = p[Keys.BiometricLock] ?: UserPreferences.Defaults.biometricLockEnabled, biometricLockEnabled = p[Keys.BiometricLock] ?: UserPreferences.Defaults.biometricLockEnabled,
@ -95,7 +117,11 @@ class UserPreferencesRepository(
private object Keys { private object Keys {
val NotificationPrivacy = stringPreferencesKey("notification_privacy") val NotificationPrivacy = stringPreferencesKey("notification_privacy")
val ReminderMinuteOfDay = intPreferencesKey("reminder_minute_of_day") 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 FertileReminder = booleanPreferencesKey("fertile_window_reminder_enabled")
val OvulationReminder = booleanPreferencesKey("ovulation_reminder_enabled") val OvulationReminder = booleanPreferencesKey("ovulation_reminder_enabled")
val BiometricLock = booleanPreferencesKey("biometric_lock_enabled") val BiometricLock = booleanPreferencesKey("biometric_lock_enabled")

View File

@ -64,7 +64,9 @@ class UserPreferencesRepositoryTest {
val p = repo.preferences.first() val p = repo.preferences.first()
assertFalse("an unrequested fertile-window reminder is an unpleasant surprise", p.fertileWindowReminderEnabled) assertFalse("an unrequested fertile-window reminder is an unpleasant surprise", p.fertileWindowReminderEnabled)
assertFalse("likewise ovulation", p.ovulationReminderEnabled) 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 @Test
@ -85,7 +87,7 @@ class UserPreferencesRepositoryTest {
fun `every setting survives being written and read back`() = scope.runTest { fun `every setting survives being written and read back`() = scope.runTest {
repo.setNotificationPrivacy(NotificationPrivacy.MAXIMUM_PRIVACY) repo.setNotificationPrivacy(NotificationPrivacy.MAXIMUM_PRIVACY)
repo.setReminderTime(LocalTime.of(21, 45)) repo.setReminderTime(LocalTime.of(21, 45))
repo.setPeriodReminderEnabled(false) repo.setPeriodApproachingEnabled(false)
repo.setFertileWindowReminderEnabled(true) repo.setFertileWindowReminderEnabled(true)
repo.setOvulationReminderEnabled(true) repo.setOvulationReminderEnabled(true)
repo.setBiometricLockEnabled(true) repo.setBiometricLockEnabled(true)
@ -97,7 +99,7 @@ class UserPreferencesRepositoryTest {
UserPreferences( UserPreferences(
notificationPrivacy = NotificationPrivacy.MAXIMUM_PRIVACY, notificationPrivacy = NotificationPrivacy.MAXIMUM_PRIVACY,
reminderTime = LocalTime.of(21, 45), reminderTime = LocalTime.of(21, 45),
periodReminderEnabled = false, periodApproachingEnabled = false,
fertileWindowReminderEnabled = true, fertileWindowReminderEnabled = true,
ovulationReminderEnabled = true, ovulationReminderEnabled = true,
biometricLockEnabled = true, biometricLockEnabled = true,

View File

@ -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)
}

View File

@ -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,
)
}
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Declared here as well as in the app, because this is the module that calls
NotificationManagerCompat.notify and lint checks the manifest of the module
making the call.
Nothing else is declared. In particular no SCHEDULE_EXACT_ALARM: §31 says a
period reminder does not need alarm-clock precision, and the checkPermissions
task in the root build.gradle.kts fails the build if one ever appears in the
merged manifest — from here or from a dependency.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>

View File

@ -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<String> =
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<String, String> =
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."
}
}

View File

@ -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<NotificationCompat.Action> = 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) }
}
}
}

View File

@ -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
}
}

View File

@ -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<ReminderWorker>(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)
}
}
}

View File

@ -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,
)

View File

@ -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))
}
}
}
}
}

View File

@ -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))
}
}

View File

@ -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))
}
}
}
}

View File

@ -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/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/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/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/cycle` | **Kotlin JVM** | `PeriodRecord`, `SpottingRecord`, `CycleRecord` and the rules over them | nothing |
| `domain/prediction` | **Kotlin JVM** | the forecast, the window, confidence, `NotYetObservation` | `domain/cycle` | | `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 instance is supplied by DI at the app layer — the only place that should know
where a file lives. 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 ### The prediction engine
`PersonalPredictionEngine` (`modelVersion` `personal-1`) is what the app ships. `PersonalPredictionEngine` (`modelVersion` `personal-1`) is what the app ships.

View File

@ -22,6 +22,7 @@ robolectric = "4.16.1"
androidxTestCore = "1.7.0" androidxTestCore = "1.7.0"
testRunner = "1.7.0" testRunner = "1.7.0"
datastore = "1.2.1" datastore = "1.2.1"
work = "2.11.2"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", 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" } 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-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", 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-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", 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-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
androidx-sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled", version.ref = "sqlite" } 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" } 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-test-runner = { group = "androidx.test", name = "runner", version.ref = "testRunner" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }

View File

@ -29,5 +29,6 @@ include(":core:designsystem")
include(":core:database") include(":core:database")
include(":core:datastore") include(":core:datastore")
include(":core:data") include(":core:data")
include(":core:notifications")
include(":domain:cycle") include(":domain:cycle")
include(":domain:prediction") include(":domain:prediction")