From 7400575a01b37cdefcf65beec7ecb26469b4c42d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 04:57:02 +0000 Subject: [PATCH 1/4] Add "on hold" notifications widget to the home carousel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Hold Notifications (DND) is on and at least one notification is currently held, a new card joins the home-screen widget carousel — last, after the calendar and days-left cards. It lists each app that has parked notifications (the app name plus the familiar bell+count pill), and tapping a row releases that app's held notifications so the system re-posts them now. The card drops out again once the last app is released. - HeldNotificationsWidgetView: a presenter modeled on CalendarWidgetView, in the same monochrome, text-only register; scrollable rows sorted most-held first, then A-Z by label. - HomeFragment: builds the card in initWidgets(), adds it in setupWidgets() only while dndEnabled and something is held, and keeps it live via NotificationDndService.onNotificationsChanged (debounced, hopped to the main thread). Notification-driven refreshes skip the calendar reload so an arriving notification never yanks its scroll. Release logic is now shared with the existing home-app count pill. - strings: held_notifications_title + held_notifications_count plurals. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012ykbDeHqrtpz4VQH9XiFzV --- .../launch0/ui/HeldNotificationsWidgetView.kt | 165 ++++++++++++++++++ .../main/java/app/launch0/ui/HomeFragment.kt | 90 +++++++++- app/src/main/res/values/strings.xml | 6 + 3 files changed, 252 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/app/launch0/ui/HeldNotificationsWidgetView.kt diff --git a/app/src/main/java/app/launch0/ui/HeldNotificationsWidgetView.kt b/app/src/main/java/app/launch0/ui/HeldNotificationsWidgetView.kt new file mode 100644 index 000000000..53973026c --- /dev/null +++ b/app/src/main/java/app/launch0/ui/HeldNotificationsWidgetView.kt @@ -0,0 +1,165 @@ +package app.launch0.ui + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import app.launch0.R +import app.launch0.helper.getColorFromAttr +import app.launch0.helper.getNotificationCountDrawable + +/** + * The "on hold" home-screen widget: while Hold Notifications (DND) is on and at least one + * notification is being held, this card lists each app that currently has parked notifications — + * its name alongside the familiar bell+count pill — in the launcher's monochrome, text-only + * register so it reads as one family with the calendar and days-left cards. Tapping a row releases + * that app's held notifications so the system re-posts them now. + * + * Purely a presenter: it renders whatever [showHeldApps] is handed and reports row taps back through + * the listener. Reading the held counts and doing the releasing live in the fragment. + */ +class HeldNotificationsWidgetView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr) { + + /** One app with parked notifications: its package, display label, and how many are held. */ + data class HeldApp(val packageName: String, val label: String, val count: Int) + + private val header = LinearLayout(context) + private val titleLabel = TextView(context) + private val countLabel = TextView(context) + private val scroll = ScrollView(context) + private val list = LinearLayout(context) + + private var fg = Color.WHITE + private var shadowColor = Color.BLACK + + private var onReleaseClick: ((String) -> Unit)? = null + + init { + orientation = VERTICAL + resolveColors() + + // Header: "ON HOLD" on the start edge, the total held count on the end edge. + header.orientation = HORIZONTAL + header.gravity = Gravity.CENTER_VERTICAL + header.setPadding(dp(2), 0, dp(2), dp(9)) + + titleLabel.applyLabel(11f, alpha(0.55f)) + titleLabel.isAllCaps = true + titleLabel.letterSpacing = 0.14f + titleLabel.text = context.getString(R.string.held_notifications_title) + + countLabel.applyLabel(11f, alpha(0.42f)) + countLabel.gravity = Gravity.END + + header.addView(titleLabel, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + header.addView(countLabel, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + + // Scrolling list of app rows, with a soft fade at the top/bottom while there's more to see. + list.orientation = VERTICAL + scroll.isVerticalFadingEdgeEnabled = true + scroll.setFadingEdgeLength(dp(18)) + scroll.isVerticalScrollBarEnabled = false + scroll.overScrollMode = View.OVER_SCROLL_NEVER + scroll.addView(list, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + + addView(header, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + // The list fills whatever height the card gives us and scrolls when there are many apps. + addView(scroll, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)) + } + + /** A row tap releases the tapped app's held notifications (the fragment does the releasing). */ + fun setOnReleaseClickListener(listener: (String) -> Unit) { + onReleaseClick = listener + } + + /** Re-reads theme colours (foreground flips with the light/dark theme). */ + fun refresh() { + resolveColors() + } + + /** + * Renders one row per [apps] entry — the app name with its bell+count pill — in the order the + * fragment hands them (most-held first). The header's end label sums the held notifications. + */ + fun showHeldApps(apps: List) { + val total = apps.sumOf { it.count } + countLabel.text = resources.getQuantityString(R.plurals.held_notifications_count, total, total) + list.removeAllViews() + apps.forEach { list.addView(buildRow(it)) } + } + + private fun buildRow(app: HeldApp): View { + val row = LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(2), dp(10), dp(2), dp(10)) + isClickable = true + isFocusable = true + setOnClickListener { onReleaseClick?.invoke(app.packageName) } + } + + val name = TextView(context).apply { + applyBody(15.5f, alpha(0.95f), light = true) + maxLines = 1 + ellipsize = android.text.TextUtils.TruncateAt.END + text = app.label + } + + // The same bell+count pill used on home and drawer rows, so held counts read consistently. + val pill = ImageView(context).apply { + setImageDrawable(context.getNotificationCountDrawable(app.count)) + } + + row.addView(name, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + row.addView( + pill, + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { marginStart = dp(10) }, + ) + return row + } + + private fun resolveColors() { + fg = context.getColorFromAttr(R.attr.primaryColor) + shadowColor = context.getColorFromAttr(R.attr.primaryTextShadowColor) + titleLabel.setLabelColors(alpha(0.55f)) + countLabel.setLabelColors(alpha(0.42f)) + } + + private fun alpha(fraction: Float): Int = + Color.argb((fraction * 255).toInt(), Color.red(fg), Color.green(fg), Color.blue(fg)) + + /** Tiny, letter-spaced labels (header). */ + private fun TextView.applyLabel(sizeSp: Float, color: Int) { + setTextSize(TypedValue.COMPLEX_UNIT_SP, sizeSp) + setLabelColors(color) + typeface = Typeface.SANS_SERIF + includeFontPadding = false + } + + private fun TextView.setLabelColors(color: Int) { + setTextColor(color) + setShadowLayer(4f, 0f, 2f, shadowColor) + } + + /** Row body text; [light] picks the thin weight used for app names. */ + private fun TextView.applyBody(sizeSp: Float, color: Int, light: Boolean) { + setTextSize(TypedValue.COMPLEX_UNIT_SP, sizeSp) + setTextColor(color) + setShadowLayer(4f, 0f, 2f, shadowColor) + typeface = if (light) Typeface.create("sans-serif-light", Typeface.NORMAL) else Typeface.SANS_SERIF + includeFontPadding = false + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() +} diff --git a/app/src/main/java/app/launch0/ui/HomeFragment.kt b/app/src/main/java/app/launch0/ui/HomeFragment.kt index f8f0aa68d..5c38b22a1 100644 --- a/app/src/main/java/app/launch0/ui/HomeFragment.kt +++ b/app/src/main/java/app/launch0/ui/HomeFragment.kt @@ -91,8 +91,16 @@ class HomeFragment : Fragment(), View.OnClickListener, View.OnLongClickListener // Home-screen widgets, each wrapped in a shared card and paged through the swipeable carousel. private var calendarView: CalendarWidgetView? = null private var yearView: YearProgressView? = null + private var heldNotifView: HeldNotificationsWidgetView? = null private var calendarCard: WidgetCard? = null private var yearCard: WidgetCard? = null + private var heldNotifCard: WidgetCard? = null + + // Coalesces the flurry of listener callbacks (each posted/removed notification fires one) into a + // single widget refresh, so the "on hold" card updates live without rebuilding on every event. + // Doesn't reload the calendar, so an arriving notification never yanks its scroll position. + private val notifChangeHandler = Handler(Looper.getMainLooper()) + private val notifChangeRunnable = Runnable { if (_binding != null) setupWidgets(reloadCalendar = false) } // The cards currently in the pager, so the adapter is only rebuilt when the enabled set changes. private var widgetPages: List = emptyList() @@ -174,6 +182,12 @@ class HomeFragment : Fragment(), View.OnClickListener, View.OnLongClickListener yearCard = WidgetCard(ctx).apply { addView(yearView, FrameLayout.LayoutParams(fill, fill)) } + heldNotifView = HeldNotificationsWidgetView(ctx).also { hv -> + hv.setOnReleaseClickListener { packageName -> releaseNotificationsForApp(packageName) } + } + heldNotifCard = WidgetCard(ctx).apply { + addView(heldNotifView, FrameLayout.LayoutParams(fill, fill)) + } binding.widgetPager.registerOnPageChangeCallback(pageChangeCallback) // A gap of wallpaper between cards as they slide, so adjacent widgets read as separate. binding.widgetPager.setPageTransformer(MarginPageTransformer(20.dpToPx())) @@ -226,11 +240,19 @@ class HomeFragment : Fragment(), View.OnClickListener, View.OnLongClickListener if (prefs.showStatusBar) showStatusBar() else hideStatusBar() startCalendarTicker() + // Keep the "on hold" card live while home is showing: refresh (debounced) whenever the + // held-notification set changes. The callback can fire on a binder thread, so hop to main. + NotificationDndService.onNotificationsChanged = { + notifChangeHandler.removeCallbacks(notifChangeRunnable) + notifChangeHandler.postDelayed(notifChangeRunnable, 250L) + } } override fun onPause() { super.onPause() stopCalendarTicker() + NotificationDndService.onNotificationsChanged = null + notifChangeHandler.removeCallbacks(notifChangeRunnable) } override fun onClick(view: View) { @@ -463,17 +485,22 @@ class HomeFragment : Fragment(), View.OnClickListener, View.OnLongClickListener /** * (Re)builds the widget carousel from the enabled widgets. Each enabled widget's card becomes a - * page in a stable order (calendar, then days-left); the pager and its dots hide entirely when - * none are on. Called on resume and whenever a widget is toggled. It only re-populates the pager - * when the enabled set actually changed — so a plain resume doesn't flicker — then refreshes - * contents. + * page in a stable order (calendar, then days-left, then the "on hold" card); the pager and its + * dots hide entirely when none are on. The "on hold" card only joins the carousel while Hold + * Notifications is enabled and at least one notification is currently being held, and drops out + * again once everything is released. Called on resume, whenever a widget is toggled, and when + * held notifications change. It only re-populates the pager when the enabled set actually changed + * — so a plain resume doesn't flicker — then refreshes contents. */ - private fun setupWidgets() { + private fun setupWidgets(reloadCalendar: Boolean = true) { if (_binding == null) return val pager = binding.widgetPager + val heldByPackage = + if (prefs.dndEnabled) NotificationDndService.parkedCountsByPackage(prefs) else emptyMap() val desired = mutableListOf() if (prefs.showCalendarWidget) calendarCard?.let { desired += it } if (prefs.showYearWidget) yearCard?.let { desired += it } + if (heldByPackage.isNotEmpty()) heldNotifCard?.let { desired += it } binding.widgetPagerLayout.isVisible = desired.isNotEmpty() if (desired.isEmpty()) { @@ -504,15 +531,49 @@ class HomeFragment : Fragment(), View.OnClickListener, View.OnLongClickListener calendarView?.refresh() if (requireContext().hasCalendarPermission()) { // A fresh show jumps to the current/next event; events arrive via bindCalendarEvents(). - calendarAutoScroll = true - viewModel.loadCalendarEvents() + // Skipped on a held-notification refresh so it never yanks the reader's scroll. + if (reloadCalendar) { + calendarAutoScroll = true + viewModel.loadCalendarEvents() + } } else { calendarView?.showMessage(getString(R.string.calendar_grant_access)) } } + if (heldByPackage.isNotEmpty()) { + heldNotifCard?.refresh() + heldNotifView?.refresh() + heldNotifView?.showHeldApps(buildHeldApps(heldByPackage)) + } positionWidgetPagerBelowHeader() } + /** + * Turns the per-package held counts into display rows for the "on hold" card, ordered most-held + * first (then A→Z by label) so the busiest app leads. Labels reuse the app's own name. + */ + private fun buildHeldApps(counts: Map): List = + counts.entries + .map { (packageName, count) -> + HeldNotificationsWidgetView.HeldApp(packageName, appLabelForPackage(packageName), count) + } + .sortedWith( + compareByDescending { it.count } + .thenBy { it.label.lowercase() } + ) + + /** Best display name for [packageName]: the user's rename if set, else the app's label, else the package. */ + private fun appLabelForPackage(packageName: String): String { + val rename = prefs.getAppRenameLabel(packageName) + if (rename.isNotBlank()) return rename + return try { + val pm = requireContext().packageManager + pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString() + } catch (e: Exception) { + packageName + } + } + /** Re-reads events (used by the minute ticker); [autoScroll] false keeps the current scroll. */ private fun refreshCalendarWidget(autoScroll: Boolean) { if (_binding == null) return @@ -795,8 +856,15 @@ class HomeFragment : Fragment(), View.OnClickListener, View.OnLongClickListener return Math.round(millis / 60000.0).toInt() } - private fun releaseNotificationsForHomeApp(location: Int) { - val packageName = prefs.getAppPackage(location) + private fun releaseNotificationsForHomeApp(location: Int) = + releaseNotificationsForApp(prefs.getAppPackage(location)) + + /** + * Releases every notification currently held for [packageName] so the system re-posts them now, + * then refreshes the home screen — which drops the "on hold" card once its last app is released. + * Shared by the home-app count pill and the "on hold" widget rows. + */ + private fun releaseNotificationsForApp(packageName: String) { if (packageName.isBlank()) return if (NotificationDndService.parkedCount(prefs, packageName) == 0) return NotificationDndService.releaseForPackage(prefs, packageName) @@ -1149,13 +1217,17 @@ class HomeFragment : Fragment(), View.OnClickListener, View.OnLongClickListener override fun onDestroyView() { super.onDestroyView() stopCalendarTicker() + NotificationDndService.onNotificationsChanged = null + notifChangeHandler.removeCallbacks(notifChangeRunnable) binding.widgetPager.unregisterOnPageChangeCallback(pageChangeCallback) binding.widgetPager.adapter = null widgetPages = emptyList() calendarView = null yearView = null + heldNotifView = null calendarCard = null yearCard = null + heldNotifCard = null _binding = null } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d0308595..8e05c112e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -190,6 +190,12 @@ Please allow notification access for Launch0 Select at least one app to hold Held notifications released + + On hold + + %d held + %d held + %1$d min 30 45 From 3628fc9c4d950c5c0aef902746b1a84bed46bb27 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 05:13:50 +0000 Subject: [PATCH 2/4] Held-notifications widget: profile-aware labels, no calendar yank on release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from review of the "on hold" widget: - appLabelForPackage now resolves the app label across every user profile via LauncherApps (mirroring getAppsList) instead of the personal-user PackageManager only. Held counts are keyed by bare package (the notification key's user id is stripped), so a work-profile-only app — absent for user 0 — previously fell back to showing its raw package name. PackageManager and the raw package remain as last resorts. - Releasing an app's held notifications (from the new widget row or the existing home-app pill) now refreshes via populateHomeScreen with reloadCalendar = false, so it re-decorates the pills and the held card without reloading the calendar and snapping its scroll back to the top — matching the notification-arrival refresh path. populateHomeScreen gains a reloadCalendar parameter (default true) that it forwards to setupWidgets; all other callers keep the fresh-show behavior. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012ykbDeHqrtpz4VQH9XiFzV --- .../main/java/app/launch0/ui/HomeFragment.kt | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/launch0/ui/HomeFragment.kt b/app/src/main/java/app/launch0/ui/HomeFragment.kt index 5c38b22a1..d0789a7ad 100644 --- a/app/src/main/java/app/launch0/ui/HomeFragment.kt +++ b/app/src/main/java/app/launch0/ui/HomeFragment.kt @@ -13,6 +13,7 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.UserManager import android.view.Gravity import android.view.LayoutInflater import android.view.View @@ -562,12 +563,30 @@ class HomeFragment : Fragment(), View.OnClickListener, View.OnLongClickListener .thenBy { it.label.lowercase() } ) - /** Best display name for [packageName]: the user's rename if set, else the app's label, else the package. */ + /** + * Best display name for [packageName]: the user's rename if set, else the app's launcher label, + * else the raw package. Held counts are keyed by bare package (the notification key's user id is + * stripped), so the label is resolved across every profile via [LauncherApps] — otherwise a + * work-profile-only app, absent for the personal user, would fall back to its raw package name. + */ private fun appLabelForPackage(packageName: String): String { val rename = prefs.getAppRenameLabel(packageName) if (rename.isNotBlank()) return rename + val ctx = requireContext() + try { + val launcherApps = ctx.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps + val userManager = ctx.getSystemService(Context.USER_SERVICE) as UserManager + for (profile in userManager.userProfiles) { + val label = launcherApps.getActivityList(packageName, profile) + .firstOrNull()?.label?.toString() + if (!label.isNullOrBlank()) return label + } + } catch (e: Exception) { + e.printStackTrace() + } + // Last resorts: the personal-user PackageManager label, then the raw package name. return try { - val pm = requireContext().packageManager + val pm = ctx.packageManager pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString() } catch (e: Exception) { packageName @@ -679,10 +698,10 @@ class HomeFragment : Fragment(), View.OnClickListener, View.OnLongClickListener } } - private fun populateHomeScreen(appCountUpdated: Boolean) { + private fun populateHomeScreen(appCountUpdated: Boolean, reloadCalendar: Boolean = true) { if (appCountUpdated) hideHomeApps() populateDateTime() - setupWidgets() + setupWidgets(reloadCalendar) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) populateScreenTime() @@ -868,7 +887,9 @@ class HomeFragment : Fragment(), View.OnClickListener, View.OnLongClickListener if (packageName.isBlank()) return if (NotificationDndService.parkedCount(prefs, packageName) == 0) return NotificationDndService.releaseForPackage(prefs, packageName) - populateHomeScreen(false) + // Refresh the held card and home-app pills, but leave the calendar as-is: releasing is a + // notification-set change, so it shouldn't reload the calendar and snap its scroll to top. + populateHomeScreen(false, reloadCalendar = false) requireContext().showToast(getString(R.string.dnd_released)) } From 02ae716b8c51c00ca18844c5bfd988120bbd1437 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 09:30:26 +0000 Subject: [PATCH 3/4] Held-notifications widget: show the app icon before the name One-letter-named apps (e.g. the X / Twitter app, whose label is just "X") were unrecognisable as a bare name in the "on hold" card. Each row now leads with the app's launcher icon, shaped and sized per the user's icon settings, so the app is obvious at a glance. The icon is resolved in the same profile-aware LauncherApps pass as the label (getBadgedIcon, then Drawable.toShapedIcon), so work-profile apps get the right badged icon; it falls back to the PackageManager icon and is omitted entirely when the launcher's "show app icons" setting is off. HeldApp gains an optional icon the view renders before the name. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012ykbDeHqrtpz4VQH9XiFzV --- .../launch0/ui/HeldNotificationsWidgetView.kt | 25 +++++++- .../main/java/app/launch0/ui/HomeFragment.kt | 62 +++++++++++++------ 2 files changed, 66 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/app/launch0/ui/HeldNotificationsWidgetView.kt b/app/src/main/java/app/launch0/ui/HeldNotificationsWidgetView.kt index 53973026c..fc2cbeb86 100644 --- a/app/src/main/java/app/launch0/ui/HeldNotificationsWidgetView.kt +++ b/app/src/main/java/app/launch0/ui/HeldNotificationsWidgetView.kt @@ -3,6 +3,7 @@ package app.launch0.ui import android.content.Context import android.graphics.Color import android.graphics.Typeface +import android.graphics.drawable.Drawable import android.util.AttributeSet import android.util.TypedValue import android.view.Gravity @@ -31,8 +32,17 @@ class HeldNotificationsWidgetView @JvmOverloads constructor( defStyleAttr: Int = 0, ) : LinearLayout(context, attrs, defStyleAttr) { - /** One app with parked notifications: its package, display label, and how many are held. */ - data class HeldApp(val packageName: String, val label: String, val count: Int) + /** + * One app with parked notifications: its package, display label, how many are held, and an + * optional pre-shaped launcher icon (null when the launcher's "show app icons" setting is off, + * or the icon can't be resolved). + */ + data class HeldApp( + val packageName: String, + val label: String, + val count: Int, + val icon: Drawable? = null, + ) private val header = LinearLayout(context) private val titleLabel = TextView(context) @@ -109,6 +119,17 @@ class HeldNotificationsWidgetView @JvmOverloads constructor( setOnClickListener { onReleaseClick?.invoke(app.packageName) } } + // The app icon leads the row (when enabled) so one-letter-named apps (e.g. "X") are + // recognisable at a glance. The drawable is already sized and shaped by the fragment. + app.icon?.let { icon -> + val iconView = ImageView(context).apply { setImageDrawable(icon) } + val size = icon.intrinsicWidth.takeIf { it > 0 } ?: dp(24) + row.addView( + iconView, + LayoutParams(size, size).apply { marginEnd = dp(12) }, + ) + } + val name = TextView(context).apply { applyBody(15.5f, alpha(0.95f), light = true) maxLines = 1 diff --git a/app/src/main/java/app/launch0/ui/HomeFragment.kt b/app/src/main/java/app/launch0/ui/HomeFragment.kt index d0789a7ad..537aa2a5f 100644 --- a/app/src/main/java/app/launch0/ui/HomeFragment.kt +++ b/app/src/main/java/app/launch0/ui/HomeFragment.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.content.pm.LauncherApps import android.content.res.Configuration +import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.provider.CalendarContract import android.os.BatteryManager @@ -55,6 +56,7 @@ import app.launch0.helper.getColorFromAttr import app.launch0.helper.getNotificationCountDrawable import app.launch0.helper.getScreenTimeCapsuleDrawable import app.launch0.helper.getShapedAppIcon +import app.launch0.helper.toShapedIcon import app.launch0.helper.getUserHandleFromString import app.launch0.helper.isEinkDisplay import app.launch0.helper.isPackageInstalled @@ -551,46 +553,68 @@ class HomeFragment : Fragment(), View.OnClickListener, View.OnLongClickListener /** * Turns the per-package held counts into display rows for the "on hold" card, ordered most-held - * first (then A→Z by label) so the busiest app leads. Labels reuse the app's own name. + * first (then A→Z by label) so the busiest app leads. Each row carries the app's own name and, + * when the launcher's "show app icons" setting is on, its shaped launcher icon. */ - private fun buildHeldApps(counts: Map): List = - counts.entries + private fun buildHeldApps(counts: Map): List { + val showIcons = prefs.showAppIcons + val sizePx = prefs.iconSize.dpToPx() + return counts.entries .map { (packageName, count) -> - HeldNotificationsWidgetView.HeldApp(packageName, appLabelForPackage(packageName), count) + val (label, icon) = resolveLabelAndIcon(packageName, showIcons, sizePx) + HeldNotificationsWidgetView.HeldApp(packageName, label, count, icon) } .sortedWith( compareByDescending { it.count } .thenBy { it.label.lowercase() } ) + } /** - * Best display name for [packageName]: the user's rename if set, else the app's launcher label, - * else the raw package. Held counts are keyed by bare package (the notification key's user id is - * stripped), so the label is resolved across every profile via [LauncherApps] — otherwise a - * work-profile-only app, absent for the personal user, would fall back to its raw package name. + * Resolves [packageName] to its display label and (when [showIcons]) a shaped launcher icon in a + * single profile-aware pass. Held counts are keyed by bare package (the notification key's user + * id is stripped), so the app is looked up across every profile via [LauncherApps] — otherwise a + * work-profile-only app, absent for the personal user, would show its raw package name and no + * icon. Falls back to the user's rename, then the personal-user PackageManager, then the package. */ - private fun appLabelForPackage(packageName: String): String { + private fun resolveLabelAndIcon( + packageName: String, + showIcons: Boolean, + sizePx: Int, + ): Pair { val rename = prefs.getAppRenameLabel(packageName) - if (rename.isNotBlank()) return rename val ctx = requireContext() try { val launcherApps = ctx.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps val userManager = ctx.getSystemService(Context.USER_SERVICE) as UserManager for (profile in userManager.userProfiles) { - val label = launcherApps.getActivityList(packageName, profile) - .firstOrNull()?.label?.toString() - if (!label.isNullOrBlank()) return label + val info = launcherApps.getActivityList(packageName, profile).firstOrNull() ?: continue + val label = rename.ifBlank { info.label?.toString().orEmpty() }.ifBlank { packageName } + val icon = if (showIcons) + info.getBadgedIcon(0)?.toShapedIcon(ctx.resources, sizePx, prefs.iconShape) else null + return label to icon } } catch (e: Exception) { e.printStackTrace() } - // Last resorts: the personal-user PackageManager label, then the raw package name. - return try { - val pm = ctx.packageManager - pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString() - } catch (e: Exception) { - packageName + // Last resorts: rename or the personal-user PackageManager label, then the raw package. + val label = rename.ifBlank { + try { + val pm = ctx.packageManager + pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString() + } catch (e: Exception) { + packageName + } } + val icon = if (showIcons) { + try { + ctx.packageManager.getApplicationIcon(packageName) + .toShapedIcon(ctx.resources, sizePx, prefs.iconShape) + } catch (e: Exception) { + null + } + } else null + return label to icon } /** Re-reads events (used by the minute ticker); [autoScroll] false keeps the current scroll. */ From 734bdeeeb7904ea20e101dd2e035e3775fd7302c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 11:21:34 +0000 Subject: [PATCH 4/4] Play the notification sound when releasing held notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Releasing parked notifications now plays the system default notification sound as an audible cue. Added a Context.playNotificationSound() helper (honours the notification stream volume, so it's silent when the phone is muted; never throws) and call it from every release site — the "on hold" widget row and home-app pill (HomeFragment.releaseNotificationsForApp) and the app-drawer row (AppDrawerFragment.onReleaseNotifications). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012ykbDeHqrtpz4VQH9XiFzV --- app/src/main/java/app/launch0/helper/Utils.kt | 17 +++++++++++++++++ .../java/app/launch0/ui/AppDrawerFragment.kt | 2 ++ .../main/java/app/launch0/ui/HomeFragment.kt | 2 ++ 3 files changed, 21 insertions(+) diff --git a/app/src/main/java/app/launch0/helper/Utils.kt b/app/src/main/java/app/launch0/helper/Utils.kt index 60167c7ad..8c91e07c4 100644 --- a/app/src/main/java/app/launch0/helper/Utils.kt +++ b/app/src/main/java/app/launch0/helper/Utils.kt @@ -14,6 +14,7 @@ import android.content.res.Configuration import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.graphics.Point import android.graphics.drawable.Drawable +import android.media.RingtoneManager import android.net.Uri import android.os.Build import android.os.UserHandle @@ -466,6 +467,22 @@ fun Context.openUrl(url: String) { startActivity(intent) } +/** + * Plays the system's default notification sound once — an audible cue when the user releases held + * notifications. Honours the notification stream volume (inaudible when the phone is silenced) and + * never throws; a missing ringtone is simply skipped. + */ +fun Context.playNotificationSound() { + try { + val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + ?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + ?: return + RingtoneManager.getRingtone(this, uri)?.play() + } catch (e: Exception) { + e.printStackTrace() + } +} + fun Context.isSystemApp(packageName: String): Boolean { if (packageName.isBlank()) return true return try { diff --git a/app/src/main/java/app/launch0/ui/AppDrawerFragment.kt b/app/src/main/java/app/launch0/ui/AppDrawerFragment.kt index e4b2970de..e40557732 100644 --- a/app/src/main/java/app/launch0/ui/AppDrawerFragment.kt +++ b/app/src/main/java/app/launch0/ui/AppDrawerFragment.kt @@ -38,6 +38,7 @@ import app.launch0.helper.isSystemApp import app.launch0.helper.openAppInfo import app.launch0.helper.openSearch import app.launch0.helper.openUrl +import app.launch0.helper.playNotificationSound import app.launch0.helper.showKeyboard import app.launch0.helper.showToast import app.launch0.helper.uninstall @@ -250,6 +251,7 @@ class AppDrawerFragment : Fragment() { }, onReleaseNotifications = { appModel -> NotificationDndService.releaseForPackage(prefs, appModel.appPackage) + requireContext().playNotificationSound() val position = adapter.appFilteredList.indexOf(appModel) if (position >= 0) adapter.notifyItemChanged(position) requireContext().showToast(getString(R.string.dnd_released)) diff --git a/app/src/main/java/app/launch0/ui/HomeFragment.kt b/app/src/main/java/app/launch0/ui/HomeFragment.kt index 537aa2a5f..7dab785c6 100644 --- a/app/src/main/java/app/launch0/ui/HomeFragment.kt +++ b/app/src/main/java/app/launch0/ui/HomeFragment.kt @@ -62,6 +62,7 @@ import app.launch0.helper.isEinkDisplay import app.launch0.helper.isPackageInstalled import app.launch0.helper.openAlarmApp import app.launch0.helper.pillTouchListener +import app.launch0.helper.playNotificationSound import app.launch0.helper.openCalendar import app.launch0.helper.openCameraApp import app.launch0.helper.openDialerApp @@ -911,6 +912,7 @@ class HomeFragment : Fragment(), View.OnClickListener, View.OnLongClickListener if (packageName.isBlank()) return if (NotificationDndService.parkedCount(prefs, packageName) == 0) return NotificationDndService.releaseForPackage(prefs, packageName) + requireContext().playNotificationSound() // Refresh the held card and home-app pills, but leave the calendar as-is: releasing is a // notification-set change, so it shouldn't reload the calendar and snap its scroll to top. populateHomeScreen(false, reloadCalendar = false)