From 4e48615b5ac2c132a3ea78ff2f8226f5957390ba Mon Sep 17 00:00:00 2001 From: TMC Date: Thu, 12 Nov 2026 00:05:08 +0100 Subject: [PATCH] Show an app's latest notification under its name in the drawer list PRD AD-11, the last buildable row of M4. - FlexeraNotificationPreview keeps the text of the notifications that currently show a dot, fed by Launcher3's NotificationListener on its worker thread, and sets a second, smaller and dimmer line on a drawer row whenever the row's dot state is applied. - It uses what the dots already use: the same notification access and the same filter for which notifications count. Without notification access nothing changes. - PopupDataProvider now refreshes an app's icons on every posted notification, so a notification updated in place brings its new text to the row. - Only rows of the drawer list take the second line (marked with a view tag); search result icons share the "all apps" display type but stay as they were. - New setting under Settings > App drawer: Notification previews, on by default. Checked on the Android 17 emulator (debug build): a test SMS showed under Messages, a second one replaced it while the drawer was open, removing the notification put the plain name back, and the setting switches the line off and on. Co-Authored-By: Claude Fable 5.2 --- flexera/res/values/flexera_ids.xml | 4 + flexera/res/values/flexera_strings.xml | 2 + .../src/app/flexera/drawer/FlexeraDrawer.kt | 3 + .../drawer/FlexeraNotificationPreview.kt | 132 ++++++++++++++++++ .../app/flexera/ui/FlexeraDrawerSettings.kt | 5 + .../preferences/PreferenceManager.kt | 3 + src/com/android/launcher3/BubbleTextView.java | 5 + .../notification/NotificationListener.java | 14 +- .../launcher3/popup/PopupDataProvider.java | 8 +- 9 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 flexera/src/app/flexera/drawer/FlexeraNotificationPreview.kt diff --git a/flexera/res/values/flexera_ids.xml b/flexera/res/values/flexera_ids.xml index aae70bbec8..6731978e18 100644 --- a/flexera/res/values/flexera_ids.xml +++ b/flexera/res/values/flexera_ids.xml @@ -2,4 +2,8 @@ + + + + diff --git a/flexera/res/values/flexera_strings.xml b/flexera/res/values/flexera_strings.xml index 157a9aa446..03b8803898 100644 --- a/flexera/res/values/flexera_strings.xml +++ b/flexera/res/values/flexera_strings.xml @@ -40,6 +40,8 @@ Alphabet + Notification previews + Show an app\'s latest notification under its name in the app list. Works when notification dots are on (Settings, General). Alphabet Alphabet on the left edge For left-handed use. The letters normally sit on the right edge. diff --git a/flexera/src/app/flexera/drawer/FlexeraDrawer.kt b/flexera/src/app/flexera/drawer/FlexeraDrawer.kt index 74ece1ccc1..39079beb2b 100644 --- a/flexera/src/app/flexera/drawer/FlexeraDrawer.kt +++ b/flexera/src/app/flexera/drawer/FlexeraDrawer.kt @@ -87,6 +87,9 @@ object FlexeraDrawer { @JvmStatic fun styleAppRow(icon: BubbleTextView) { val res = icon.resources + // Other icons share the "all apps" display type (search results, for one); this tag + // tells list rows apart from them. + icon.setTag(R.id.flexera_drawer_row, true) icon.layoutParams.height = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height) icon.compoundDrawablePadding = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_icon_gap) icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.flexera_drawer_row_text_size)) diff --git a/flexera/src/app/flexera/drawer/FlexeraNotificationPreview.kt b/flexera/src/app/flexera/drawer/FlexeraNotificationPreview.kt new file mode 100644 index 0000000000..c09ffecc29 --- /dev/null +++ b/flexera/src/app/flexera/drawer/FlexeraNotificationPreview.kt @@ -0,0 +1,132 @@ +package app.flexera.drawer + +import android.app.Notification +import android.service.notification.StatusBarNotification +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextUtils +import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan +import app.lawnchair.preferences.PreferenceManager +import com.android.launcher3.BubbleTextView +import com.android.launcher3.R +import com.android.launcher3.dot.DotInfo +import com.android.launcher3.model.data.ItemInfo +import java.util.concurrent.ConcurrentHashMap + +/** + * Inline notification previews (PRD AD-11): in the drawer list, a row whose app has + * notifications shows the latest one under the app name, smaller and dimmer. + * + * It rides on what Launcher3 already has for notification dots. The same notification access + * feeds it, the same filter decides which notifications count, and a row is refreshed whenever + * its dot is. Without notification access there are no dots and no previews. + * + * Launcher3's listener hands each notification to [onPosted], [onRemoved] or [onFullRefresh] on + * its worker thread; the rows read the stored text on the main thread through [apply]. + */ +object FlexeraNotificationPreview { + + private class Preview(val text: String, val postTime: Long) + + /** Latest text per notification key, for the notifications that currently show a dot. */ + private val previews = ConcurrentHashMap() + + private const val MAX_LENGTH = 140 + + /** The preview line's text size as a share of the app name's. */ + private const val TEXT_SCALE = 0.78f + + /** White at 70%, so the app name stays the stronger of the two lines. */ + private const val TEXT_COLOR = 0xB3FFFFFF.toInt() + + @JvmStatic + fun onPosted(sbn: StatusBarNotification) { + val text = previewText(sbn) + if (text == null) previews.remove(sbn.key) else previews[sbn.key] = Preview(text, sbn.postTime) + } + + @JvmStatic + fun onRemoved(sbn: StatusBarNotification) { + previews.remove(sbn.key) + } + + @JvmStatic + fun onFullRefresh(active: List) { + previews.clear() + active.forEach(::onPosted) + } + + /** + * Sets [icon]'s text for [info]: the app name, plus the latest notification under it when + * [dot] says the app has any. Called for drawer rows whenever their dot state is applied. + */ + @JvmStatic + fun apply(icon: BubbleTextView, info: ItemInfo, dot: DotInfo?) { + // Only rows of the drawer list have room for a second line. + if (icon.getTag(R.id.flexera_drawer_row) == null) return + val name = info.title ?: return + val preview = if (dot != null && isEnabled(icon)) latest(dot) else null + + if (preview == null) { + if (icon.getTag(R.id.flexera_notification_preview) != null) { + // The row was showing a preview, for this app earlier or for another app before + // the view was reused. Back to the plain one-line name. + icon.setTag(R.id.flexera_notification_preview, null) + icon.setLines(1) + icon.text = name + } + return + } + + // The text is set afresh every time. Binding a row puts the plain name back first, so + // what the row showed before says nothing about what it shows now. + + // Keep a long app name on its own line, so the preview is never pushed off the row. + val width = icon.width - icon.compoundPaddingLeft - icon.compoundPaddingRight + val firstLine = if (width > 0) { + TextUtils.ellipsize(name, icon.paint, width.toFloat(), TextUtils.TruncateAt.END) + } else { + name + } + val label = SpannableStringBuilder(firstLine).append('\n') + val start = label.length + label.append(preview) + label.setSpan(RelativeSizeSpan(TEXT_SCALE), start, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + label.setSpan(ForegroundColorSpan(TEXT_COLOR), start, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + + icon.setLines(2) + icon.ellipsize = TextUtils.TruncateAt.END + icon.text = label + icon.setTag(R.id.flexera_notification_preview, true) + } + + private fun isEnabled(icon: BubbleTextView): Boolean = + PreferenceManager.getInstance(icon.context).flexeraNotificationPreviews.get() + + /** The text of the newest of the app's notifications that has any text. */ + private fun latest(dot: DotInfo): String? { + var newest: Preview? = null + for (key in dot.notificationKeys) { + val candidate = previews[key.notificationKey] ?: continue + if (newest == null || candidate.postTime > newest.postTime) newest = candidate + } + return newest?.text + } + + /** "Title: text" in one line, or whichever of the two the notification has. */ + private fun previewText(sbn: StatusBarNotification): String? { + val extras = sbn.notification.extras ?: return null + val title = extras.getCharSequence(Notification.EXTRA_TITLE).oneLine() + val text = extras.getCharSequence(Notification.EXTRA_TEXT).oneLine() + val combined = when { + title.isEmpty() -> text + text.isEmpty() -> title + else -> "$title: $text" + } + return combined.take(MAX_LENGTH).ifEmpty { null } + } + + private fun CharSequence?.oneLine(): String = + this?.toString()?.replace('\n', ' ')?.trim().orEmpty() +} diff --git a/flexera/src/app/flexera/ui/FlexeraDrawerSettings.kt b/flexera/src/app/flexera/ui/FlexeraDrawerSettings.kt index a6d56410ee..c13fb99d2e 100644 --- a/flexera/src/app/flexera/ui/FlexeraDrawerSettings.kt +++ b/flexera/src/app/flexera/ui/FlexeraDrawerSettings.kt @@ -49,6 +49,11 @@ fun FlexeraDrawerSettings(modifier: Modifier = Modifier) { label = stringResource(id = R.string.suggestion_pref_screen_title), destination = Predictions, ) + SwitchPreference( + label = stringResource(id = R.string.flexera_notification_previews), + description = stringResource(id = R.string.flexera_notification_previews_description), + adapter = prefs.flexeraNotificationPreviews.getAdapter(), + ) SwitchPreference( label = stringResource(id = R.string.pref_all_apps_remember_position_title), description = stringResource(id = R.string.pref_all_apps_remember_position_description), diff --git a/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt b/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt index 226b632b59..809da92a05 100644 --- a/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt +++ b/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt @@ -220,6 +220,9 @@ class PreferenceManager @Inject constructor( // Flexera Launcher: whether a slide along the home screen's edge opens the drawer at a letter (PRD AD-10). val flexeraScrubFromHome = BoolPref("pref_flexeraScrubFromHome", false) + // Flexera Launcher: whether drawer rows show the app's latest notification under its name (PRD AD-11). + val flexeraNotificationPreviews = BoolPref("pref_flexeraNotificationPreviews", true, recreate) + override fun close() { TODO("Not yet implemented") } diff --git a/src/com/android/launcher3/BubbleTextView.java b/src/com/android/launcher3/BubbleTextView.java index eac6f1ce33..3b866368c3 100644 --- a/src/com/android/launcher3/BubbleTextView.java +++ b/src/com/android/launcher3/BubbleTextView.java @@ -1413,6 +1413,11 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver, setContentDescription(itemInfo.contentDescription); } } + // Flexera Launcher: a row in the drawer list shows its app's latest notification + // under the app name (PRD AD-11). + if (mDisplay == DISPLAY_ALL_APPS) { + app.flexera.drawer.FlexeraNotificationPreview.apply(this, itemInfo, mDotInfo); + } } } diff --git a/src/com/android/launcher3/notification/NotificationListener.java b/src/com/android/launcher3/notification/NotificationListener.java index 945e7968ee..3f34400fdc 100644 --- a/src/com/android/launcher3/notification/NotificationListener.java +++ b/src/com/android/launcher3/notification/NotificationListener.java @@ -129,13 +129,23 @@ public class NotificationListener extends NotificationListenerService { switch (message.what) { case MSG_NOTIFICATION_POSTED: { StatusBarNotification sbn = (StatusBarNotification) message.obj; - mUiHandler.obtainMessage(notificationIsValidForUI(sbn) + // Flexera Launcher: keep the text for the drawer's notification previews in step + // with the dots, before the rows are told to refresh (PRD AD-11). + boolean validForUi = notificationIsValidForUI(sbn); + if (validForUi) { + app.flexera.drawer.FlexeraNotificationPreview.onPosted(sbn); + } else { + app.flexera.drawer.FlexeraNotificationPreview.onRemoved(sbn); + } + mUiHandler.obtainMessage(validForUi ? MSG_NOTIFICATION_POSTED : MSG_NOTIFICATION_REMOVED, toKeyPair(sbn)).sendToTarget(); return true; } case MSG_NOTIFICATION_REMOVED: { StatusBarNotification sbn = (StatusBarNotification) message.obj; + // Flexera Launcher: drop the notification's preview text with its dot. + app.flexera.drawer.FlexeraNotificationPreview.onRemoved(sbn); mUiHandler.obtainMessage(MSG_NOTIFICATION_REMOVED, toKeyPair(sbn)).sendToTarget(); @@ -158,6 +168,8 @@ public class NotificationListener extends NotificationListenerService { } else { activeNotifications = new ArrayList<>(); } + // Flexera Launcher: rebuild the preview texts from the same list as the dots. + app.flexera.drawer.FlexeraNotificationPreview.onFullRefresh(activeNotifications); mUiHandler.obtainMessage(message.what, activeNotifications).sendToTarget(); return true; diff --git a/src/com/android/launcher3/popup/PopupDataProvider.java b/src/com/android/launcher3/popup/PopupDataProvider.java index 95110329d3..a0e41ea45a 100644 --- a/src/com/android/launcher3/popup/PopupDataProvider.java +++ b/src/com/android/launcher3/popup/PopupDataProvider.java @@ -102,9 +102,11 @@ public class PopupDataProvider implements NotificationListener.NotificationsChan dotInfo = new DotInfo(); mPackageUserToDotInfos.put(postedPackageUserKey, dotInfo); } - if (dotInfo.addOrUpdateNotificationKey(notificationKey)) { - updateNotificationDots(postedPackageUserKey::equals); - } + // Flexera Launcher: refresh the app's icons on every posted notification, not only when + // the dot or its count changes. A notification that is updated in place keeps its dot + // but brings new text for the drawer's preview line (PRD AD-11). + dotInfo.addOrUpdateNotificationKey(notificationKey); + updateNotificationDots(postedPackageUserKey::equals); } @Override