feat: notifications say what changed and alert once: a grouped notice names the tracks, grows without re-alerting, and a phone pile lists its titles (M542 #6146-#6151)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m33s
release / android (push) Failing after 1m42s
release / integration (push) Failing after 4m42s
release / Build signed APK (releases and dev) (push) Successful in 4m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

- An update to a grouped notice keeps its arrival time and email state, so the
  phone and the email no longer go off again each time it grows.
- Notices carry the names of what changed (newest ten), shown as three and
  "and N more": tracks still missing after the scan's move/replacement fold,
  duplicate groups new to a sweep, songs merged automatically, and the track
  and listener a playback error was for.
- Duplicates found counts only the groups new to that sweep; playback errors
  count reports, not the unresolved total.
- A scan failure says what stopped and links to Admin > Library.
- Android: a pile of more than three alerts lists the newest five titles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:44:19 -04:00
co-authored by Claude Opus 5.5
parent 2365514e5b
commit 854c307834
26 changed files with 486 additions and 190 deletions
@@ -30,8 +30,9 @@ internal data class CatchUp(
* - The mark moves to the newest notice in the page, announced or not, so a
* kind with the phone off is never announced later.
*
* A coalesced notice (tracks missing, now 5) moves its time forward when its
* count grows, so it is announced again with the new count.
* A coalesced notice (tracks missing, now 5) keeps the time it arrived when
* its count grows (M542), so it is announced once: the inbox shows it grown,
* the phone does not buzz for it again.
*/
internal fun planCatchUp(
page: List<CachedNotificationEntity>,
@@ -51,18 +52,32 @@ internal fun planCatchUp(
internal sealed interface Announcement {
data class Each(val items: List<CachedNotificationEntity>) : Announcement
/** Coming back from a day offline is one line, not forty buzzes. */
data class Pile(val count: Int, val channel: ShadeChannel) : Announcement
/**
* Coming back from a day offline is one line, not forty buzzes. [lines]
* are the newest notices' titles, so the pile says what it holds (M542).
*/
data class Pile(
val count: Int,
val channel: ShadeChannel,
val lines: List<String> = emptyList(),
) : Announcement
}
internal fun announcementFor(fresh: List<CachedNotificationEntity>): Announcement? = when {
fresh.isEmpty() -> null
fresh.size <= INDIVIDUAL_LIMIT -> Announcement.Each(fresh)
/** How many titles an expanded pile lists, newest first. */
internal const val PILE_LINES = 5
internal fun announcementFor(fresh: List<CachedNotificationEntity>): Announcement? {
if (fresh.isEmpty()) return null
if (fresh.size <= INDIVIDUAL_LIMIT) return Announcement.Each(fresh)
// A pile goes where its notices would: library health only when every
// one of them is, so a listener's own news is never filed under it.
fresh.all { shadeChannelFor(it.kind) == ShadeChannel.LIBRARY_HEALTH } ->
Announcement.Pile(fresh.size, ShadeChannel.LIBRARY_HEALTH)
else -> Announcement.Pile(fresh.size, ShadeChannel.YOUR_REQUESTS)
val channel = if (fresh.all { shadeChannelFor(it.kind) == ShadeChannel.LIBRARY_HEALTH }) {
ShadeChannel.LIBRARY_HEALTH
} else {
ShadeChannel.YOUR_REQUESTS
}
val lines = fresh.sortedByDescending { it.createdAt }.take(PILE_LINES).map { it.title }
return Announcement.Pile(fresh.size, channel, lines)
}
/** The phone channel per kind, as the user set it. No settings cached: on, the default for every kind. */
@@ -92,3 +107,7 @@ internal fun deliveryWanted(signedIn: Boolean, enabled: Boolean): Boolean = sign
/** One line for a pile. */
internal fun pileText(count: Int): String = "$count new notifications"
/** Under an expanded pile's titles: how many it holds beyond them, or null. */
internal fun pileMore(p: Announcement.Pile): String? =
(p.count - p.lines.size).takeIf { it > 0 }?.let { "and $it more" }
@@ -1,6 +1,7 @@
package com.fabledsword.minstrel.notifications.delivery
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
@@ -50,14 +51,20 @@ class NotificationPoster @Inject constructor(
.setContentIntent(openIntent(item.link, item.id.hashCode()))
.build()
private fun pile(p: Announcement.Pile) =
NotificationCompat.Builder(context, p.channel.id)
/** Collapsed, the count; expanded, the newest titles and how many more. */
private fun pile(p: Announcement.Pile): Notification {
val style = NotificationCompat.InboxStyle().setBigContentTitle(pileText(p.count))
p.lines.forEach { style.addLine(it) }
pileMore(p)?.let { style.setSummaryText(it) }
return NotificationCompat.Builder(context, p.channel.id)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Minstrel")
.setContentText(pileText(p.count))
.setContentTitle(pileText(p.count))
.setContentText(p.lines.joinToString(" · "))
.setStyle(style)
.setAutoCancel(true)
.setContentIntent(openIntent(link = null, requestCode = PILE_ID))
.build()
}
/** Each notice gets its own request code, so their links stay apart. */
private fun openIntent(link: String?, requestCode: Int): PendingIntent =
@@ -99,6 +99,16 @@ class DeliveryPlanTest {
assertEquals(5, pile.count)
assertEquals(ShadeChannel.YOUR_REQUESTS, pile.channel)
assertEquals("5 new notifications", pileText(pile.count))
assertEquals(listOf("t-n5", "t-n4", "t-n3", "t-n2", "t-n1"), pile.lines)
assertNull(pileMore(pile))
}
@Test
fun `a pile names its newest notices and counts the rest`() {
val many = (1..17).map { notice("n$it", it.toLong()) }
val pile = assertIs<Announcement.Pile>(announcementFor(many))
assertEquals(listOf("t-n17", "t-n16", "t-n15", "t-n14", "t-n13"), pile.lines)
assertEquals("and 12 more", pileMore(pile))
}
@Test