Privacy-Period-Tracker/core/notifications/build.gradle.kts

51 lines
1.5 KiB
Plaintext
Raw Permalink Normal View History

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
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "dev.privacyllc.period.core.notifications"
compileSdk = 37
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
fix: stop counting reminders the app could not send A reminder can be switched on and still never arrive: the permission denied, notifications off for the whole app, or the channel blocked. notify() returns false in all three cases, and both call sites discarded it while still counting the check-in. So with notifications denied the counter climbed to §30's limit and the app stopped asking -- permanently, having never once asked. Counted only when posted now. canPost() also asks whether notifications are enabled at all and whether this mode's channel is blocked; below API 33 the permission is granted by definition, so an app whose notifications the user had switched off posted into nothing and called it asking. The settings screen says so, in one row above the toggles, with a button to the system setting that would fix it -- and re-reads on resume, so somebody who leaves to switch notifications back on is believed when she returns. Revocation after the fact was previously undetectable: hasPermission() was called from nowhere in main. A denial does not switch the toggle back off. That is the tempting fix and it is wrong: she said she wants the reminder, and rewriting her answer means a later grant changes nothing and she has to find the toggle again to discover that. The preference records what she asked for; the row records what the system is doing about it. ReminderWorker now takes a ReminderNotifier rather than building one from the application context, which is what made it testable. It had no test of any kind -- the class that reads the history, applies the rules, posts, and counts -- and every defect in this batch lived in that gap. Six now, over a real repository and a real preference store with only the notifier faked, since what is asserted is precisely what the worker does with the notifier's answer. Two things the prove-guard discipline caught that a green suite did not. The posted-and-counts guard reddened nothing at first, because the worker had no tests to redden -- the fix was unproven until the harness existed. And an assertion of mine read vm.state.value, which is stateIn(WhileSubscribed): with nobody collecting, it sits on the defaults, where every reminder is already true. That test could not have failed. It reads the store now. closes #71 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:08:44 -05:00
testOptions {
// The worker builds a real Notification, which resolves
// R.drawable.ic_notification — Robolectric needs the resources for that.
unitTests.isIncludeAndroidResources = true
}
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
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
api(project(":core:datastore"))
implementation(project(":core:data"))
implementation(project(":domain:cycle"))
implementation(project(":domain:prediction"))
implementation(libs.androidx.work.runtime)
implementation(libs.androidx.core.ktx)
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.work)
ksp(libs.hilt.compiler)
ksp(libs.androidx.hilt.compiler)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
fix: stop counting reminders the app could not send A reminder can be switched on and still never arrive: the permission denied, notifications off for the whole app, or the channel blocked. notify() returns false in all three cases, and both call sites discarded it while still counting the check-in. So with notifications denied the counter climbed to §30's limit and the app stopped asking -- permanently, having never once asked. Counted only when posted now. canPost() also asks whether notifications are enabled at all and whether this mode's channel is blocked; below API 33 the permission is granted by definition, so an app whose notifications the user had switched off posted into nothing and called it asking. The settings screen says so, in one row above the toggles, with a button to the system setting that would fix it -- and re-reads on resume, so somebody who leaves to switch notifications back on is believed when she returns. Revocation after the fact was previously undetectable: hasPermission() was called from nowhere in main. A denial does not switch the toggle back off. That is the tempting fix and it is wrong: she said she wants the reminder, and rewriting her answer means a later grant changes nothing and she has to find the toggle again to discover that. The preference records what she asked for; the row records what the system is doing about it. ReminderWorker now takes a ReminderNotifier rather than building one from the application context, which is what made it testable. It had no test of any kind -- the class that reads the history, applies the rules, posts, and counts -- and every defect in this batch lived in that gap. Six now, over a real repository and a real preference store with only the notifier faked, since what is asserted is precisely what the worker does with the notifier's answer. Two things the prove-guard discipline caught that a green suite did not. The posted-and-counts guard reddened nothing at first, because the worker had no tests to redden -- the fix was unproven until the harness existed. And an assertion of mine read vm.state.value, which is stateIn(WhileSubscribed): with nobody collecting, it sits on the defaults, where every reminder is already true. That test could not have failed. It reads the store now. closes #71 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:08:44 -05:00
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.work.testing)
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
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
}