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 @@ + + + + + + + + + +