feat: the status bar shows this app's mark, not a framework glyph

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) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-19 21:51:54 -05:00
parent 255808f2fc
commit caf1244cf1
4 changed files with 162 additions and 4 deletions

View File

@ -465,6 +465,7 @@ tasks.register("checkNoHealthLogging") {
val themedResourceRoots: List<String> = listOf( val themedResourceRoots: List<String> = listOf(
"app/src/main/res", "app/src/main/res",
"core/designsystem/src/main/res", "core/designsystem/src/main/res",
"core/notifications/src/main/res",
) )
/** /**
@ -479,6 +480,9 @@ val themedDrawableExemptions: Map<String, String> = mapOf(
"ic_launcher_monochrome" to "ic_launcher_monochrome" to
"themed monochrome vector — the launcher tints it from the system palette, " + "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", "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") { tasks.register("checkThemedDrawables") {

View File

@ -2,6 +2,7 @@ package dev.privacyllc.period.core.notifications
import android.app.Notification import android.app.Notification
import android.app.NotificationManager import android.app.NotificationManager
import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.GrantPermissionRule import androidx.test.rule.GrantPermissionRule
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
@ -12,6 +13,7 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith 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. * 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 * 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. * 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 @get:org.junit.Rule
val permission: GrantPermissionRule = val permission: TestRule =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS) GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS)
} else {
TestRule { base, _ -> base }
}
private val context = InstrumentationRegistry.getInstrumentation().targetContext private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val manager = context.getSystemService(NotificationManager::class.java) 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 @Test
fun directModeIsTheOnlyOneMarkedPublic() { fun directModeIsTheOnlyOneMarkedPublic() {
val text = NotificationCopy.textFor( val text = NotificationCopy.textFor(

View File

@ -99,14 +99,14 @@ class PeriodNotifier(private val context: Context) {
// The public version is a whole second notification, and it is what a // The public version is a whole second notification, and it is what a
// locked screen renders. Built first so it cannot be forgotten. // locked screen renders. Built first so it cannot be forgotten.
val public = NotificationCompat.Builder(context, channelId(privacy)) val public = NotificationCompat.Builder(context, channelId(privacy))
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(text.publicTitle) .setContentTitle(text.publicTitle)
.apply { text.publicBody?.let { setContentText(it) } } .apply { text.publicBody?.let { setContentText(it) } }
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build() .build()
val notification = NotificationCompat.Builder(context, channelId(privacy)) val notification = NotificationCompat.Builder(context, channelId(privacy))
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(text.privateTitle) .setContentTitle(text.privateTitle)
.setContentText(text.privateBody) .setContentText(text.privateBody)
.setStyle(NotificationCompat.BigTextStyle().bigText(text.privateBody)) .setStyle(NotificationCompat.BigTextStyle().bigText(text.privateBody))

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The status bar mark: an open ring with one offset dot, the same abstract cycle
as the themed launcher layer in app/src/main/res/drawable/ic_launcher_monochrome.xml.
REDRAWN rather than copied, and the difference matters. That file is a 108dp
launcher canvas whose shape sits in the upper safe zone, because a launcher
crops and masks it. A notification icon is a 24dp canvas the graphic nearly
fills, so copying the launcher geometry would have produced a small mark
floating above centre in the status bar.
ALPHA ONLY. Android masks a notification's small icon to a white silhouette
taken from its alpha channel, which is the whole defect this replaces: the old
android.R.drawable.ic_dialog_info is a full-colour framework asset, so what
every reminder has ever shown is that asset's silhouette rather than anything
anybody chose. Everything here is opaque white for that reason — the system
supplies the colour, and any colour written here is discarded.
It also has to survive being tiny and being the app's identity on a lock
screen. A ring with one dot still reads at 24dp in a single colour; the real
emblem — calendar, shield, padlock, crescent, botanicals, overlapping — would
flatten into a blob, which is the same reasoning the monochrome launcher layer
records.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- The cycle: an open ring, centred, stroked rather than filled. -->
<path
android:pathData="M12,12 m-8,0 a8,8 0 1,1 16,0 a8,8 0 1,1 -16,0"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:fillColor="#00000000" />
<!-- Progression: one dot on the ring, at the top. -->
<path
android:pathData="M12,4 m-2.25,0 a2.25,2.25 0 1,1 4.5,0 a2.25,2.25 0 1,1 -4.5,0"
android:fillColor="#FFFFFFFF" />
</vector>