2 Commits
Author SHA1 Message Date
TMC 1f9116d666 Release 0.3.0 2026-11-12 00:05:16 +01:00
TMCandClaude Fable 5.2 4e48615b5a 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 <noreply@anthropic.com>
2026-11-12 00:05:08 +01:00
10 changed files with 173 additions and 5 deletions

No files matched your search

+4
View File
@@ -2,4 +2,8 @@
<resources>
<!-- The wave alphabet view inside the drawer (app.flexera.drawer.WaveAlphabetView). -->
<item name="flexera_wave_alphabet" type="id" />
<!-- View tag that marks an app row of the drawer list (FlexeraDrawer.styleAppRow). -->
<item name="flexera_drawer_row" type="id" />
<!-- View tag on a drawer row: set while the row shows a notification preview under the app name. -->
<item name="flexera_notification_preview" type="id" />
</resources>
+2
View File
@@ -40,6 +40,8 @@
<!-- Read by screen readers as the name of the alphabet strip, followed by the current letter -->
<string name="flexera_alphabet_description">Alphabet</string>
<!-- App drawer settings (app.flexera.ui.FlexeraDrawerSettings) -->
<string name="flexera_notification_previews">Notification previews</string>
<string name="flexera_notification_previews_description">Show an app\'s latest notification under its name in the app list. Works when notification dots are on (Settings, General).</string>
<string name="flexera_alphabet_heading">Alphabet</string>
<string name="flexera_alphabet_on_left">Alphabet on the left edge</string>
<string name="flexera_alphabet_on_left_description">For left-handed use. The letters normally sit on the right edge.</string>
@@ -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))
@@ -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<String, Preview>()
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<StatusBarNotification>) {
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()
}
@@ -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),
+1 -1
View File
@@ -2,4 +2,4 @@
# build.gradle reads this and derives the Android version code from it
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
# flexera/release.ps1 raises it for every release.
versionName=0.2.0
versionName=0.3.0
@@ -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")
}
@@ -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);
}
}
}
@@ -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;
@@ -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