fix: tell a broken reminder apart from a quiet cycle
Every throw in the worker became Result.success(). A repository failing every day produced no reminders, no retries, no logs -- §45 forbids them here -- and nothing a user or a developer could tell apart from a cycle with nothing to say. resultFor splits the two kinds. A file or database that could not be read this minute is worth asking again, three times with WorkManager's backoff; past that it gives up quietly, because a reminder that finally posts after a day of retries answers a question nobody is still asking. Anything else -- most likely a Prediction invariant -- fails identically every time, so retrying would spin. Extracted and internal because the plumbing that produces each throw is a Room handle or a corrupt file, and neither is worth constructing to assert a when. The catch is Exception, not Throwable, deliberately: an OOM is neither retryable nor a success, and WorkManager recording it as a failure is right. An answered reminder now leaves the shade. setAutoCancel only covers the body being tapped, so answering with a button left it sitting there to be answered again, and confirming in the app left it asking something already settled. And the stop-asking notice goes through NotificationCopy. Built inline in the worker, it sat outside every test in that file -- both suites iterate ReminderKind and it is not one -- and hardcoded PRIVATE visibility, so a user who explicitly asked to be told plainly got less than her mode promises. Also records the spotting decision in HISTORY.md: recorded, never predicted. §25 keeps spotting from driving the cycle, so predicting it would hand it influence the specification withholds -- and it is optional and sparsely logged, so a forecast built on it would be a pattern invented from whichever cycles she happened to log. One diagnosis worth keeping. Adding the clock generation to the schedule key made that flow infinite, which is right in production and hung the whole app suite for an hour in tests -- a collect that waits for completion never gets one. A thread dump named the line. The tests now pass a finite generation flow, and the suite runs in eight seconds. closes #73 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cf09c7ab97
commit
5f6412856e
|
|
@ -5,6 +5,7 @@ import dev.privacyllc.period.core.datastore.UserPreferencesRepository
|
||||||
import dev.privacyllc.period.core.notifications.ReminderAction
|
import dev.privacyllc.period.core.notifications.ReminderAction
|
||||||
import dev.privacyllc.period.core.notifications.ReminderActionRequest
|
import dev.privacyllc.period.core.notifications.ReminderActionRequest
|
||||||
import dev.privacyllc.period.core.notifications.ReminderActionRules
|
import dev.privacyllc.period.core.notifications.ReminderActionRules
|
||||||
|
import dev.privacyllc.period.core.notifications.ReminderNotifier
|
||||||
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
|
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
|
|
@ -32,6 +33,7 @@ class NotificationActionHandler @Inject constructor(
|
||||||
private val repository: CycleRepository,
|
private val repository: CycleRepository,
|
||||||
private val preferences: UserPreferencesRepository,
|
private val preferences: UserPreferencesRepository,
|
||||||
private val clock: Clock,
|
private val clock: Clock,
|
||||||
|
private val notifier: ReminderNotifier,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -47,6 +49,11 @@ class NotificationActionHandler @Inject constructor(
|
||||||
val today = LocalDate.now(clock)
|
val today = LocalDate.now(clock)
|
||||||
val periods = repository.confirmedPeriods.first()
|
val periods = repository.confirmedPeriods.first()
|
||||||
|
|
||||||
|
// Answered, so it comes out of the shade whatever the verdict below is —
|
||||||
|
// including "too old to write down", where leaving it would invite the
|
||||||
|
// same tap tomorrow. Cancelling is idempotent and touches no health data.
|
||||||
|
notifier.cancel()
|
||||||
|
|
||||||
return when (val verdict = ReminderActionRules.verdict(request, today, periods)) {
|
return when (val verdict = ReminderActionRules.verdict(request, today, periods)) {
|
||||||
// Too old, already answered, or nothing left to close. The tap has
|
// Too old, already answered, or nothing left to close. The tap has
|
||||||
// opened the app, which is where she can see what is recorded and
|
// opened the app, which is where she can see what is recorded and
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import dev.privacyllc.period.core.data.CycleRepository
|
||||||
import dev.privacyllc.period.core.datastore.UserPreferences
|
import dev.privacyllc.period.core.datastore.UserPreferences
|
||||||
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
|
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
|
||||||
import dev.privacyllc.period.core.notifications.ReminderScheduler
|
import dev.privacyllc.period.core.notifications.ReminderScheduler
|
||||||
|
import dev.privacyllc.period.core.notifications.ReminderNotifier
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
@ -36,6 +38,7 @@ class ReminderCoordinator @Inject constructor(
|
||||||
private val repository: CycleRepository,
|
private val repository: CycleRepository,
|
||||||
private val preferences: UserPreferencesRepository,
|
private val preferences: UserPreferencesRepository,
|
||||||
private val scheduler: ReminderScheduler,
|
private val scheduler: ReminderScheduler,
|
||||||
|
private val notifier: ReminderNotifier,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -60,8 +63,34 @@ class ReminderCoordinator @Inject constructor(
|
||||||
forecastDates = repository.forecast.map { it?.mostLikelyStartDate },
|
forecastDates = repository.forecast.map { it?.mostLikelyStartDate },
|
||||||
preferences = preferences.preferences,
|
preferences = preferences.preferences,
|
||||||
).launchIn(scope)
|
).launchIn(scope)
|
||||||
|
|
||||||
|
dismissAnswered(
|
||||||
|
answers = repository.confirmedPeriods.map { periods ->
|
||||||
|
periods.maxByOrNull { it.startDate }?.let { it.startDate to it.endDate }
|
||||||
|
},
|
||||||
|
).launchIn(scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A question answered in the app takes the notification with it.
|
||||||
|
*
|
||||||
|
* `setAutoCancel` only fires when the body is tapped, so a reminder that was
|
||||||
|
* answered — by a button, or by opening the app and logging it there — stayed
|
||||||
|
* in the shade asking something that had been settled, and could be answered
|
||||||
|
* a second time.
|
||||||
|
*
|
||||||
|
* `drop(1)` is the load-bearing part. This runs in every process, including
|
||||||
|
* the one WorkManager starts to run the worker, and the first emission is
|
||||||
|
* simply the current state rather than an answer. Without it the worker's own
|
||||||
|
* process would cancel the notification it was about to post.
|
||||||
|
*/
|
||||||
|
internal fun dismissAnswered(answers: Flow<Pair<LocalDate, LocalDate?>?>): Flow<*> =
|
||||||
|
answers
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.drop(1)
|
||||||
|
.onEach { notifier.cancel() }
|
||||||
|
.catch { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The clock or the time zone moved, so the schedule has to be re-aimed.
|
* The clock or the time zone moved, so the schedule has to be re-aimed.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
|
@ -49,6 +51,18 @@ import java.time.ZoneOffset
|
||||||
@Config(sdk = [34])
|
@Config(sdk = [34])
|
||||||
class NotificationActionHandlerTest {
|
class NotificationActionHandlerTest {
|
||||||
|
|
||||||
|
/** Dismissing is not a health write; these tests are about the writes. */
|
||||||
|
private class NoOpNotifier : dev.privacyllc.period.core.notifications.ReminderNotifier {
|
||||||
|
override fun notify(
|
||||||
|
text: dev.privacyllc.period.core.notifications.NotificationText,
|
||||||
|
privacy: NotificationPrivacy,
|
||||||
|
contentIntent: android.app.PendingIntent?,
|
||||||
|
actions: List<androidx.core.app.NotificationCompat.Action>,
|
||||||
|
) = true
|
||||||
|
override fun cancel() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@get:Rule val temp = TemporaryFolder()
|
@get:Rule val temp = TemporaryFolder()
|
||||||
|
|
||||||
private lateinit var repo: CycleRepository
|
private lateinit var repo: CycleRepository
|
||||||
|
|
@ -63,13 +77,22 @@ class NotificationActionHandlerTest {
|
||||||
// cannot name PeriodDatabase, which is the boundary architecture/README
|
// cannot name PeriodDatabase, which is the boundary architecture/README
|
||||||
// describes and the compiler enforces.
|
// describes and the compiler enforces.
|
||||||
repo = CycleData.repository(ApplicationProvider.getApplicationContext(), PersonalPredictionEngine(), clock)
|
repo = CycleData.repository(ApplicationProvider.getApplicationContext(), PersonalPredictionEngine(), clock)
|
||||||
runBlocking { repo.deleteAllHealthData() }
|
// Wait for the flow to actually reflect the wipe. The database is a real
|
||||||
|
// file shared by every test in this class, and a delete that has not yet
|
||||||
|
// reached the observer makes the NEXT test read the previous one's
|
||||||
|
// period — which looks exactly like the staleness rule failing.
|
||||||
|
runBlocking {
|
||||||
|
repo.deleteAllHealthData()
|
||||||
|
withTimeout(10_000) {
|
||||||
|
while (repo.confirmedPeriods.first().isNotEmpty()) delay(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
prefs = UserPreferencesRepository(
|
prefs = UserPreferencesRepository(
|
||||||
PreferenceDataStoreFactory.create(scope = CoroutineScope(Dispatchers.Unconfined)) {
|
PreferenceDataStoreFactory.create(scope = CoroutineScope(Dispatchers.Unconfined)) {
|
||||||
temp.newFile("prefs.preferences_pb")
|
temp.newFile("prefs.preferences_pb")
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
handler = NotificationActionHandler(repo, prefs, clock)
|
handler = NotificationActionHandler(repo, prefs, clock, NoOpNotifier())
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.test.core.app.ApplicationProvider
|
||||||
import dev.privacyllc.period.core.data.CycleData
|
import dev.privacyllc.period.core.data.CycleData
|
||||||
import dev.privacyllc.period.core.datastore.UserPreferences
|
import dev.privacyllc.period.core.datastore.UserPreferences
|
||||||
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
|
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
|
||||||
|
import dev.privacyllc.period.core.notifications.ReminderNotifier
|
||||||
import dev.privacyllc.period.core.notifications.ReminderScheduler
|
import dev.privacyllc.period.core.notifications.ReminderScheduler
|
||||||
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
|
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -47,12 +48,26 @@ class ReminderCoordinatorTest {
|
||||||
override fun cancel() = Unit
|
override fun cancel() = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Counts dismissals; posting is the worker's business, not the coordinator's. */
|
||||||
|
private class FakeNotifier : ReminderNotifier {
|
||||||
|
var cancels = 0
|
||||||
|
override fun notify(
|
||||||
|
text: dev.privacyllc.period.core.notifications.NotificationText,
|
||||||
|
privacy: dev.privacyllc.period.core.datastore.NotificationPrivacy,
|
||||||
|
contentIntent: android.app.PendingIntent?,
|
||||||
|
actions: List<androidx.core.app.NotificationCompat.Action>,
|
||||||
|
) = true
|
||||||
|
override fun cancel() { cancels++ }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private lateinit var coordinator: ReminderCoordinator
|
private lateinit var coordinator: ReminderCoordinator
|
||||||
private lateinit var preferences: UserPreferencesRepository
|
private lateinit var preferences: UserPreferencesRepository
|
||||||
|
private lateinit var notifier: FakeNotifier
|
||||||
|
|
||||||
@Before fun setUp() {
|
@Before fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
notifier = FakeNotifier()
|
||||||
preferences = UserPreferencesRepository(
|
preferences = UserPreferencesRepository(
|
||||||
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("coordinator_test") },
|
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("coordinator_test") },
|
||||||
)
|
)
|
||||||
|
|
@ -60,6 +75,7 @@ class ReminderCoordinatorTest {
|
||||||
repository = CycleData.repository(context, PersonalPredictionEngine(), Clock.systemUTC()),
|
repository = CycleData.repository(context, PersonalPredictionEngine(), Clock.systemUTC()),
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
scheduler = FakeScheduler(),
|
scheduler = FakeScheduler(),
|
||||||
|
notifier = notifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +89,7 @@ class ReminderCoordinatorTest {
|
||||||
coordinator.scheduleUpdates(
|
coordinator.scheduleUpdates(
|
||||||
forecastDates = flow { throw IllegalArgumentException("a prediction invariant failed") },
|
forecastDates = flow { throw IllegalArgumentException("a prediction invariant failed") },
|
||||||
preferences = flowOf(UserPreferences.Defaults),
|
preferences = flowOf(UserPreferences.Defaults),
|
||||||
|
clockGeneration = flowOf(0),
|
||||||
).collect { }
|
).collect { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +97,7 @@ class ReminderCoordinatorTest {
|
||||||
coordinator.scheduleUpdates(
|
coordinator.scheduleUpdates(
|
||||||
forecastDates = flowOf(null),
|
forecastDates = flowOf(null),
|
||||||
preferences = flow { throw IllegalStateException("the preferences file is unreadable") },
|
preferences = flow { throw IllegalStateException("the preferences file is unreadable") },
|
||||||
|
clockGeneration = flowOf(0),
|
||||||
).collect { }
|
).collect { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,6 +114,11 @@ class ReminderCoordinatorTest {
|
||||||
coordinator.scheduleUpdates(
|
coordinator.scheduleUpdates(
|
||||||
forecastDates = flowOf(null, null),
|
forecastDates = flowOf(null, null),
|
||||||
preferences = flowOf(UserPreferences.Defaults),
|
preferences = flowOf(UserPreferences.Defaults),
|
||||||
|
// Finite, or this never completes. The real generation flow is a
|
||||||
|
// MutableStateFlow that stays open for the life of the process —
|
||||||
|
// correct there, and a hang here. It hung the whole app suite for an
|
||||||
|
// hour before a thread dump named this line.
|
||||||
|
clockGeneration = flowOf(0),
|
||||||
).collect { emissions++ }
|
).collect { emissions++ }
|
||||||
assertEquals("distinctUntilChanged should collapse the repeated value", 1, emissions)
|
assertEquals("distinctUntilChanged should collapse the repeated value", 1, emissions)
|
||||||
}
|
}
|
||||||
|
|
@ -123,4 +146,30 @@ class ReminderCoordinatorTest {
|
||||||
|
|
||||||
assertEquals(2, preferences.checkInTally.first().countFor(7L))
|
assertEquals(2, preferences.checkInTally.first().countFor(7L))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first emission is the current state, not an answer.
|
||||||
|
*
|
||||||
|
* This chain runs in every process, including the one WorkManager starts to
|
||||||
|
* run the worker — so without the drop, the worker's own process would
|
||||||
|
* cancel the notification it was about to post.
|
||||||
|
*/
|
||||||
|
@Test fun `the state at start does not dismiss anything`() = runBlocking {
|
||||||
|
coordinator.dismissAnswered(
|
||||||
|
answers = flowOf(java.time.LocalDate.of(2026, 8, 20) to null),
|
||||||
|
).collect { }
|
||||||
|
|
||||||
|
assertEquals(0, notifier.cancels)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `an answer after start dismisses the reminder`() = runBlocking {
|
||||||
|
coordinator.dismissAnswered(
|
||||||
|
answers = flowOf(
|
||||||
|
java.time.LocalDate.of(2026, 8, 20) to null,
|
||||||
|
java.time.LocalDate.of(2026, 8, 20) to java.time.LocalDate.of(2026, 8, 24),
|
||||||
|
),
|
||||||
|
).collect { }
|
||||||
|
|
||||||
|
assertEquals(1, notifier.cancels)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,33 @@ object NotificationCopy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "We'll stop checking for now" — §30's sentiment, and the only notification
|
||||||
|
* the app posts to say it is about to be quiet.
|
||||||
|
*
|
||||||
|
* It lived inline in the worker, which put it outside every test in this
|
||||||
|
* file: `NotificationCopyTest` and `NotificationPrivacyTest` both iterate
|
||||||
|
* `ReminderKind`, and this is not one. It also hardcoded PRIVATE visibility,
|
||||||
|
* so a user who chose Direct — having asked, explicitly, to be told plainly —
|
||||||
|
* got less than the mode promises.
|
||||||
|
*
|
||||||
|
* Not a [ReminderKind]: those are the six toggles, one per switch on the
|
||||||
|
* settings screen, and `toReminderPreferences` maps exactly six. Adding a
|
||||||
|
* seventh for a message nobody can turn off would make that mapping a lie.
|
||||||
|
*/
|
||||||
|
fun stopAskingText(privacy: NotificationPrivacy): NotificationText {
|
||||||
|
val plain = privacy == NotificationPrivacy.DIRECT
|
||||||
|
return NotificationText(
|
||||||
|
publicTitle = if (plain) "We'll stop checking for now" else "Reminder",
|
||||||
|
publicBody = if (plain) "Log your period whenever it begins." else null,
|
||||||
|
privateTitle = "We'll stop checking for now",
|
||||||
|
privateBody = "Log your period whenever it begins.",
|
||||||
|
// Direct is the mode that asked to be spoken to plainly on a lock
|
||||||
|
// screen, and none of this text names anything anyway.
|
||||||
|
visibility = if (plain) LockScreenVisibility.PUBLIC else LockScreenVisibility.PRIVATE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The buttons a reminder offers: what each says, and what each does.
|
* The buttons a reminder offers: what each says, and what each does.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,10 @@ class PeriodNotifier(private val context: Context) : ReminderNotifier {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun cancel() {
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
|
||||||
fun hasPermission(): Boolean =
|
fun hasPermission(): Boolean =
|
||||||
android.os.Build.VERSION.SDK_INT < 33 ||
|
android.os.Build.VERSION.SDK_INT < 33 ||
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,14 @@ interface ReminderNotifier {
|
||||||
contentIntent: android.app.PendingIntent?,
|
contentIntent: android.app.PendingIntent?,
|
||||||
actions: List<NotificationCompat.Action> = emptyList(),
|
actions: List<NotificationCompat.Action> = emptyList(),
|
||||||
): Boolean
|
): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the reminder out of the shade.
|
||||||
|
*
|
||||||
|
* `setAutoCancel` only covers the body being tapped, so answering with a
|
||||||
|
* button left the notification sitting there to be answered again — and
|
||||||
|
* confirming in the app instead left it there too, still asking a question
|
||||||
|
* that had been settled.
|
||||||
|
*/
|
||||||
|
fun cancel()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ package dev.privacyllc.period.core.notifications
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import java.io.IOException
|
||||||
|
import android.database.sqlite.SQLiteException
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.hilt.work.HiltWorker
|
import androidx.hilt.work.HiltWorker
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ListenableWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
|
@ -64,8 +67,8 @@ class ReminderWorker @AssistedInject constructor(
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
// Deliberately not logged. §45 applies here as much as to the UI,
|
// Deliberately not logged. §45 applies here as much as to the UI,
|
||||||
// and the likeliest throw on this path is a `Prediction` invariant
|
// and the likeliest throw on this path is a `Prediction` invariant
|
||||||
// failure whose message is derived from a cycle date.
|
// whose message is derived from a cycle date.
|
||||||
Result.success()
|
resultFor(failure, runAttemptCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun decideAndNotify(): Result {
|
private suspend fun decideAndNotify(): Result {
|
||||||
|
|
@ -105,15 +108,7 @@ class ReminderWorker @AssistedInject constructor(
|
||||||
// §30's exact sentiment, and it is the only notification in the
|
// §30's exact sentiment, and it is the only notification in the
|
||||||
// app that exists to say the app will now be quiet.
|
// app that exists to say the app will now be quiet.
|
||||||
val posted = notifier.notify(
|
val posted = notifier.notify(
|
||||||
text = NotificationText(
|
text = NotificationCopy.stopAskingText(prefs.notificationPrivacy),
|
||||||
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,
|
privacy = prefs.notificationPrivacy,
|
||||||
contentIntent = openApp(),
|
contentIntent = openApp(),
|
||||||
)
|
)
|
||||||
|
|
@ -180,6 +175,48 @@ class ReminderWorker @AssistedInject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a failed wake-up should report.
|
||||||
|
*
|
||||||
|
* Extracted and internal so the taxonomy can be tested directly: the plumbing
|
||||||
|
* that would produce each throw is a Room handle or a corrupt file, and neither
|
||||||
|
* is worth constructing to assert a `when`.
|
||||||
|
*
|
||||||
|
* Every failure used to be `Result.success()`, so a repository that threw every
|
||||||
|
* day produced no reminders, no retries, no logs and nothing a user or a
|
||||||
|
* developer could tell apart from a quiet cycle.
|
||||||
|
*
|
||||||
|
* Note the parameter is `Exception`, not `Throwable`, and the caller catches the
|
||||||
|
* same: an `Error` — an OOM, a `StackOverflowError` — is not something to retry
|
||||||
|
* or to call a success, and WorkManager recording it as a failure is right.
|
||||||
|
*/
|
||||||
|
internal fun resultFor(failure: Exception, runAttemptCount: Int): ListenableWorker.Result = when (failure) {
|
||||||
|
// A file or a database that could not be read this minute may be readable in
|
||||||
|
// fifteen. Worth asking again, a few times.
|
||||||
|
is IOException, is SQLiteException ->
|
||||||
|
if (runAttemptCount < MAX_REMINDER_RETRIES) {
|
||||||
|
ListenableWorker.Result.retry()
|
||||||
|
} else {
|
||||||
|
// Past the limit, give up quietly: a reminder that finally posts
|
||||||
|
// after a day of retries is answering a question nobody is still
|
||||||
|
// asking.
|
||||||
|
ListenableWorker.Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deterministic fault — most likely a `Prediction` invariant, which fails
|
||||||
|
// identically on every retry. Retrying would spin, and silence is already
|
||||||
|
// this worker's honest answer when it has nothing it can say.
|
||||||
|
else -> ListenableWorker.Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many times a transient failure is worth retrying.
|
||||||
|
*
|
||||||
|
* Three, with WorkManager's default backoff: enough to ride out a busy disk, and
|
||||||
|
* short of turning a persistent fault into an all-day retry loop.
|
||||||
|
*/
|
||||||
|
internal const val MAX_REMINDER_RETRIES = 3
|
||||||
|
|
||||||
/** The six toggles, as the rules want them. */
|
/** The six toggles, as the rules want them. */
|
||||||
fun UserPreferences.toReminderPreferences() = ReminderPreferences(
|
fun UserPreferences.toReminderPreferences() = ReminderPreferences(
|
||||||
periodApproaching = periodApproachingEnabled,
|
periodApproaching = periodApproachingEnabled,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package dev.privacyllc.period.core.notifications
|
||||||
|
|
||||||
import dev.privacyllc.period.core.datastore.NotificationPrivacy
|
import dev.privacyllc.period.core.datastore.NotificationPrivacy
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
|
@ -155,4 +156,38 @@ class NotificationCopyTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// The one notification that says the app is going quiet
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test fun `the stop-asking notice leaks nothing outside Direct`() {
|
||||||
|
NotificationPrivacy.entries.filter { it != NotificationPrivacy.DIRECT }.forEach { privacy ->
|
||||||
|
val text = NotificationCopy.stopAskingText(privacy)
|
||||||
|
val onLockScreen = listOfNotNull(text.publicTitle, text.publicBody).joinToString(" ").lowercase()
|
||||||
|
NotificationCopy.SENSITIVE_WORDS.forEach {
|
||||||
|
assertFalse("$privacy leaked \"$it\"", onLockScreen.contains(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `the stop-asking notice speaks plainly only in Direct`() {
|
||||||
|
// It used to hardcode PRIVATE visibility, so a user who explicitly asked
|
||||||
|
// to be told plainly got less than the mode promises.
|
||||||
|
assertEquals(
|
||||||
|
LockScreenVisibility.PUBLIC,
|
||||||
|
NotificationCopy.stopAskingText(NotificationPrivacy.DIRECT).visibility,
|
||||||
|
)
|
||||||
|
NotificationPrivacy.entries.filter { it != NotificationPrivacy.DIRECT }.forEach {
|
||||||
|
assertEquals(LockScreenVisibility.PRIVATE, NotificationCopy.stopAskingText(it).visibility)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `the stop-asking notice always has something to show on a lock screen`() {
|
||||||
|
// The same rule every other kind is held to: a notification with no
|
||||||
|
// public version renders its private one, which is the leak.
|
||||||
|
NotificationPrivacy.entries.forEach {
|
||||||
|
assertTrue(NotificationCopy.stopAskingText(it).publicTitle.isNotBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ class ReminderWorkerTest {
|
||||||
private class FakeNotifier(var canPost: Boolean = true) : ReminderNotifier {
|
private class FakeNotifier(var canPost: Boolean = true) : ReminderNotifier {
|
||||||
val posted = mutableListOf<NotificationText>()
|
val posted = mutableListOf<NotificationText>()
|
||||||
var lastActions: List<NotificationCompat.Action> = emptyList()
|
var lastActions: List<NotificationCompat.Action> = emptyList()
|
||||||
|
var cancels = 0
|
||||||
|
|
||||||
|
override fun cancel() { cancels++ }
|
||||||
|
|
||||||
override fun notify(
|
override fun notify(
|
||||||
text: NotificationText,
|
text: NotificationText,
|
||||||
|
|
@ -184,4 +187,29 @@ class ReminderWorkerTest {
|
||||||
assertTrue(notifier.posted.isEmpty())
|
assertTrue(notifier.posted.isEmpty())
|
||||||
assertEquals(0, tally())
|
assertEquals(0, tally())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Failing in a way somebody could act on
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test fun `a transient failure asks to be retried, and eventually gives up`() {
|
||||||
|
// Every failure used to be a success: a repository throwing every day
|
||||||
|
// produced no reminders, no retries, no logs, and nothing a user or a
|
||||||
|
// developer could tell apart from a quiet cycle.
|
||||||
|
listOf(java.io.IOException("disk busy"), android.database.sqlite.SQLiteException("locked")).forEach {
|
||||||
|
assertEquals(ListenableWorker.Result.retry(), resultFor(it, runAttemptCount = 0))
|
||||||
|
assertEquals(ListenableWorker.Result.retry(), resultFor(it, runAttemptCount = MAX_REMINDER_RETRIES - 1))
|
||||||
|
|
||||||
|
// Past the limit it stops: a reminder that finally posts after a day
|
||||||
|
// of retries is answering a question nobody is still asking.
|
||||||
|
assertEquals(ListenableWorker.Result.success(), resultFor(it, runAttemptCount = MAX_REMINDER_RETRIES))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `a deterministic fault ends the run quietly rather than spinning`() {
|
||||||
|
// A Prediction invariant fails identically every time.
|
||||||
|
listOf(IllegalStateException("invariant"), IllegalArgumentException("window")).forEach {
|
||||||
|
assertEquals(ListenableWorker.Result.success(), resultFor(it, runAttemptCount = 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,34 @@ health app a crash mid-write is adjacent to losing what the user just entered,
|
||||||
and a message they can read beats a process that vanished. The message carries
|
and a message they can read beats a process that vanished. The message carries
|
||||||
the exception *type* and never a record's contents — §45.
|
the exception *type* and never a record's contents — §45.
|
||||||
|
|
||||||
|
### A failed wake-up says which kind of failure it was
|
||||||
|
|
||||||
|
Every throw in the reminder worker used to become `Result.success()`. A
|
||||||
|
repository failing every day produced no reminders, no retries, no logs — §45
|
||||||
|
forbids them here — and nothing a user or a developer could tell apart from a
|
||||||
|
quiet cycle.
|
||||||
|
|
||||||
|
`resultFor` splits the two kinds. A file or database that could not be read this
|
||||||
|
minute (`IOException`, `SQLiteException`) is worth asking again, three times,
|
||||||
|
with WorkManager's backoff; past that it gives up quietly, because a reminder
|
||||||
|
that finally posts after a day of retries is answering a question nobody is still
|
||||||
|
asking. Anything else — most likely a `Prediction` invariant — fails identically
|
||||||
|
on every retry, so retrying would spin and silence is the honest answer.
|
||||||
|
|
||||||
|
The catch is `Exception`, not `Throwable`, deliberately: an OOM or a
|
||||||
|
`StackOverflowError` is neither retryable nor a success, and WorkManager
|
||||||
|
recording it as a failure is right.
|
||||||
|
|
||||||
|
**An answered reminder leaves the shade.** `setAutoCancel` only covers the body
|
||||||
|
being tapped, so answering with a button left it sitting there to be answered
|
||||||
|
again, and confirming in the app left it asking a settled question. The handler
|
||||||
|
cancels on any verdict — including "too old to write down", where leaving it
|
||||||
|
would invite the same tap tomorrow — and the coordinator cancels when the history
|
||||||
|
changes underneath it. That chain drops its first emission: it runs in every
|
||||||
|
process, including the one WorkManager starts for the worker, and the first value
|
||||||
|
is the current state rather than an answer, so without the drop the worker's own
|
||||||
|
process would cancel the notification it was about to post.
|
||||||
|
|
||||||
### An answer belongs to the day it was asked about
|
### An answer belongs to the day it was asked about
|
||||||
|
|
||||||
A notification waits in the shade until somebody deals with it. The handler used
|
A notification waits in the shade until somebody deals with it. The handler used
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ was true at the time" survives Y stopping being true.
|
||||||
| 2026-08-18 | Four Gradle modules at the skeleton, not the seventeen the specification sketches | a module created before it has contents is a place for things to be put by accident; the rest arrive with the batch that needs them |
|
| 2026-08-18 | Four Gradle modules at the skeleton, not the seventeen the specification sketches | a module created before it has contents is a place for things to be put by accident; the rest arrive with the batch that needs them |
|
||||||
| 2026-08-18 | Eight milestones opened at once, issues filed only under Batch 01 | the roadmap is genuinely known and worth being visible; the *work items* are not, and inventing them would make every tracker percentage permanently wrong |
|
| 2026-08-18 | Eight milestones opened at once, issues filed only under Batch 01 | the roadmap is genuinely known and worth being visible; the *work items* are not, and inventing them would make every tracker percentage permanently wrong |
|
||||||
| 2026-08-18 | OPERATIONS.md deleted rather than kept empty | this is an offline app, not a deployed service; an empty runbook reads as one nobody wrote |
|
| 2026-08-18 | OPERATIONS.md deleted rather than kept empty | this is an offline app, not a deployed service; an empty runbook reads as one nobody wrote |
|
||||||
|
| 2026-08-21 | Spotting is recorded, never predicted | §25 deliberately keeps spotting from resetting or driving the cycle, so predicting it would give it influence the specification withholds. It is also optional and sparsely logged: a forecast built on it would be a pattern invented from whichever cycles she happened to log, presented with the same confidence as one built on periods. The honest shape, if it is ever wanted, is an Insights sentence when her own history strongly supports it — same cycle phase in at least three of the last four cycles — and no calendar marks |
|
||||||
|
|
||||||
## What was tried and dropped
|
## What was tried and dropped
|
||||||
|
|
||||||
|
|
@ -59,6 +60,15 @@ This is the most useful section in the file and the one most often missing — a
|
||||||
approach abandoned for a good reason will be proposed again by somebody who does
|
approach abandoned for a good reason will be proposed again by somebody who does
|
||||||
not know it was tried, including you, in a year.
|
not know it was tried, including you, in a year.
|
||||||
|
|
||||||
|
- **Predicting spotting, and the whole feature surface behind it.** Reviewing an
|
||||||
|
open-source period widget for ideas turned up a symptom library, per-cycle-day
|
||||||
|
symptom recurrence, hormone curves, water and sleep tracking, a pregnancy mode
|
||||||
|
and a phase wheel. Almost all of it is the "overloaded women's health super-app"
|
||||||
|
PRODUCT_PLAN §5 exists to refuse, and its prediction was the naive
|
||||||
|
last-start-plus-28 §11 forbids — so there was nothing to take from the maths
|
||||||
|
and little from the features. Three ideas survived as their own batch:
|
||||||
|
restore-from-export, a year-ahead calendar, and a cycle-length chart. Spotting
|
||||||
|
prediction did not, and the reason is in the decisions table above.
|
||||||
- **A Room schema-export comparison as the schema-drift guard.** `SchemaTest` was
|
- **A Room schema-export comparison as the schema-drift guard.** `SchemaTest` was
|
||||||
written believing it caught drift. Room regenerates the export during
|
written believing it caught drift. Room regenerates the export during
|
||||||
compilation, so both sides of every comparison agree by construction, and
|
compilation, so both sides of every comparison agree by construction, and
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue