From caf1244cf1e5ae077f2ffefa621be99af6c814cc Mon Sep 17 00:00:00 2001 From: null Date: Wed, 19 Aug 2026 21:51:54 -0500 Subject: [PATCH] feat: the status bar shows this app's mark, not a framework glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both builders in PeriodNotifier called setSmallIcon(android.R.drawable.ic_dialog_info). Android masks a small icon to a white silhouette taken from its alpha channel, so what every reminder this app has ever sent put in the status bar was that framework asset's outline. ic_notification is redrawn rather than copied from ic_launcher_monochrome, and the difference is the point: that file is a 108dp launcher canvas whose shape sits in the upper safe zone because a launcher crops and masks it. Copying its geometry would have produced a small mark floating above centre. This is a 24dp canvas the ring nearly fills, opaque white throughout, because the system discards colour and supplies its own. Both call sites, not one. The public builder is what a locked screen renders and is the one that matters most here. ## Two tests, because "which resource id" is not the whole claim bothTheLockScreenAndTheShadeShowThisAppsOwnMark reads the posted Notification rather than the source, and checks the public version separately — a change made by half is the likely mistake and it fails silently on the surface this product is most careful about. theStatusBarMarkRendersAsAReadableSilhouette renders the vector and measures alpha coverage. Two failures look identical in source and completely different in the status bar: a vector that draws nothing, and one that draws a filled shape. Neither is caught by asserting a resource id. Proved: reverting only the public builder fails exactly one test, naming that builder. prove-guard exit 0. ## NotificationPrivacyTest could never run on minSdk Found while satisfying this issue's own verify line. GrantPermissionRule asked for POST_NOTIFICATIONS unconditionally, and that permission arrived in API 33 — so on PeriodMinSdk26 every test in the class errored with "Failed to grant permissions" before reaching an assertion, for a reason unrelated to what it tests. That is how it stayed unnoticed: it is the only emulator where it fails, and a green run on a modern image looks like a green run. The class guards what a LOCKED SCREEN shows. "Passes on the newest device" was never the claim worth having. The rule is conditional now, and below 33 no permission is needed to post at all, so a no-op rule is correct rather than a workaround. All six tests now pass on PeriodMinSdk26 — the first time this file has run there. ## Not verified The API 36 instrumented run. That emulator repeatedly dies the moment Gradle starts on this machine today, across three attempts and after freeing memory; it ran the whole app-lock UI verification earlier in the same session, so this is resource contention rather than a defect. The SECURITY_CHECKLIST row covers it, and both new assertions are resource-id and render checks whose substance does not vary by API level. closes #43 Co-Authored-By: Claude Opus 5 (1M context) --- build.gradle.kts | 4 + .../notifications/NotificationPrivacyTest.kt | 115 +++++++++++++++++- .../core/notifications/PeriodNotifier.kt | 4 +- .../src/main/res/drawable/ic_notification.xml | 43 +++++++ 4 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 core/notifications/src/main/res/drawable/ic_notification.xml diff --git a/build.gradle.kts b/build.gradle.kts index 654aeed..d2ec34c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -465,6 +465,7 @@ tasks.register("checkNoHealthLogging") { val themedResourceRoots: List = listOf( "app/src/main/res", "core/designsystem/src/main/res", + "core/notifications/src/main/res", ) /** @@ -479,6 +480,9 @@ val themedDrawableExemptions: Map = mapOf( "ic_launcher_monochrome" to "themed monochrome vector — the launcher tints it from the system palette, " + "so a night copy would be a second source of truth for one shape", + "ic_notification" to + "alpha-only status bar mark — Android masks a small icon to a silhouette " + + "and supplies the colour itself, so a dark variant would never be drawn", ) tasks.register("checkThemedDrawables") { diff --git a/core/notifications/src/androidTest/kotlin/dev/privacyllc/period/core/notifications/NotificationPrivacyTest.kt b/core/notifications/src/androidTest/kotlin/dev/privacyllc/period/core/notifications/NotificationPrivacyTest.kt index fed023d..0589b4a 100644 --- a/core/notifications/src/androidTest/kotlin/dev/privacyllc/period/core/notifications/NotificationPrivacyTest.kt +++ b/core/notifications/src/androidTest/kotlin/dev/privacyllc/period/core/notifications/NotificationPrivacyTest.kt @@ -2,6 +2,7 @@ package dev.privacyllc.period.core.notifications import android.app.Notification import android.app.NotificationManager +import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule import androidx.test.platform.app.InstrumentationRegistry @@ -12,6 +13,7 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.junit.rules.TestRule import org.junit.runner.RunWith /** @@ -32,10 +34,30 @@ class NotificationPrivacyTest { * The test APK is its own package, so the app's grant does not cover it. * Granted here rather than with `adb shell pm grant` out of band, because a * test that only passes after a manual command is a test nobody can re-run. + * + * **Conditional, and it has to be.** `POST_NOTIFICATIONS` arrived in API 33. + * On anything older the permission does not exist, so `GrantPermissionRule` + * fails with "Failed to grant permissions" *before the first assertion runs* + * — and every test in this class errors out with a message that says nothing + * about notifications. + * + * That is how this file came to be un-runnable on `PeriodMinSdk26` without + * anybody noticing: it is the only emulator where it fails, it fails for a + * reason unrelated to what it tests, and a green run on a modern image looks + * like a green run. The class guards what a **locked screen** shows, which + * is the disclosure this product is most careful about, so "passes on the + * newest device" was never the claim worth having. + * + * Below 33 the permission is not required to post at all, so a no-op rule is + * correct rather than a workaround. */ @get:org.junit.Rule - val permission: GrantPermissionRule = - GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS) + val permission: TestRule = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS) + } else { + TestRule { base, _ -> base } + } private val context = InstrumentationRegistry.getInstrumentation().targetContext private val manager = context.getSystemService(NotificationManager::class.java) @@ -104,6 +126,95 @@ class NotificationPrivacyTest { } } + /** + * Both builders carry the app's own mark, not a framework asset. + * + * Android masks a small icon to a white silhouette taken from its alpha + * channel, so the old `android.R.drawable.ic_dialog_info` reached the status + * bar as *that* asset's outline — a generic glyph, on every reminder this + * app has ever sent. + * + * Asserted on the posted `Notification` rather than by reading the source, + * and on **both** versions: the public one is what a locked screen renders, + * which is the surface this product is most careful about, and it is built + * by a separate call that is easy to update by half. + */ + @Test + fun bothTheLockScreenAndTheShadeShowThisAppsOwnMark() { + val expected = R.drawable.ic_notification + + NotificationPrivacy.entries.forEach { mode -> + manager.cancelAll() + val text = NotificationCopy.textFor( + ReminderKind.PERIOD_APPROACHING, mode, daysUntil = 2, + ) + notifier.notify(text, mode, contentIntent = null) + + val posted = postedNotification() + assertNotNull("nothing was posted for $mode", posted) + + // `Notification.icon` is deprecated and is also the only reader that + // works unchanged from API 26 to 36 — Icon.getResId() is not public + // across that whole range. + @Suppress("DEPRECATION") + assertEquals("the shade icon is not this app's, in $mode", expected, posted!!.icon) + + @Suppress("DEPRECATION") + assertEquals( + "the LOCK SCREEN icon is not this app's, in $mode — the public " + + "version is a separate builder and is the one that matters most", + expected, + posted.publicVersion?.icon, + ) + } + } + + /** + * The mark is a silhouette that actually has a silhouette. + * + * Android throws the icon's colour away and tints its **alpha**, so two + * failures look identical in source and completely different in the status + * bar: a vector that renders nothing (invisible icon), and one that renders + * a filled shape (a blob). Neither is caught by asserting which resource id + * was used. + * + * Rendered at the size it is actually drawn and measured. A ring with one + * dot covers roughly a fifth of its box; the bounds are wide enough not to + * be a pixel-comparison test, and narrow enough to fail on both mistakes. + */ + @Test + fun theStatusBarMarkRendersAsAReadableSilhouette() { + val size = 24 * context.resources.displayMetrics.density.toInt() + val drawable = requireNotNull( + androidx.core.content.ContextCompat.getDrawable(context, R.drawable.ic_notification), + ) { "ic_notification did not inflate" } + + val bitmap = android.graphics.Bitmap.createBitmap( + size, size, android.graphics.Bitmap.Config.ARGB_8888, + ) + drawable.setBounds(0, 0, size, size) + drawable.draw(android.graphics.Canvas(bitmap)) + + var opaque = 0 + for (x in 0 until size) { + for (y in 0 until size) { + if (android.graphics.Color.alpha(bitmap.getPixel(x, y)) > 0) opaque++ + } + } + val coverage = opaque.toDouble() / (size * size) + + assertTrue( + "the icon rendered almost nothing (coverage $coverage) — it would be " + + "invisible in the status bar", + coverage > 0.08, + ) + assertTrue( + "the icon rendered as a near-solid block (coverage $coverage) — a " + + "small icon is masked to its alpha, so a filled shape becomes a blob", + coverage < 0.45, + ) + } + @Test fun directModeIsTheOnlyOneMarkedPublic() { val text = NotificationCopy.textFor( 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 121e017..8a62955 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 @@ -99,14 +99,14 @@ class PeriodNotifier(private val context: Context) { // The public version is a whole second notification, and it is what a // locked screen renders. Built first so it cannot be forgotten. val public = NotificationCompat.Builder(context, channelId(privacy)) - .setSmallIcon(android.R.drawable.ic_dialog_info) + .setSmallIcon(R.drawable.ic_notification) .setContentTitle(text.publicTitle) .apply { text.publicBody?.let { setContentText(it) } } .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .build() val notification = NotificationCompat.Builder(context, channelId(privacy)) - .setSmallIcon(android.R.drawable.ic_dialog_info) + .setSmallIcon(R.drawable.ic_notification) .setContentTitle(text.privateTitle) .setContentText(text.privateBody) .setStyle(NotificationCompat.BigTextStyle().bigText(text.privateBody)) diff --git a/core/notifications/src/main/res/drawable/ic_notification.xml b/core/notifications/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..d18be89 --- /dev/null +++ b/core/notifications/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,43 @@ + + + + + + + + + +