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:
parent
270de7b90f
commit
99dbc36802
|
|
@ -58,6 +58,7 @@ dependencies {
|
|||
implementation(project(":core:designsystem"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:datastore"))
|
||||
implementation(project(":core:notifications"))
|
||||
implementation(project(":domain:cycle"))
|
||||
implementation(project(":domain:prediction"))
|
||||
|
||||
|
|
@ -79,7 +80,10 @@ dependencies {
|
|||
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
ksp(libs.hilt.compiler)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
<?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
|
||||
|
|
@ -22,6 +30,22 @@
|
|||
android:supportsRtl="true"
|
||||
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
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ class MainActivity : ComponentActivity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,50 @@
|
|||
package dev.privacyllc.period
|
||||
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
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
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import dagger.hilt.components.SingletonComponent
|
|||
import dev.privacyllc.period.core.data.CycleData
|
||||
import dev.privacyllc.period.core.data.CycleRepository
|
||||
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.PredictionEngine
|
||||
import java.time.Clock
|
||||
|
|
@ -76,4 +77,9 @@ object DataModule {
|
|||
@Provides
|
||||
@Singleton
|
||||
fun clock(): Clock = Clock.systemDefaultZone()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun reminderScheduler(@ApplicationContext context: Context): ReminderScheduler =
|
||||
ReminderScheduler(context)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ import dev.privacyllc.period.designsystem.PeriodTheme
|
|||
import dev.privacyllc.period.feature.calendar.CalendarScreen
|
||||
import dev.privacyllc.period.feature.insights.InsightsScreen
|
||||
import dev.privacyllc.period.feature.onboarding.OnboardingScreen
|
||||
import dev.privacyllc.period.feature.settings.NotificationSettingsScreen
|
||||
import dev.privacyllc.period.feature.today.TodayScreen
|
||||
|
||||
/**
|
||||
|
|
@ -66,9 +67,18 @@ enum class PeriodDestination(
|
|||
* onboarded again, and somebody who abandoned onboarding halfway has.
|
||||
*/
|
||||
@Composable
|
||||
fun PeriodRoot(viewModel: RootViewModel = hiltViewModel()) {
|
||||
fun PeriodRoot(
|
||||
notificationAction: String? = null,
|
||||
viewModel: RootViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// Applied once per delivered intent. Keyed on the action so a rotation does
|
||||
// not record a second "Not yet" the user never tapped.
|
||||
androidx.compose.runtime.LaunchedEffect(notificationAction) {
|
||||
viewModel.onNotificationAction(notificationAction)
|
||||
}
|
||||
|
||||
when (state) {
|
||||
RootState.Loading -> Unit // one frame; a spinner here flashes and reads as jank
|
||||
RootState.Onboarding -> OnboardingScreen(onFinished = viewModel::onboardingFinished)
|
||||
|
|
@ -112,15 +122,10 @@ fun PeriodApp() {
|
|||
composable(PeriodDestination.CALENDAR.route) { CalendarScreen() }
|
||||
composable(PeriodDestination.INSIGHTS.route) { InsightsScreen() }
|
||||
|
||||
// Settings arrives in Batch 06. It says "not built yet" rather than
|
||||
// showing a convincing mock.
|
||||
PeriodDestination.entries
|
||||
.filter { it == PeriodDestination.SETTINGS }
|
||||
.forEach { destination ->
|
||||
composable(destination.route) {
|
||||
PlaceholderScreen(stringResource(destination.labelRes))
|
||||
}
|
||||
}
|
||||
// Settings shows the notification plumbing from Batch 05. The
|
||||
// designed screen — cycle, privacy and security, appearance,
|
||||
// premium, about — is Batch 06, and this surface says so on itself.
|
||||
composable(PeriodDestination.SETTINGS.route) { NotificationSettingsScreen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class RootState { Loading, Onboarding, Ready }
|
||||
|
|
@ -17,8 +18,23 @@ enum class RootState { Loading, Onboarding, Ready }
|
|||
@HiltViewModel
|
||||
class RootViewModel @Inject constructor(
|
||||
preferences: UserPreferencesRepository,
|
||||
private val notificationActions: dev.privacyllc.period.notifications.NotificationActionHandler,
|
||||
) : ViewModel() {
|
||||
|
||||
/**
|
||||
* Apply an action tapped on a notification.
|
||||
*
|
||||
* Handled once per intent by the caller. Doing it here rather than in the
|
||||
* activity keeps it off the main thread and, more importantly, routes it
|
||||
* through the same repository call the in-app button uses — two paths into
|
||||
* one piece of state is how a lock-screen "Not yet" and an in-app "Not yet"
|
||||
* come to mean slightly different things.
|
||||
*/
|
||||
fun onNotificationAction(action: String?) {
|
||||
if (action == null) return
|
||||
viewModelScope.launch { notificationActions.handle(action) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set when onboarding finishes, so the switch happens immediately rather
|
||||
* than waiting for the preference write to travel back through DataStore —
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
127
build.gradle.kts
127
build.gradle.kts
|
|
@ -42,11 +42,15 @@ plugins {
|
|||
|
||||
/** Project dependencies each module is permitted. Anything else fails. */
|
||||
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:database" to setOf(":domain:cycle", ":domain:prediction"),
|
||||
":core:datastore" to emptySet(),
|
||||
":core:data" to setOf(":core:database", ":domain:cycle", ":domain:prediction"),
|
||||
":core:notifications" to setOf(":core:data", ":core:datastore", ":domain:cycle", ":domain:prediction"),
|
||||
":domain:cycle" to emptySet(),
|
||||
":domain:prediction" to setOf(":domain:cycle"),
|
||||
// Batch 07. Empty, and that is the whole point: the ads module may reach
|
||||
|
|
@ -182,9 +186,130 @@ tasks.register("checkModuleBoundaries") {
|
|||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Permissions
|
||||
// ===========================================================================
|
||||
//
|
||||
// The Play listing shows this list, the Data Safety form has to describe it, and
|
||||
// a privacy-first period tracker is judged on it before anybody opens the app.
|
||||
//
|
||||
// It is also the list most likely to grow without anyone deciding to grow it: a
|
||||
// dependency added for one feature brings its own <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.
|
||||
subprojects {
|
||||
tasks.matching { it.name == "check" }.configureEach {
|
||||
dependsOn(rootProject.tasks.named("checkModuleBoundaries"))
|
||||
if (project.path == ":app") dependsOn(rootProject.tasks.named("checkPermissions"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,13 @@ enum class AppTheme { LIGHT, DARK, SYSTEM }
|
|||
data class UserPreferences(
|
||||
val notificationPrivacy: NotificationPrivacy = NotificationPrivacy.DISCREET,
|
||||
val reminderTime: LocalTime = DEFAULT_REMINDER_TIME,
|
||||
val periodReminderEnabled: Boolean = true,
|
||||
// §29 gives six types an INDEPENDENT toggle each. Folding them into one
|
||||
// "reminders" switch would make the common case — a period reminder and no
|
||||
// fertility notifications — impossible to express.
|
||||
val periodApproachingEnabled: Boolean = true,
|
||||
val periodExpectedTodayEnabled: Boolean = true,
|
||||
val didItStartEnabled: Boolean = true,
|
||||
val periodEndCheckInEnabled: Boolean = true,
|
||||
val fertileWindowReminderEnabled: Boolean = false,
|
||||
val ovulationReminderEnabled: Boolean = false,
|
||||
val biometricLockEnabled: Boolean = false,
|
||||
|
|
@ -59,7 +65,8 @@ data class UserPreferences(
|
|||
* [NotificationPrivacy.DISCREET] because a default of DIRECT would leak
|
||||
* on a lock screen before the user has been asked anything, and the
|
||||
* fertility reminders off because most users are not tracking fertility
|
||||
* and an unrequested ovulation notification is an unpleasant surprise.
|
||||
* and an unrequested ovulation notification is an unpleasant surprise —
|
||||
* doubly so on a lock screen somebody else can see.
|
||||
*/
|
||||
val Defaults = UserPreferences()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,13 +44,32 @@ class UserPreferencesRepository(
|
|||
it[Keys.ReminderMinuteOfDay] = value.hour * 60 + value.minute
|
||||
}
|
||||
|
||||
suspend fun setPeriodReminderEnabled(value: Boolean) = edit { it[Keys.PeriodReminder] = value }
|
||||
suspend fun setPeriodApproachingEnabled(value: Boolean) = edit { it[Keys.PeriodApproaching] = value }
|
||||
suspend fun setPeriodExpectedTodayEnabled(value: Boolean) = edit { it[Keys.PeriodExpectedToday] = value }
|
||||
suspend fun setDidItStartEnabled(value: Boolean) = edit { it[Keys.DidItStart] = value }
|
||||
suspend fun setPeriodEndCheckInEnabled(value: Boolean) = edit { it[Keys.PeriodEndCheckIn] = value }
|
||||
suspend fun setFertileWindowReminderEnabled(value: Boolean) = edit { it[Keys.FertileReminder] = value }
|
||||
suspend fun setOvulationReminderEnabled(value: Boolean) = edit { it[Keys.OvulationReminder] = value }
|
||||
suspend fun setBiometricLockEnabled(value: Boolean) = edit { it[Keys.BiometricLock] = value }
|
||||
suspend fun setTheme(value: AppTheme) = edit { it[Keys.Theme] = value.name }
|
||||
suspend fun setOnboardingCompleted(value: Boolean) = edit { it[Keys.OnboardingCompleted] = value }
|
||||
|
||||
/**
|
||||
* How many times the app has asked "did it start?" without being told yes.
|
||||
*
|
||||
* State rather than a setting, and it lives here because it has to survive a
|
||||
* reboot — §30's stopping rule is meaningless if the count resets whenever
|
||||
* the process dies. Reset when a period is confirmed, which is the event
|
||||
* that makes the question moot.
|
||||
*/
|
||||
val checkInCount: Flow<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.
|
||||
*
|
||||
|
|
@ -81,7 +100,10 @@ class UserPreferencesRepository(
|
|||
?.takeIf { it in 0 until MINUTES_PER_DAY }
|
||||
?.let { LocalTime.of(it / 60, it % 60) }
|
||||
?: UserPreferences.Defaults.reminderTime,
|
||||
periodReminderEnabled = p[Keys.PeriodReminder] ?: UserPreferences.Defaults.periodReminderEnabled,
|
||||
periodApproachingEnabled = p[Keys.PeriodApproaching] ?: UserPreferences.Defaults.periodApproachingEnabled,
|
||||
periodExpectedTodayEnabled = p[Keys.PeriodExpectedToday] ?: UserPreferences.Defaults.periodExpectedTodayEnabled,
|
||||
didItStartEnabled = p[Keys.DidItStart] ?: UserPreferences.Defaults.didItStartEnabled,
|
||||
periodEndCheckInEnabled = p[Keys.PeriodEndCheckIn] ?: UserPreferences.Defaults.periodEndCheckInEnabled,
|
||||
fertileWindowReminderEnabled = p[Keys.FertileReminder] ?: UserPreferences.Defaults.fertileWindowReminderEnabled,
|
||||
ovulationReminderEnabled = p[Keys.OvulationReminder] ?: UserPreferences.Defaults.ovulationReminderEnabled,
|
||||
biometricLockEnabled = p[Keys.BiometricLock] ?: UserPreferences.Defaults.biometricLockEnabled,
|
||||
|
|
@ -95,7 +117,11 @@ class UserPreferencesRepository(
|
|||
private object Keys {
|
||||
val NotificationPrivacy = stringPreferencesKey("notification_privacy")
|
||||
val ReminderMinuteOfDay = intPreferencesKey("reminder_minute_of_day")
|
||||
val PeriodReminder = booleanPreferencesKey("period_reminder_enabled")
|
||||
val PeriodApproaching = booleanPreferencesKey("reminder_period_approaching")
|
||||
val PeriodExpectedToday = booleanPreferencesKey("reminder_period_expected_today")
|
||||
val DidItStart = booleanPreferencesKey("reminder_did_it_start")
|
||||
val PeriodEndCheckIn = booleanPreferencesKey("reminder_period_end_check_in")
|
||||
val CheckInCount = intPreferencesKey("check_in_count")
|
||||
val FertileReminder = booleanPreferencesKey("fertile_window_reminder_enabled")
|
||||
val OvulationReminder = booleanPreferencesKey("ovulation_reminder_enabled")
|
||||
val BiometricLock = booleanPreferencesKey("biometric_lock_enabled")
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ class UserPreferencesRepositoryTest {
|
|||
val p = repo.preferences.first()
|
||||
assertFalse("an unrequested fertile-window reminder is an unpleasant surprise", p.fertileWindowReminderEnabled)
|
||||
assertFalse("likewise ovulation", p.ovulationReminderEnabled)
|
||||
assertTrue("the period reminder is the one the user came for", p.periodReminderEnabled)
|
||||
assertTrue("the period reminder is the one the user came for", p.periodApproachingEnabled)
|
||||
assertTrue(p.periodExpectedTodayEnabled)
|
||||
assertTrue(p.didItStartEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -85,7 +87,7 @@ class UserPreferencesRepositoryTest {
|
|||
fun `every setting survives being written and read back`() = scope.runTest {
|
||||
repo.setNotificationPrivacy(NotificationPrivacy.MAXIMUM_PRIVACY)
|
||||
repo.setReminderTime(LocalTime.of(21, 45))
|
||||
repo.setPeriodReminderEnabled(false)
|
||||
repo.setPeriodApproachingEnabled(false)
|
||||
repo.setFertileWindowReminderEnabled(true)
|
||||
repo.setOvulationReminderEnabled(true)
|
||||
repo.setBiometricLockEnabled(true)
|
||||
|
|
@ -97,7 +99,7 @@ class UserPreferencesRepositoryTest {
|
|||
UserPreferences(
|
||||
notificationPrivacy = NotificationPrivacy.MAXIMUM_PRIVACY,
|
||||
reminderTime = LocalTime.of(21, 45),
|
||||
periodReminderEnabled = false,
|
||||
periodApproachingEnabled = false,
|
||||
fertileWindowReminderEnabled = true,
|
||||
ovulationReminderEnabled = true,
|
||||
biometricLockEnabled = true,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ way, with the batch that needs it.
|
|||
| `core/database` | Android library | Room entities, DAOs, converters, the schema export | `domain/cycle`, `domain/prediction` |
|
||||
| `core/datastore` | Android library | `UserPreferences` and the settings that are not health history | nothing in this project |
|
||||
| `core/data` | Android library | `CycleRepository`, entity⇄domain mapping, accuracy — the only module that touches a DAO | `core/database`, `domain/cycle`, `domain/prediction` |
|
||||
| `core/notifications` | Android library | reminder copy, the privacy modes, WorkManager scheduling | `core/data`, `core/datastore`, `domain/*` |
|
||||
| `domain/cycle` | **Kotlin JVM** | `PeriodRecord`, `SpottingRecord`, `CycleRecord` and the rules over them | nothing |
|
||||
| `domain/prediction` | **Kotlin JVM** | the forecast, the window, confidence, `NotYetObservation` | `domain/cycle` |
|
||||
|
||||
|
|
@ -219,6 +220,39 @@ is what lets its tests run on the JVM against a temporary file. The Android
|
|||
instance is supplied by DI at the app layer — the only place that should know
|
||||
where a file lives.
|
||||
|
||||
### Permissions are a declared set, not whatever the build produces
|
||||
|
||||
The Play listing shows them, the Data Safety form describes them, and a
|
||||
privacy-first period tracker is judged on them before anybody opens the app. It
|
||||
is also the list most likely to grow without anyone deciding to: adding
|
||||
WorkManager brought four permissions in one line — `WAKE_LOCK`,
|
||||
`ACCESS_NETWORK_STATE`, `RECEIVE_BOOT_COMPLETED`, `FOREGROUND_SERVICE` — none
|
||||
typed by anybody.
|
||||
|
||||
`checkPermissions` in the root `build.gradle.kts` holds the allowed set and a
|
||||
forbidden set, checks the **release** manifest as well as debug, and fails on
|
||||
anything outside either. §31's exact-alarm permissions are in the forbidden list
|
||||
by name.
|
||||
|
||||
It failed its own first proof too: without a `dependsOn` on the manifest task it
|
||||
read whatever was left from a previous build, so an injected
|
||||
`SCHEDULE_EXACT_ALARM` went unnoticed. Both directions are proved now.
|
||||
|
||||
### Notifications, and the two ways privacy leaks through Android
|
||||
|
||||
`NotificationCopy` is a pure function — mode plus kind plus day count in, two
|
||||
versions of the text out — so every combination is tested without an emulator.
|
||||
`PeriodNotifier` maps that onto Android, and the mapping is where the leaks are:
|
||||
|
||||
- **A private notification with no public version** does not blank the lock
|
||||
screen, it shows the private text. `setPublicVersion` is mandatory, which is
|
||||
why `NotificationText` has no nullable title.
|
||||
- **A channel is immutable after creation.** Importance and lock-screen
|
||||
visibility cannot be changed, so one shared channel would keep whatever the
|
||||
user's first privacy mode set forever — the setting would appear to work and
|
||||
change nothing. There is one channel per mode. Found by an instrumented test,
|
||||
not by reading the docs.
|
||||
|
||||
### The prediction engine
|
||||
|
||||
`PersonalPredictionEngine` (`modelVersion` `personal-1`) is what the app ships.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ robolectric = "4.16.1"
|
|||
androidxTestCore = "1.7.0"
|
||||
testRunner = "1.7.0"
|
||||
datastore = "1.2.1"
|
||||
work = "2.11.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
|
|
@ -44,12 +45,15 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
|
|||
|
||||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
|
||||
androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" }
|
||||
androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltNavigationCompose" }
|
||||
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
|
||||
androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
androidx-sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled", version.ref = "sqlite" }
|
||||
|
||||
|
|
@ -58,6 +62,7 @@ androidx-test-core = { group = "androidx.test", name = "core", version.ref = "an
|
|||
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" }
|
||||
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "testRunner" }
|
||||
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "testRunner" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
|
||||
|
||||
|
|
|
|||
|
|
@ -29,5 +29,6 @@ include(":core:designsystem")
|
|||
include(":core:database")
|
||||
include(":core:datastore")
|
||||
include(":core:data")
|
||||
include(":core:notifications")
|
||||
include(":domain:cycle")
|
||||
include(":domain:prediction")
|
||||
|
|
|
|||
Loading…
Reference in New Issue