Privacy-Period-Tracker/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt

86 lines
3.2 KiB
Kotlin
Raw Normal View History

feat: period CRUD end to end, and stop a double tap killing the app The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
2026-08-18 02:52:35 -05:00
package dev.privacyllc.period.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
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
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
2026-08-18 15:26:59 -05:00
import dev.privacyllc.period.core.notifications.ReminderScheduler
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. Everything the product needs falls out of that one structure: the most likely date is its mode, the window is the narrowest span holding 80% of the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both with a two-day window and misses. What a window promises is that the period starts inside it. An engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
feat: period CRUD end to end, and stop a double tap killing the app The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
2026-08-18 02:52:35 -05:00
import dev.privacyllc.period.domain.prediction.PredictionEngine
import java.time.Clock
import javax.inject.Singleton
/**
* Where the app learns what a file path is, and the only place it does.
*
* Note what is absent: no `PeriodDatabase`, no DAO, no Room import anywhere in
* this module or anywhere above it. `CycleData.repository` hands back a
* repository and keeps the storage to itself see
* docs/architecture/README.md.
*/
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
/**
* Singleton because Room and DataStore both are: two instances over one
* file is a corruption bug that only shows up under concurrency.
*/
@Provides
@Singleton
fun cycleRepository(
@ApplicationContext context: Context,
engine: PredictionEngine,
clock: Clock,
): CycleRepository = CycleData.repository(context, engine, clock)
@Provides
@Singleton
fun preferencesDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create {
context.preferencesDataStoreFile("user_preferences")
}
@Provides
@Singleton
fun userPreferencesRepository(store: DataStore<Preferences>) = UserPreferencesRepository(store)
/**
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. Everything the product needs falls out of that one structure: the most likely date is its mode, the window is the narrowest span holding 80% of the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both with a two-day window and misses. What a window promises is that the period starts inside it. An engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
* The engine, and the one line that decides which one the product ships.
feat: period CRUD end to end, and stop a double tap killing the app The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
2026-08-18 02:52:35 -05:00
*
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. Everything the product needs falls out of that one structure: the most likely date is its mode, the window is the narrowest span holding 80% of the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both with a two-day window and misses. What a window promises is that the period starts inside it. An engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
* Everything else depends on [PredictionEngine] rather than an
* implementation, which is what made this swap a single line and what makes
* the next one a single line too.
*
* `BaselinePredictionEngine` stays in the tree. It stopped being the product
* and became the control: `EngineComparisonTest` scores both over the §51
* fixtures every build, so "the new engine is better" is a number rather
* than an opinion. At the swap it was **mean absolute error 0.67 against
* 1.00, and the window contained the actual start 9 times out of 9 against
* 7** the second mattering more, because a window that misses is a broken
* promise rather than a tight forecast.
feat: period CRUD end to end, and stop a double tap killing the app The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
2026-08-18 02:52:35 -05:00
*/
@Provides
@Singleton
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. Everything the product needs falls out of that one structure: the most likely date is its mode, the window is the narrowest span holding 80% of the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both with a two-day window and misses. What a window promises is that the period starts inside it. An engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
fun predictionEngine(): PredictionEngine = PersonalPredictionEngine()
feat: period CRUD end to end, and stop a double tap killing the app The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
2026-08-18 02:52:35 -05:00
/** Injected rather than read from the environment, so §50's date edge cases stay testable. */
@Provides
@Singleton
fun clock(): Clock = Clock.systemDefaultZone()
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
2026-08-18 15:26:59 -05:00
@Provides
@Singleton
fun reminderScheduler(@ApplicationContext context: Context): ReminderScheduler =
ReminderScheduler(context)
feat: period CRUD end to end, and stop a double tap killing the app The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
2026-08-18 02:52:35 -05:00
}