From 8bfd61319687899af114197dd94e4bff03cde48b Mon Sep 17 00:00:00 2001 From: A-nony-mous Date: Mon, 19 Oct 2026 02:18:09 +0800 Subject: [PATCH] fix(notifications): keep the colors that carry a small icon's shape Apps that ship a filled colour bitmap as their small icon (QQ, WeChat, DiDi) reached the glasses as a flat block: tinting every small icon white keeps the alpha and drops the colours that carried the shape. System icons are unaffected, since their alpha is cut out. Classify from the rendered pixels instead: hue means colour artwork and keeps its colours; a colourless icon is a template only when its alpha is cut out. Template icons render exactly as before. The debug-icon dump now writes in debug builds only and sweeps once per process. --- ...aceclawMediaNotificationListenerService.kt | 81 +++++++++++++++++-- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/App_Resources/Android/src/main/java/com/faceclaw/app/FaceclawMediaNotificationListenerService.kt b/App_Resources/Android/src/main/java/com/faceclaw/app/FaceclawMediaNotificationListenerService.kt index 5ca84df2..b8bb3613 100644 --- a/App_Resources/Android/src/main/java/com/faceclaw/app/FaceclawMediaNotificationListenerService.kt +++ b/App_Resources/Android/src/main/java/com/faceclaw/app/FaceclawMediaNotificationListenerService.kt @@ -30,8 +30,16 @@ class FaceclawMediaNotificationListenerService : NotificationListenerService() { companion object { private const val TAG = "FaceclawNotify" private const val NOTIFICATION_ICON_GAMMA = 1.6 + /** Alpha at or below this is treated as cut out when classifying an icon. */ + private const val TEMPLATE_ALPHA_FLOOR = 32 + /** Spread between an icon pixel's brightest and darkest channel that reads as hue. */ + private const val TEMPLATE_CHROMA_THRESHOLD = 24 private const val EXTRA_SUBSTITUTE_APP_NAME = "android.substName" + /** Set once the debug-icon directory has been cleaned for this process. */ + @Volatile + private var debugIconsPruned = false + @Volatile private var activeService: FaceclawMediaNotificationListenerService? = null private val mainHandler = Handler(Looper.getMainLooper()) @@ -412,13 +420,12 @@ class FaceclawMediaNotificationListenerService : NotificationListenerService() { if (smallIcon != null) { val drawable: Drawable? = smallIcon.loadDrawable(service) if (drawable != null) { - // Status-bar small icons are alpha templates: the platform - // draws them tinted and ignores their color channels, which - // apps may fill with garbage (Discord ships noise there). - // Tint white so the shape comes from alpha alone, matching - // how the status bar renders them. + // Template or not is decided from the rendered pixels + // (see iconIsAlphaTemplate): apps also ship filled color + // bitmaps as their small icon, and tinting one of those + // white keeps the alpha while discarding the shape that + // the colors carried, leaving a solid block. drawable.mutate() - drawable.setTint(Color.WHITE) if (drawable is BitmapDrawable) { drawable.setFilterBitmap(true) } @@ -443,12 +450,17 @@ class FaceclawMediaNotificationListenerService : NotificationListenerService() { private fun appendIconGrayBytes(drawable: Drawable, size: Int, out: ByteArrayOutputStream, service: FaceclawMediaNotificationListenerService, index: Int, packageName: String?) { val bitmap = renderIconScaled(drawable, size) + val template = iconIsAlphaTemplate(bitmap) dumpIconDebugPng(service, index, packageName, bitmap) for (y in 0 until size) { for (x in 0 until size) { val color = bitmap.getPixel(x, y) val alpha = Color.alpha(color) - val grayLinear = (0.2126 * Color.red(color) + 0.7152 * Color.green(color) + 0.0722 * Color.blue(color)) * alpha / (255.0 * 255.0) + val grayLinear = if (template) { + alpha / 255.0 + } else { + (0.2126 * Color.red(color) + 0.7152 * Color.green(color) + 0.0722 * Color.blue(color)) * alpha / (255.0 * 255.0) + } val gray = Math.round(255.0 * Math.pow(Math.max(0.0, Math.min(1.0, grayLinear)), NOTIFICATION_ICON_GAMMA)).toInt() out.write(gray and 0xff) } @@ -456,6 +468,47 @@ class FaceclawMediaNotificationListenerService : NotificationListenerService() { bitmap.recycle() } + /** + * Whether a rendered icon is an alpha template: a shape the platform + * draws by tinting, where alpha alone carries the outline. + * + * Small icons are meant to be templates, but plenty of apps put a filled + * color bitmap there instead. Tinting one of those white keeps its alpha + * and throws away the only thing that carried the shape, so the glasses + * got a blank block where the icon should be. + * + * Hue settles it: an icon with color is color artwork. A colorless icon is + * a template only when its alpha is actually cut out, and a colorless + * icon with solid alpha is a flat image its own brightness has to carry. + */ + private fun iconIsAlphaTemplate(bitmap: Bitmap): Boolean { + var opaque = 0 + var transparent = 0 + var colored = 0 + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + val color = bitmap.getPixel(x, y) + val alpha = Color.alpha(color) + if (alpha < TEMPLATE_ALPHA_FLOOR) { + transparent += 1 + continue + } + opaque += 1 + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + val high = Math.max(red, Math.max(green, blue)) + val low = Math.min(red, Math.min(green, blue)) + if (high - low >= TEMPLATE_CHROMA_THRESHOLD) colored += 1 + } + } + if (opaque == 0) return false + // A quarter of the icon carrying hue is color artwork, whatever its alpha does. + if (colored * 4 >= opaque) return false + // Otherwise it is a template only if there is something cut out of it. + return transparent * 20 >= opaque + transparent + } + /** * Render a drawable at the target size with proper downscaling. Detailed * sources (e.g. avatar bitmaps used as notification icons) are rendered at @@ -483,6 +536,11 @@ class FaceclawMediaNotificationListenerService : NotificationListenerService() { return bitmap } + private fun isDebuggable(service: FaceclawMediaNotificationListenerService): Boolean { + val flags = service.applicationInfo.flags + return (flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 + } + /** * Debug aid for garbled-icon reports: saves each rendered icon to * /debug-icons/ (adb-pullable) so extraction problems can @@ -493,11 +551,20 @@ class FaceclawMediaNotificationListenerService : NotificationListenerService() { if (index < 0) { return } + // Debug aid only. Nobody can pull these files out of a release build, + // and the directory kept one file per icon per index forever. + if (!isDebuggable(service)) { + return + } try { val dir = java.io.File(service.getExternalFilesDir(null), "debug-icons") if (!dir.exists() && !dir.mkdirs()) { return } + if (!debugIconsPruned) { + debugIconsPruned = true + dir.listFiles()?.forEach { stale -> stale.delete() } + } val safeName = if (packageName == null) "unknown" else packageName.replace(Regex("[^A-Za-z0-9._-]"), "_") val file = java.io.File(dir, "icon-$index-$safeName.png") java.io.FileOutputStream(file).use { stream ->