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/HeldNotificationsWidgetView.kt b/app/src/main/java/app/launch0/ui/HeldNotificationsWidgetView.kt new file mode 100644 index 000000000..fc2cbeb86 --- /dev/null +++ b/app/src/main/java/app/launch0/ui/HeldNotificationsWidgetView.kt @@ -0,0 +1,186 @@ +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 +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, 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) + 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) } + } + + // 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 + 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..7dab785c6 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 @@ -13,6 +14,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 @@ -54,11 +56,13 @@ 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 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 @@ -91,8 +95,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 +186,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 +244,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 +489,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 +535,89 @@ 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. 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 { + val showIcons = prefs.showAppIcons + val sizePx = prefs.iconSize.dpToPx() + return counts.entries + .map { (packageName, count) -> + val (label, icon) = resolveLabelAndIcon(packageName, showIcons, sizePx) + HeldNotificationsWidgetView.HeldApp(packageName, label, count, icon) + } + .sortedWith( + compareByDescending { it.count } + .thenBy { it.label.lowercase() } + ) + } + + /** + * 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 resolveLabelAndIcon( + packageName: String, + showIcons: Boolean, + sizePx: Int, + ): Pair { + val rename = prefs.getAppRenameLabel(packageName) + 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 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: 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. */ private fun refreshCalendarWidget(autoScroll: Boolean) { if (_binding == null) return @@ -618,10 +723,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() @@ -795,12 +900,22 @@ 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) - populateHomeScreen(false) + 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) requireContext().showToast(getString(R.string.dnd_released)) } @@ -1149,13 +1264,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