diff --git a/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt b/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt index 7f6eda7..3c152ff 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import dev.privacyllc.period.core.notifications.PeriodNotifier +import dev.privacyllc.period.core.notifications.ReminderActionRequest import dev.privacyllc.period.core.security.AppLockRepository import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.feature.export.ExportController @@ -77,7 +78,10 @@ class MainActivity : FragmentActivity() { // Parked, never applied here. With a lock on, the write waits for the // unlock; the gate delivers it. See AppLockController. - lockController.holdNotificationAction(intent?.getStringExtra(EXTRA_REMINDER_ACTION)) + // Only on a genuine launch: after process death the original Intent is + // redelivered with its extras intact, and re-parking it would apply an + // answer the user gave days ago a second time. + if (savedInstanceState == null) parkActionFrom(intent) lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -117,7 +121,8 @@ class MainActivity : FragmentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - lockController.holdNotificationAction(intent.getStringExtra(EXTRA_REMINDER_ACTION)) + setIntent(intent) + parkActionFrom(intent) } /** @@ -139,6 +144,20 @@ class MainActivity : FragmentActivity() { } } + /** + * Take the answer out of the Intent and hold it for the gate. + * + * `removeExtra` matters: without it a rotation or a theme change re-runs + * `onCreate` on the same Intent and parks the same answer again. The writes + * are idempotent today, which is the only reason that was survivable. + */ + private fun parkActionFrom(intent: Intent?) { + val request = intent?.let(ReminderActionRequest::fromIntent) ?: return + intent.removeExtra(PeriodNotifier.EXTRA_REMINDER_ACTION) + intent.removeExtra(PeriodNotifier.EXTRA_REMINDER_DATE) + lockController.holdNotificationAction(request) + } + private companion object { /** * The same key `core/notifications` writes, not a second copy of the diff --git a/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockController.kt b/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockController.kt index 2edb3d7..0ef9a37 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockController.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockController.kt @@ -3,6 +3,7 @@ package dev.privacyllc.period.lock import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import dev.privacyllc.period.core.notifications.ReminderActionRequest import javax.inject.Inject import javax.inject.Singleton @@ -42,13 +43,13 @@ class AppLockController @Inject constructor() { @Volatile var authInProgress: Boolean = false - private val _pendingNotificationAction = MutableStateFlow(null) + private val _pendingNotificationAction = MutableStateFlow(null) /** * Observed rather than read once, so an action arriving while the app is * already open — `onNewIntent`, not `onCreate` — is delivered too. */ - val pendingNotificationAction: StateFlow = _pendingNotificationAction.asStateFlow() + val pendingNotificationAction: StateFlow = _pendingNotificationAction.asStateFlow() fun unlock() { _unlocked.value = true @@ -67,11 +68,11 @@ class AppLockController @Inject constructor() { * has to wait for the unlock, or the lock is decorative for the one action * that modifies data. */ - fun holdNotificationAction(action: String?) { + fun holdNotificationAction(action: ReminderActionRequest?) { if (action != null) _pendingNotificationAction.value = action } /** Returns the held action once, and forgets it. Null when there is none. */ - fun takeNotificationAction(): String? = + fun takeNotificationAction(): ReminderActionRequest? = _pendingNotificationAction.value.also { _pendingNotificationAction.value = null } } diff --git a/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockViewModel.kt index 7cb26c8..4c3064d 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockViewModel.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.privacyllc.period.core.datastore.UserPreferencesRepository +import dev.privacyllc.period.core.notifications.ReminderActionRequest import dev.privacyllc.period.core.security.AppLockRepository import dev.privacyllc.period.core.security.UnlockResult import dev.privacyllc.period.notifications.NotificationActionHandler @@ -58,7 +59,7 @@ class AppLockViewModel @Inject constructor( ) : ViewModel() { /** Non-null while a notification action is waiting to be applied. */ - val pendingNotificationAction: StateFlow = controller.pendingNotificationAction + val pendingNotificationAction: StateFlow = controller.pendingNotificationAction /** * Apply a parked notification action, now that somebody has authenticated. diff --git a/app/src/main/kotlin/dev/privacyllc/period/notifications/NotificationActionHandler.kt b/app/src/main/kotlin/dev/privacyllc/period/notifications/NotificationActionHandler.kt index deb4366..3d2e3f5 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/notifications/NotificationActionHandler.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/notifications/NotificationActionHandler.kt @@ -3,6 +3,8 @@ 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.ReminderAction +import dev.privacyllc.period.core.notifications.ReminderActionRequest +import dev.privacyllc.period.core.notifications.ReminderActionRules import dev.privacyllc.period.domain.cycle.PeriodRecordSource import kotlinx.coroutines.flow.first import java.time.Clock @@ -32,55 +34,60 @@ class NotificationActionHandler @Inject constructor( private val clock: Clock, ) { - /** Returns true when the action was recognised and applied. */ - suspend fun handle(action: String?): Boolean { + /** + * Apply an answer to the day it was about. + * + * Returns true when something was recognised — including the cases that + * deliberately write nothing, because "understood, and there is nothing to + * record" is not a failure. + */ + suspend fun handle(request: ReminderActionRequest?): Boolean { + request ?: return false + val today = LocalDate.now(clock) - // Anything this app did not write — including the action strings used - // before the buttons carried their own meaning — writes nothing. A - // notification already sitting in somebody's shade at upgrade time - // still opens the app; it just does not guess what she meant. - return when (ReminderAction.fromWireName(action)) { - ReminderAction.STARTED -> { - // The question is answered. Nothing resets a counter here: the - // count is stored against the period it was asked about, so this - // new start makes the old count read as zero on its own. - repository.confirmPeriodStart(today, PeriodRecordSource.NOTIFICATION_CONFIRMATION) - true - } + val periods = repository.confirmedPeriods.first() - ReminderAction.NOT_YET -> { - // Exactly what the Today screen's "Not yet" does — same call, - // same censoring observation, same re-conditioned forecast. - repository.recordNotYet(today) - true - } + return when (val verdict = ReminderActionRules.verdict(request, today, periods)) { + // 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 + // change it — better than a confident write against the wrong day. + ReminderActionRules.Verdict.Stale -> true - ReminderAction.ENDED -> { + is ReminderActionRules.Verdict.EndPeriod -> { // Closes the period that is running. NEVER opens one: this // button answers "is it over?", and the write that used to sit // here answered "did it start?" — inserting a fresh period in // the middle of the one it was asking about. - // - // Nothing to close is not a failure. She may have ended it in - // the app between the notification being posted and being - // tapped, and the honest response to an answered question is - // silence rather than a second record. - val open = repository.confirmedPeriods.first() - .lastOrNull { it.endDate == null && !it.startDate.isAfter(today) } - open?.let { repository.setPeriodEnd(it.id, today) } + repository.setPeriodEnd(verdict.id, request.date!!) true } - ReminderAction.STILL_GOING -> { - // Deliberately writes nothing. "Still going" is the state the - // record is already in, and the Today screen's equivalent - // (setPeriodEnd(id, null)) is a no-op on an open period — one - // that would still move `updatedAt` and read, in the history, - // as an edit the user never made. + ReminderActionRules.Verdict.Apply -> { + when (request.action) { + ReminderAction.STARTED -> + repository.confirmPeriodStart( + request.date!!, + PeriodRecordSource.NOTIFICATION_CONFIRMATION, + ) + + // Exactly what the Today screen's "Not yet" does — same + // call, same censoring observation, same re-conditioned + // forecast — but dated to the day that was asked about. + ReminderAction.NOT_YET -> repository.recordNotYet(request.date!!) + + // Deliberately writes nothing. "Still going" is the state + // the record is already in, and the in-app equivalent is a + // no-op that would still move `updatedAt` and read, in the + // history, as an edit she never made. + ReminderAction.STILL_GOING -> Unit + + // Handled above, and unreachable: ENDED always resolves to + // EndPeriod or Stale. + ReminderAction.ENDED -> Unit + } true } - - null -> false } } + } diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/lock/LockSettingsViewModelTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/lock/LockSettingsViewModelTest.kt index b973eb0..ea6ef7d 100644 --- a/app/src/test/kotlin/dev/privacyllc/period/feature/lock/LockSettingsViewModelTest.kt +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/lock/LockSettingsViewModelTest.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Assert.assertEquals @@ -118,8 +119,28 @@ class LockSettingsViewModelTest { * and time out when the whole suite runs beside it. This budget is for * catching a genuine hang, not for measuring the crypto. */ - private fun await(predicate: suspend () -> Boolean) = runBlocking { - withTimeout(30_000) { while (!predicate()) delay(10) } + /** + * Generous, and it reports what it was waiting on when it gives up. + * + * Every PIN here costs a real PBKDF2 derivation at 210,000 iterations, and + * one test asks for three, so a budget tuned to a lone run turns red when + * the whole module runs beside it — a flaky guard is one people learn to + * ignore. The budget is for catching a hang, not for measuring the crypto. + * + * The message matters as much as the number: a bare "timed out" says + * nothing about whether the write never happened, the callback never fired, + * or the state simply had not arrived yet. + */ + private fun await(what: String = "a condition", predicate: suspend () -> Boolean) = runBlocking { + try { + withTimeout(60_000) { while (!predicate()) delay(10) } + } catch (timeout: TimeoutCancellationException) { + throw AssertionError( + "gave up waiting for $what — busy=${vm.state.value.busy}, " + + "message=${vm.state.value.message}, hasPin=${lock.hasPin.first()}", + timeout, + ) + } } private fun pin(value: String) = value.toCharArray() diff --git a/app/src/test/kotlin/dev/privacyllc/period/lock/AppLockControllerTest.kt b/app/src/test/kotlin/dev/privacyllc/period/lock/AppLockControllerTest.kt index 817c338..4adbc4c 100644 --- a/app/src/test/kotlin/dev/privacyllc/period/lock/AppLockControllerTest.kt +++ b/app/src/test/kotlin/dev/privacyllc/period/lock/AppLockControllerTest.kt @@ -6,7 +6,10 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import dev.privacyllc.period.core.notifications.ReminderAction +import dev.privacyllc.period.core.notifications.ReminderActionRequest import org.junit.Test +import java.time.LocalDate /** * The session flag, and the parked notification action. @@ -16,6 +19,13 @@ import org.junit.Test */ class AppLockControllerTest { + /** A parked answer now carries the day it was about, not just its name. */ + private fun request(action: String) = ReminderActionRequest( + ReminderAction.valueOf(action), + LocalDate.of(2026, 8, 20), + ) + + @Test fun `a new process starts locked`() = runBlocking { assertFalse( "a cold start must never begin unlocked — that is the whole reason " + @@ -39,27 +49,27 @@ class AppLockControllerTest { */ @Test fun `a notification action is held rather than applied`() = runBlocking { val controller = AppLockController() - controller.holdNotificationAction("not_yet") + controller.holdNotificationAction(request("NOT_YET")) - assertEquals("not_yet", controller.pendingNotificationAction.first()) - assertEquals("not_yet", controller.takeNotificationAction()) + assertEquals(request("NOT_YET"), controller.pendingNotificationAction.first()) + assertEquals(request("NOT_YET"), controller.takeNotificationAction()) } @Test fun `taking the action consumes it, so it cannot be applied twice`() { val controller = AppLockController() - controller.holdNotificationAction("not_yet") + controller.holdNotificationAction(request("NOT_YET")) - assertEquals("not_yet", controller.takeNotificationAction()) + assertEquals(request("NOT_YET"), controller.takeNotificationAction()) assertNull("a second take must find nothing", controller.takeNotificationAction()) } @Test fun `holding null does not clear an action already waiting`() { val controller = AppLockController() - controller.holdNotificationAction("started") + controller.holdNotificationAction(request("STARTED")) // Every launch delivers an intent; most carry no action. That must not // discard one that is genuinely waiting. controller.holdNotificationAction(null) - assertEquals("started", controller.takeNotificationAction()) + assertEquals(request("STARTED"), controller.takeNotificationAction()) } @Test fun `the auth-in-progress flag defaults to false`() { diff --git a/app/src/test/kotlin/dev/privacyllc/period/notifications/NotificationActionHandlerTest.kt b/app/src/test/kotlin/dev/privacyllc/period/notifications/NotificationActionHandlerTest.kt index 8eeb8f0..0e3df60 100644 --- a/app/src/test/kotlin/dev/privacyllc/period/notifications/NotificationActionHandlerTest.kt +++ b/app/src/test/kotlin/dev/privacyllc/period/notifications/NotificationActionHandlerTest.kt @@ -7,6 +7,7 @@ import dev.privacyllc.period.core.data.PeriodWriteResult import dev.privacyllc.period.core.datastore.UserPreferencesRepository import dev.privacyllc.period.core.notifications.NotificationCopy import dev.privacyllc.period.core.notifications.ReminderAction +import dev.privacyllc.period.core.notifications.ReminderActionRequest import dev.privacyllc.period.core.notifications.ReminderKind import dev.privacyllc.period.core.datastore.NotificationPrivacy import dev.privacyllc.period.domain.cycle.PeriodRecordSource @@ -101,7 +102,7 @@ class NotificationActionHandlerTest { @Test fun `Started records a period, sourced from the notification`() = runTest { - assertTrue(handler.handle(ReminderAction.STARTED.name)) + assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today))) val period = repo.confirmedPeriods.first().single() assertEquals(today, period.startDate) @@ -111,7 +112,7 @@ class NotificationActionHandlerTest { @Test fun `Not yet records a censoring observation and no period`() = runTest { - assertTrue(handler.handle(ReminderAction.NOT_YET.name)) + assertTrue(handler.handle(ReminderActionRequest(ReminderAction.NOT_YET, today))) assertEquals(today, repo.notYetObservations.first().single().date) assertTrue(repo.confirmedPeriods.first().isEmpty()) @@ -120,7 +121,7 @@ class NotificationActionHandlerTest { @Test fun `Ended closes the period that is running rather than starting another`() = runTest { repo.confirmPeriodStart(LocalDate.of(2026, 8, 16)) - assertTrue(handler.handle(ReminderAction.ENDED.name)) + assertTrue(handler.handle(ReminderActionRequest(ReminderAction.ENDED, today))) // Regression: this used to insert a SECOND period starting today, in the // middle of the one the notification was asking about. @@ -131,7 +132,7 @@ class NotificationActionHandlerTest { @Test fun `Ended with nothing running writes nothing`() = runTest { - assertTrue(handler.handle(ReminderAction.ENDED.name)) + assertTrue(handler.handle(ReminderActionRequest(ReminderAction.ENDED, today))) assertTrue(repo.confirmedPeriods.first().isEmpty()) } @@ -140,7 +141,7 @@ class NotificationActionHandlerTest { val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 16)) as PeriodWriteResult.Added).id repo.setPeriodEnd(id, LocalDate.of(2026, 8, 19)) - assertTrue(handler.handle(ReminderAction.ENDED.name)) + assertTrue(handler.handle(ReminderActionRequest(ReminderAction.ENDED, today))) // She answered in the app first. The honest response to an answered // question is silence, not a correction. @@ -152,7 +153,7 @@ class NotificationActionHandlerTest { repo.confirmPeriodStart(LocalDate.of(2026, 8, 16)) val before = repo.confirmedPeriods.first().single() - assertTrue(handler.handle(ReminderAction.STILL_GOING.name)) + assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STILL_GOING, today))) val after = repo.confirmedPeriods.first().single() assertEquals(before, after) @@ -166,12 +167,58 @@ class NotificationActionHandlerTest { @Test fun `an action this app did not write is not guessed at`() = runTest { - // Including the strings used before the buttons carried their own - // meaning — a notification sitting in the shade across an upgrade. - listOf(null, "", "dev.privacyllc.period.action.STARTED", "started", "ENDED_MAYBE").forEach { - assertFalse("$it was treated as an action", handler.handle(it)) - } + // Nothing to apply at all. + assertFalse(handler.handle(null)) + + // And a notification posted before the day travelled with the answer — + // one sitting in somebody's shade across an upgrade. It opens the app + // and writes nothing, because there is no day it could honestly use. + assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, date = null))) + assertTrue(repo.confirmedPeriods.first().isEmpty()) assertTrue(repo.notYetObservations.first().isEmpty()) } + + // ----------------------------------------------------------------------- + // The day it was asking about + // ----------------------------------------------------------------------- + + @Test + fun `an answer given the next morning is recorded against the day it was asked`() = runTest { + // Posted yesterday evening, tapped this morning. The handler used + // LocalDate.now(), so this recorded today — and a start date is the one + // input the whole prediction engine is built on. + assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today.minusDays(1)))) + + assertEquals(today.minusDays(1), repo.confirmedPeriods.first().single().startDate) + } + + @Test + fun `an answer left for days writes nothing`() = runTest { + assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today.minusDays(5)))) + + // She has lived days the app knows nothing about. Opening the app, where + // she can see and fix what is recorded, beats a confident wrong write. + assertTrue(repo.confirmedPeriods.first().isEmpty()) + } + + @Test + fun `a question already answered in the app is not answered twice`() = runTest { + repo.confirmPeriodStart(today) + + // The notification from yesterday is still in the shade. + assertTrue(handler.handle(ReminderActionRequest(ReminderAction.NOT_YET, today.minusDays(1)))) + + // A "not yet" for a day before a start she has since logged would + // censor a forecast that has already arrived. + assertTrue(repo.notYetObservations.first().isEmpty()) + assertEquals(1, repo.confirmedPeriods.first().size) + } + + @Test + fun `an answer dated in the future writes nothing`() = runTest { + assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today.plusDays(1)))) + + assertTrue(repo.confirmedPeriods.first().isEmpty()) + } } diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/PeriodNotifier.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/PeriodNotifier.kt index efab1e7..b9728a1 100644 --- a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/PeriodNotifier.kt +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/PeriodNotifier.kt @@ -192,12 +192,24 @@ class PeriodNotifier(private val context: Context) : ReminderNotifier { */ const val EXTRA_REMINDER_ACTION = "reminder_action" - fun launchIntent(context: Context, action: ReminderAction? = null): Intent = + /** + * The day the reminder was asking about, as an epoch day. + * + * Not the day it is tapped. A notification waits in the shade, and the + * handler used to write against "now" — so an answer given on Monday to + * a question asked on Friday recorded Monday. + */ + const val EXTRA_REMINDER_DATE = "reminder_date" + + fun launchIntent( + context: Context, + request: ReminderActionRequest? = 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(EXTRA_REMINDER_ACTION, it.name) } + request?.writeTo(this) } } } diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderActionRequest.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderActionRequest.kt new file mode 100644 index 0000000..a113efc --- /dev/null +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderActionRequest.kt @@ -0,0 +1,56 @@ +package dev.privacyllc.period.core.notifications + +import android.content.Intent +import java.time.LocalDate + +/** + * A tapped notification button, and the day it was asking about. + * + * ## Why the date travels with the action + * + * A notification sits in the shade until it is dealt with. The handler used + * `LocalDate.now()`, so a reminder posted on Friday and tapped on Monday + * recorded Monday — and a period start is the single input the whole prediction + * engine is built on. Being wrong by a weekend there is worse than not asking. + * + * The date is the day the *question* was about, not the day the answer was + * given, which is why it is written when the notification is built rather than + * read when it is tapped. + * + * ## Unknown is not a guess + * + * [fromIntent] returns null for anything it does not recognise, including the + * action strings this app used before buttons carried their own meaning. A + * notification already in somebody's shade across an upgrade still opens the + * app; it simply does not write anything, because there is nothing it can know + * for certain about what she meant. + */ +data class ReminderActionRequest( + val action: ReminderAction, + /** Null for a notification posted before the date travelled — see the KDoc. */ + val date: LocalDate?, +) { + fun writeTo(intent: Intent) { + intent.putExtra(PeriodNotifier.EXTRA_REMINDER_ACTION, action.name) + date?.let { intent.putExtra(PeriodNotifier.EXTRA_REMINDER_DATE, it.toEpochDay()) } + } + + /** Dateless: it records which day was asked about, and that is a cycle date. */ + override fun toString(): String = "ReminderActionRequest(action=$action)" + + companion object { + fun fromIntent(intent: Intent): ReminderActionRequest? { + val action = ReminderAction.fromWireName( + intent.getStringExtra(PeriodNotifier.EXTRA_REMINDER_ACTION), + ) ?: return null + val epochDay = intent.getLongExtra(PeriodNotifier.EXTRA_REMINDER_DATE, NO_DATE) + return ReminderActionRequest( + action = action, + date = if (epochDay == NO_DATE) null else LocalDate.ofEpochDay(epochDay), + ) + } + + /** Not a plausible epoch day, and distinct from 1970-01-01 (which is 0). */ + private const val NO_DATE = Long.MIN_VALUE + } +} diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderActionRules.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderActionRules.kt new file mode 100644 index 0000000..dd42c57 --- /dev/null +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderActionRules.kt @@ -0,0 +1,72 @@ +package dev.privacyllc.period.core.notifications + +import dev.privacyllc.period.domain.cycle.PeriodRecord +import java.time.LocalDate + +/** + * Whether an answer is still worth writing down. + * + * Pure, and separate from the handler, because the interesting decisions here + * are all about time and none of them need a database: a notification can be + * answered days late, answered twice, or answered after the user has already + * said the same thing in the app. + * + * The bias is towards writing nothing. A stale answer still opens the app — + * which is where she can see what is actually recorded and fix it — and that is + * a better outcome than a confident write against the wrong day. + */ +object ReminderActionRules { + + /** + * How old an answer may be and still count. + * + * One day: a reminder asked in the evening and answered the next morning is + * the ordinary case, and the ordinary case has to work. Beyond that she has + * lived a day the app knows nothing about, and the app should not pretend + * otherwise on the strength of a tap. + */ + const val MAX_ACTION_AGE_DAYS = 1L + + sealed interface Verdict { + /** Write nothing; the tap has already opened the app, which is enough. */ + data object Stale : Verdict + + /** Apply the action to [ReminderActionRequest.date]. */ + data object Apply : Verdict + + /** Close this period, rather than opening one. */ + data class EndPeriod(val id: Long) : Verdict + } + + fun verdict( + request: ReminderActionRequest, + today: LocalDate, + periods: List, + ): Verdict { + val date = request.date ?: return Verdict.Stale + if (date.isAfter(today)) return Verdict.Stale + if (today.toEpochDay() - date.toEpochDay() > MAX_ACTION_AGE_DAYS) return Verdict.Stale + + val latest = periods.filter { it.isConfirmed }.maxByOrNull { it.startDate } + + return when (request.action) { + // Already answered. She logged a start on or after the day the + // notification was asking about, so the question is settled and a + // second write would either duplicate it or contradict it. + ReminderAction.STARTED, ReminderAction.NOT_YET -> + if (latest != null && !latest.startDate.isBefore(date)) Verdict.Stale else Verdict.Apply + + // Ending needs something open that began on or before the day in + // question. Nothing open is not a failure: she may have ended it in + // the app between the notification being posted and being tapped. + ReminderAction.ENDED -> { + val open = periods.filter { it.isConfirmed } + .lastOrNull { it.endDate == null && !it.startDate.isAfter(date) } + open?.let { Verdict.EndPeriod(it.id) } ?: Verdict.Stale + } + + // Answered, and deliberately writes nothing either way. + ReminderAction.STILL_GOING -> Verdict.Apply + } + } +} diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderWorker.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderWorker.kt index 7ad7757..9277d67 100644 --- a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderWorker.kt +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderWorker.kt @@ -140,7 +140,9 @@ class ReminderWorker @AssistedInject constructor( NotificationCompat.Action.Builder( 0, button.label, - openApp(button.action), + // The day the question is about travels with the + // answer, so a tap tomorrow still means today. + openApp(ReminderActionRequest(button.action, today)), ).build() }, ) @@ -169,11 +171,11 @@ class ReminderWorker @AssistedInject constructor( * overwrite one another's PendingIntent (they differ only in an extra, and * FLAG_UPDATE_CURRENT would otherwise make the last one win for all). */ - private fun openApp(action: ReminderAction? = null): PendingIntent = + private fun openApp(request: ReminderActionRequest? = null): PendingIntent = PendingIntent.getActivity( applicationContext, - action?.let { it.ordinal + 1 } ?: 0, - PeriodNotifier.launchIntent(applicationContext, action), + request?.let { it.action.ordinal + 1 } ?: 0, + PeriodNotifier.launchIntent(applicationContext, request), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) } diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 967827e..a86318d 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -157,6 +157,32 @@ 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 the exception *type* and never a record's contents — §45. +### An answer belongs to the day it was asked about + +A notification waits in the shade until somebody deals with it. The handler used +`LocalDate.now()`, so a reminder posted on Friday and tapped on Monday recorded +Monday — and a period start is the single input the whole prediction engine is +built on. Being wrong by a weekend there is worse than never asking. + +The day the question was about now travels in the PendingIntent, written when the +notification is built rather than read when it is tapped, and the parked +notification action carries it too. `ReminderActionRules` then decides whether the +answer is still worth writing: nothing dated in the future, nothing older than a +day, nothing already settled by a start she has logged since, and `ENDED` only +where something is actually open to close. + +The bias is deliberately towards writing nothing. A stale tap still opens the +app, which is where she can see what is recorded and change it — a better outcome +than a confident write against the wrong day. Anything unrecognised, including +the action strings used before buttons carried their own meaning, is treated the +same way: a notification sitting in a shade across an upgrade opens the app and +records nothing. + +**The Intent's extras are consumed once.** `MainActivity` removes them after +parking, and only parks at all when `savedInstanceState` is null: after process +death Android redelivers the original Intent with its extras intact, so a +rotation or a restore would otherwise apply a days-old answer a second time. + ### Scheduling a reminder is not the same as saying when `ExistingPeriodicWorkPolicy.UPDATE` carries the previous request's