From e38cd37c94024ae664f228ab51b10c875a9c018d Mon Sep 17 00:00:00 2001 From: rayvader <42436531+rayvader@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:17:11 +0000 Subject: [PATCH 1/3] feat: notification channels per Bot and per kind Feed notes now carry channel and member. A Bot's note goes to its own channel bot_, named after the Bot (renamed in place, never deleted), in a Bots group. system.agent goes to News and agents, system.monitor to Watches, chat.* to Chat updates, the rest to Dashboard alerts (the old channel id), all in a Kiro Crew group. Importance stays HIGH. Each channel has its own stack and summary. The Settings row is now Notification types. --- .github/e2e/gateway.py | 5 + .github/e2e/run.sh | 64 ++++++++++ README.md | 13 +- .../io/github/rayvader/kirocrew/Notifier.kt | 68 ++++++++-- .../rayvader/kirocrew/core/AlertChannels.kt | 79 ++++++++++++ .../github/rayvader/kirocrew/core/Alerts.kt | 16 +++ app/src/main/res/values/strings.xml | 10 +- .../kirocrew/core/AlertChannelsTest.kt | 117 ++++++++++++++++++ 8 files changed, 358 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/io/github/rayvader/kirocrew/core/AlertChannels.kt create mode 100644 app/src/test/java/io/github/rayvader/kirocrew/core/AlertChannelsTest.kt diff --git a/.github/e2e/gateway.py b/.github/e2e/gateway.py index 565efb3..2db9395 100644 --- a/.github/e2e/gateway.py +++ b/.github/e2e/gateway.py @@ -303,6 +303,11 @@ def do_GET(self): "silenced": q.get("silenced") == "1", "acked": False, } + # Like the real feed: which kind of note, and the Bot that posted it. + if q.get("channel"): + note["channel"] = q["channel"] + if q.get("member_slug"): + note["member"] = {"slug": q["member_slug"], "name": q.get("member_name", "")} state.push(note) return self.send(200, json.dumps(note), "application/json") if p == "/test/arm-toast": diff --git a/.github/e2e/run.sh b/.github/e2e/run.sh index b7129aa..38c53fd 100755 --- a/.github/e2e/run.sh +++ b/.github/e2e/run.sh @@ -439,6 +439,70 @@ f_instant_alerts() { ! grep -qF "E2E muted note" "$OUT/notif.txt" || fail "muted note was shown" ! grep -qF "E2E passive note" "$OUT/notif.txt" || fail "passive note was shown" check "muted and passive notes are skipped, like the bell badge" + + # Notification types: a Bot's note gets its own channel in the Bots group, an agent note + # goes to "News and agents" in the Kiro Crew group. Made-up Bot name. + push_note --data-urlencode "title=E2E bot note" --data-urlencode "channel=system.member" \ + --data-urlencode "member_slug=e2e-demo-bot" --data-urlencode "member_name=E2E Demo Bot" + push_note --data-urlencode "title=E2E agent note" --data-urlencode "channel=system.agent" + wait_notif "E2E bot note" 20 + wait_notif "E2E agent note" 20 + channels_dump + has_channel bot_e2e-demo-bot bots || fail "no 'bot_e2e-demo-bot' channel in the Bots group" + grep -qF "E2E Demo Bot" "$OUT/channels.txt" || fail "the Bot's channel is not named 'E2E Demo Bot'" + check "a Bot's note makes its own channel 'E2E Demo Bot' in the Bots group" + has_channel news kirocrew || fail "no 'news' channel in the Kiro Crew group" + has_channel alerts kirocrew || fail "the old 'alerts' channel is gone or not in the Kiro Crew group" + check "an agent note goes to 'News and agents'; 'Dashboard alerts' is kept, in the Kiro Crew group" + python3 - "$OUT/notif.txt" <<'PY' || fail "the notes are not posted on their channels" +import re, sys +text = open(sys.argv[1], encoding="utf-8", errors="replace").read() +def channel_of(title): + for block in re.split(r"\n\s*NotificationRecord\(", text): + if title in block: + m = re.search(r"channel=([\w.-]+)", block) + return m.group(1) if m else None +bot, agent = channel_of("E2E bot note"), channel_of("E2E agent note") +print("bot note on", bot, "| agent note on", agent) +sys.exit(0 if bot == "bot_e2e-demo-bot" and agent == "news" else 1) +PY + check "each note is posted on its own channel" + # Push the same Bot again under a new name: the channel is renamed in place. + push_note --data-urlencode "title=E2E bot note two" --data-urlencode "channel=system.member" \ + --data-urlencode "member_slug=e2e-demo-bot" --data-urlencode "member_name=E2E Demo Bot Renamed" + wait_notif "E2E bot note two" 20 + channels_dump + grep -qF "E2E Demo Bot Renamed" "$OUT/channels.txt" || fail "the Bot's channel was not renamed" + has_channel bot_e2e-demo-bot bots || fail "the renamed Bot's channel left the Bots group" + check "a renamed Bot keeps its channel, with the new name" + # The settings page Raymond uses to set sound and mute per channel. + adb shell am start -a android.settings.APP_NOTIFICATION_SETTINGS \ + --es android.provider.extra.APP_PACKAGE "$PKG" >/dev/null + local i + for i in $(seq 1 15); do has_text "E2E Demo Bot Renamed" && break; sleep 1; done + sleep 2 + adb exec-out screencap -p > "$OUT/shots/notif-channels.png" 2>/dev/null || true + has_text "Bots" || fail "the app's notification settings do not show the Bots group" + check "Android's notification settings list the Bots group" + back +} + +# The app's notification channels and their groups, as Android reports them. +channels_dump() { + { adb shell dumpsys notification --noredact; adb shell cmd notification list_channels "$PKG" 0 2>/dev/null; } > "$OUT/channels.txt" + cp "$OUT/channels.txt" "$OUT/logs/$FEATURE_ID-channels.txt" +} +# has_channel ID GROUP: the channel ID exists in GROUP. +has_channel() { + python3 - "$OUT/channels.txt" "$1" "$2" <<'PY' +import re, sys +text, cid, group = open(sys.argv[1], encoding="utf-8", errors="replace").read(), sys.argv[2], sys.argv[3] +for m in re.finditer(r"NotificationChannel\{[^}]*\}", text): + c = m.group(0) + if "mId='%s'" % cid in c and "mGroup='%s'" % group in c: + sys.exit(0) +sys.exit(1) +PY } f_periodic_alerts() { diff --git a/README.md b/README.md index dbf5cfa..9d64678 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,14 @@ Pick how bell notes arrive in **Settings → Notifications → Background alerts | Every 15 minutes (default), 30 minutes or 1 hour | Up to the interval. Android can wait longer when the phone sleeps. | Very low | | Instant | A few seconds | Higher. Keeps one connection open and shows a small "Instant alerts are on" notice. | -**Sound and vibration** opens Android's settings for the app. Each kind of alert has its own channel there: Dashboard alerts, Chat updates, App updates and sign-in, Instant alerts connection. +**Notification types** opens Android's settings for the app, where you set sound, vibration and mute per channel. Channels come in two groups: + +| Group | Channels | +|---|---| +| Bots | one per Bot (named after it, made the first time it posts) | +| Kiro Crew | News and agents (agent messages, news, watch cards), Watches, Chat updates, Dashboard alerts (everything else), App updates and sign-in, Instant alerts connection | + +A Bot's note goes to its own channel even when it is an agent message. A Bot that is renamed keeps its channel and settings. Channels are never deleted, so your settings stay. How it works: the app reads `GET /api/notifications` on your gateway with the app's own login cookie. Instant mode also listens to the dashboard's live `/api/stream`. When the login runs out, the app renews it through `/api/auth/refresh`, the same way the dashboard page does. Nothing goes through Google or any other server. @@ -129,7 +136,7 @@ Open **Settings** from the app menu. | Area | Options | |---|---| | Server | Several servers, start page (or a custom path), fast page switching | -| Notifications | On or off. Background alerts: only while open, every 15/30/60 minutes, or instant. Sound and vibration per kind of alert. | +| Notifications | On or off. Background alerts: only while open, every 15/30/60 minutes, or instant. Notification types: sound, vibration and mute per Bot and per kind of alert. | | Look | Light, dark or system theme. 6 accent colors. Force-dark pages. Text size 50–200 %, or **Auto text size**: slightly bigger text on a folded phone (110 %), normal size on an unfolded phone or a tablet (100 %), and the slider adjusts it. Turning the phone does not change it. Top bar and bottom bar on or off. Pick up to 5 bottom-bar pages. Full screen. | | Behavior | Pull to refresh, keep screen on, desktop mode, open other sites in browser, double back to exit, custom CSS, user-agent suffix | | Security | App lock (fingerprint, face or screen lock) with timeout. Privacy screen (no screenshots, hidden in recents). | @@ -151,7 +158,7 @@ Open **Settings** from the app menu. | Microphone does not work | Use an `https` address. Browsers block the mic on plain `http`. | | QR scan fails | Make a fresh code in **Connect your phone**. | | Update will not install | The old app was signed with a different key. Uninstall it, then install the new one. | -| No notifications | Settings → Notifications. If it says Android is blocking them, tap **Sound and vibration** and allow them. | +| No notifications | Settings → Notifications. If it says Android is blocking them, tap **Notification types** and allow them. | | Alerts come late | Pick **Instant**. On Samsung, also set the app's battery use to **Unrestricted** (Android settings → Apps → Kiro Crew → Battery). | | "Sign in again for alerts" | Open the app once. If the login page shows, sign in or scan a new QR code. | | Update says "checksum does not match" | The download was damaged. Try again on a better connection. | diff --git a/app/src/main/java/io/github/rayvader/kirocrew/Notifier.kt b/app/src/main/java/io/github/rayvader/kirocrew/Notifier.kt index 72c569e..2908c3c 100644 --- a/app/src/main/java/io/github/rayvader/kirocrew/Notifier.kt +++ b/app/src/main/java/io/github/rayvader/kirocrew/Notifier.kt @@ -3,6 +3,7 @@ package io.github.rayvader.kirocrew import android.Manifest import android.app.Notification import android.app.NotificationChannel +import android.app.NotificationChannelGroup import android.app.NotificationManager import android.app.PendingIntent import android.content.Context @@ -14,6 +15,8 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import io.github.rayvader.kirocrew.core.Alert +import io.github.rayvader.kirocrew.core.AlertChannels +import io.github.rayvader.kirocrew.core.AlertRoute import io.github.rayvader.kirocrew.core.GatewayUpdateParser import io.github.rayvader.kirocrew.core.LiveTags import io.github.rayvader.kirocrew.core.Profile @@ -39,14 +42,35 @@ object Notifier { private const val MAX_TITLE = 200 private const val MAX_BODY = 1000 + const val CH_NEWS = AlertChannels.NEWS + const val CH_WATCHES = AlertChannels.WATCHES + const val GROUP_BOTS = "bots" + const val GROUP_KIRO_CREW = "kirocrew" + + /** + * The fixed channels, in two groups: "Bots" (one channel per Bot, made when it first posts) + * and "Kiro Crew" (the rest). Channels are never deleted: Android would forget the sound and + * mute settings set on them. Creating an existing channel again only updates its name, + * description and (when it has none yet) its group; its importance and sound stay as set. + */ fun createChannels(ctx: Context) { val nm = ctx.getSystemService(NotificationManager::class.java) ?: return + nm.createNotificationChannelGroups( + listOf( + NotificationChannelGroup(GROUP_BOTS, ctx.getString(R.string.channel_group_bots)), + NotificationChannelGroup(GROUP_KIRO_CREW, ctx.getString(R.string.app_name)), + ) + ) nm.createNotificationChannels( listOf( NotificationChannel(CH_ALERTS, ctx.getString(R.string.channel_alerts), NotificationManager.IMPORTANCE_HIGH) .apply { description = ctx.getString(R.string.channel_alerts_desc) }, NotificationChannel(CH_CHAT, ctx.getString(R.string.channel_chat), NotificationManager.IMPORTANCE_HIGH) .apply { description = ctx.getString(R.string.channel_chat_desc) }, + NotificationChannel(CH_NEWS, ctx.getString(R.string.channel_news), NotificationManager.IMPORTANCE_HIGH) + .apply { description = ctx.getString(R.string.channel_news_desc) }, + NotificationChannel(CH_WATCHES, ctx.getString(R.string.channel_watches), NotificationManager.IMPORTANCE_HIGH) + .apply { description = ctx.getString(R.string.channel_watches_desc) }, NotificationChannel(CH_APP, ctx.getString(R.string.channel_app), NotificationManager.IMPORTANCE_DEFAULT) .apply { description = ctx.getString(R.string.channel_app_desc) }, NotificationChannel(CH_CONNECTION, ctx.getString(R.string.channel_connection), NotificationManager.IMPORTANCE_MIN) @@ -54,10 +78,30 @@ object Notifier { description = ctx.getString(R.string.channel_connection_desc) setShowBadge(false) }, - ) + ).onEach { it.group = GROUP_KIRO_CREW } ) } + /** + * The channel for [route]. A Bot's channel is made the first time it posts, and renamed in + * place when its name changes (same id, so Android keeps its settings). + */ + private fun channelFor(ctx: Context, route: AlertRoute): String { + val name = route.botName ?: return route.id + val nm = ctx.getSystemService(NotificationManager::class.java) ?: return route.id + val existing = nm.getNotificationChannel(route.id) + if (existing == null || existing.name?.toString() != name || existing.group == null) { + nm.createNotificationChannelGroup(NotificationChannelGroup(GROUP_BOTS, ctx.getString(R.string.channel_group_bots))) + nm.createNotificationChannel( + NotificationChannel(route.id, name, NotificationManager.IMPORTANCE_HIGH).apply { + description = ctx.getString(R.string.channel_bot_desc, name) + group = GROUP_BOTS + } + ) + } + return route.id + } + /** Android lets the app post notifications (runtime permission on 13+, and not switched off). */ fun canPost(ctx: Context): Boolean { if (Build.VERSION.SDK_INT >= 33 && @@ -69,20 +113,25 @@ object Notifier { /** A note from the dashboard's notification feed. */ fun postAlert(ctx: Context, profile: Profile, showServer: Boolean, alert: Alert) { val key = "alert:${profile.id}:${alert.ts}" - val n = base(ctx, CH_ALERTS) + val channel = channelFor(ctx, alert.route) + val n = base(ctx, channel) .setContentTitle(alert.title.take(MAX_TITLE)) .setContentText(alert.body.take(MAX_BODY).ifBlank { null }) .setStyle(NotificationCompat.BigTextStyle().bigText(alert.body.take(MAX_BODY))) .setSubText(if (showServer) profile.name else null) .setWhen(alert.epochMillis ?: System.currentTimeMillis()) .setShowWhen(true) - .setGroup(GROUP_ALERTS) + .setGroup(groupKey(channel)) .setContentIntent(open(ctx, key, profile.id, alert.path ?: Shortcut.NOTIFICATIONS.path)) .build() post(ctx, key, ID_ALERT, n) - postSummary(ctx, profile) + postSummary(ctx, profile, channel) } + /** One stack per channel: Android needs a stack's summary on the same channel as its notes. */ + private fun groupKey(channel: String) = + if (channel == CH_ALERTS) GROUP_ALERTS else "$GROUP_ALERTS.$channel" + /** "3 more alerts" when one check finds more than it shows. */ fun postMore(ctx: Context, profile: Profile, count: Int) { val key = "more:${profile.id}" @@ -96,15 +145,16 @@ object Notifier { post(ctx, key, ID_ALERT, n) } - private fun postSummary(ctx: Context, profile: Profile) { - val n = base(ctx, CH_ALERTS) + private fun postSummary(ctx: Context, profile: Profile, channel: String) { + val tag = if (channel == CH_ALERTS) "summary" else "summary:$channel" + val n = base(ctx, channel) .setContentTitle(ctx.getString(R.string.app_name)) - .setGroup(GROUP_ALERTS) + .setGroup(groupKey(channel)) .setGroupSummary(true) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) - .setContentIntent(open(ctx, "summary", profile.id, Shortcut.NOTIFICATIONS.path)) + .setContentIntent(open(ctx, tag, profile.id, Shortcut.NOTIFICATIONS.path)) .build() - post(ctx, "summary", ID_SUMMARY, n) + post(ctx, tag, ID_SUMMARY, n) } /** A toast raised by the dashboard page itself. The same tag replaces the last one, like a browser. */ diff --git a/app/src/main/java/io/github/rayvader/kirocrew/core/AlertChannels.kt b/app/src/main/java/io/github/rayvader/kirocrew/core/AlertChannels.kt new file mode 100644 index 0000000..459a70f --- /dev/null +++ b/app/src/main/java/io/github/rayvader/kirocrew/core/AlertChannels.kt @@ -0,0 +1,79 @@ +package io.github.rayvader.kirocrew.core + +import java.util.Locale + +/** The Bot a feed note came from: the feed's `member` object. */ +data class AlertMember(val slug: String, val name: String) + +/** Which Android notification channel group a channel sits in. */ +enum class ChannelGroup { BOTS, KIRO_CREW } + +/** + * Where one feed note goes. [id] is the Android channel id. [botName] is set only for a Bot's + * own channel (`bot_`), whose name comes from the feed; the fixed channels take their + * names from the app's strings. + */ +data class AlertRoute(val id: String, val group: ChannelGroup, val botName: String? = null) + +/** + * Picks the notification channel for each feed note, so sound and mute can be set per Bot and + * per kind in Android settings. Plain Kotlin. + * + * member present -> bot_, named after the Bot (Bots group) + * system.agent -> news: News and agents (Kiro Crew group) + * system.monitor -> watches: Watches + * chat.* / session.* -> chat: Chat updates + * anything else, or none -> alerts: Dashboard alerts (the old channel) + */ +object AlertChannels { + /** The old channel every note used. Kept as the fallback so its settings are kept too. */ + const val ALERTS = "alerts" + const val CHAT = "chat" + const val NEWS = "news" + const val WATCHES = "watches" + const val BOT_PREFIX = "bot_" + + private const val MAX_SLUG = 60 + private const val MAX_NAME = 40 + + fun route(channel: String?, member: AlertMember?): AlertRoute { + val slug = member?.slug?.let(::cleanSlug) + if (member != null && !slug.isNullOrEmpty()) { + return AlertRoute(BOT_PREFIX + slug, ChannelGroup.BOTS, displayName(member.name, member.slug)) + } + val c = channel?.trim()?.lowercase(Locale.ROOT).orEmpty() + val id = when { + c == "system.agent" -> NEWS + c == "system.monitor" -> WATCHES + c.startsWith("chat.") || c.startsWith("session.") -> CHAT + else -> ALERTS + } + return AlertRoute(id, ChannelGroup.KIRO_CREW) + } + + /** A slug made safe for a channel id: lower case, letters, digits, `-` and `_` only. */ + fun cleanSlug(slug: String): String = + slug.trim().lowercase(Locale.ROOT) + .replace(Regex("[^a-z0-9_-]+"), "_") + .trim('_', '-') + .take(MAX_SLUG) + + /** + * The channel's name. The feed's `member.name` as given ("Froyo", "Travel Guru"). The feed + * often carries the slug there too ("travel-guru"): a name with no spaces and no capitals + * that is made of `-` or `_` words is turned into words ("Travel Guru"). Empty: from the slug. + */ + fun displayName(name: String?, slug: String): String { + val n = name?.trim()?.replace(Regex("\\s+"), " ").orEmpty() + val source = n.ifEmpty { slug.trim() } + val looksLikeSlug = source.isNotEmpty() && source == source.lowercase(Locale.ROOT) && + !source.contains(' ') && Regex("[a-z0-9]+([-_][a-z0-9]+)*").matches(source) + val out = if (looksLikeSlug) { + source.split('-', '_').filter { it.isNotEmpty() } + .joinToString(" ") { w -> w.replaceFirstChar { it.titlecase(Locale.ROOT) } } + } else { + source + } + return out.take(MAX_NAME).ifEmpty { "Bot" } + } +} diff --git a/app/src/main/java/io/github/rayvader/kirocrew/core/Alerts.kt b/app/src/main/java/io/github/rayvader/kirocrew/core/Alerts.kt index 31feb44..6935a08 100644 --- a/app/src/main/java/io/github/rayvader/kirocrew/core/Alerts.kt +++ b/app/src/main/java/io/github/rayvader/kirocrew/core/Alerts.kt @@ -15,7 +15,14 @@ data class Alert( val priority: String, val silenced: Boolean, val acked: Boolean, + /** The feed's `channel` ("system.member", "system.agent", ...). Null in older feeds. */ + val channel: String? = null, + /** The Bot that posted it. Null in older feeds and for notes no Bot posted. */ + val member: AlertMember? = null, ) { + /** The Android notification channel this note goes to. */ + val route: AlertRoute get() = AlertChannels.route(channel, member) + /** Worth a phone notification: unread, not muted and not passive (the same rule as the bell badge). */ val loud: Boolean get() = !acked && !silenced && priority != "passive" @@ -61,9 +68,18 @@ object AlertFeed { priority = o.text("priority") ?: "normal", silenced = o.optBoolean("silenced", false), acked = o.optBoolean("acked", false), + channel = o.text("channel")?.takeIf { it.isNotBlank() }, + member = parseMember(o), ) } + /** `member: {slug, name}`. Needs a slug; a missing name falls back to the slug later. */ + private fun parseMember(o: JSONObject): AlertMember? { + val m = o.optJSONObject("member") ?: return null + val slug = m.text("slug")?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return AlertMember(slug, m.text("name")?.trim().orEmpty()) + } + /** What to post now, how many new notes were left out, and the marker to store. */ data class Sync(val toPost: List, val skipped: Int, val marker: String) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2ef7adf..5efada8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,13 +116,19 @@ The app saves the server and signs you in.

Android is blocking them. Tap Sound and vibration to allow. Notifications are off. Turn them on in Sound and vibration. Background alerts - Sound and vibration - Choose sounds, vibration and pop-ups for each kind of alert + Notification types + Sound, vibration and mute for each Bot and each kind of alert Dashboard alerts Notes from the dashboard bell, like approvals and finished jobs Chat updates A chat finished or is waiting for you + News and agents + Messages from agents, news and watch cards + Watches + Updates from things the dashboard is watching for you + Bots + Notes from %1$s App updates and sign-in New app and Kiro Crew versions, and when a server needs you to sign in again Check for Kiro Crew updates diff --git a/app/src/test/java/io/github/rayvader/kirocrew/core/AlertChannelsTest.kt b/app/src/test/java/io/github/rayvader/kirocrew/core/AlertChannelsTest.kt new file mode 100644 index 0000000..63f7b0e --- /dev/null +++ b/app/src/test/java/io/github/rayvader/kirocrew/core/AlertChannelsTest.kt @@ -0,0 +1,117 @@ +package io.github.rayvader.kirocrew.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AlertChannelsTest { + + private fun note(extra: String) = + AlertFeed.parseNoteJson("""{"ts":"2026-11-07T10:00:00+00:00","title":"Test note"$extra}""")!! + + @Test + fun aBotsNoteGoesToItsOwnChannel() { + val r = AlertChannels.route("system.member", AlertMember("test-butler", "Test Butler")) + assertEquals(AlertRoute("bot_test-butler", ChannelGroup.BOTS, "Test Butler"), r) + } + + @Test + fun theMemberWinsOverTheChannel() { + // A Bot posting an agent message still lands in that Bot's own channel. + assertEquals("bot_demo-bot", AlertChannels.route("system.agent", AlertMember("demo-bot", "Demo")).id) + assertEquals("bot_demo-bot", AlertChannels.route(null, AlertMember("demo-bot", "Demo")).id) + } + + @Test + fun kindsWithoutABot() { + assertEquals(AlertRoute("news", ChannelGroup.KIRO_CREW), AlertChannels.route("system.agent", null)) + assertEquals(AlertRoute("watches", ChannelGroup.KIRO_CREW), AlertChannels.route("system.monitor", null)) + assertEquals("chat", AlertChannels.route("chat.completion", null).id) + assertEquals("chat", AlertChannels.route("session.pool_size", null).id) + assertEquals("watches", AlertChannels.route(" System.Monitor ", null).id) + } + + @Test + fun unknownOrMissingFallsBackToDashboardAlerts() { + assertEquals(AlertRoute("alerts", ChannelGroup.KIRO_CREW), AlertChannels.route("system.approval", null)) + assertEquals("alerts", AlertChannels.route("something.new", null).id) + assertEquals("alerts", AlertChannels.route(null, null).id) + assertEquals("alerts", AlertChannels.route("", null).id) + // A member with no usable slug is treated as no member. + assertEquals("alerts", AlertChannels.route(null, AlertMember(" ", "Ghost")).id) + assertEquals("alerts", AlertChannels.route(null, AlertMember("***", "Ghost")).id) + } + + @Test + fun channelNamesComeFromTheBotsName() { + assertEquals("Test Butler", AlertChannels.displayName("Test Butler", "test-butler")) + assertEquals("Demo", AlertChannels.displayName(" Demo ", "demo-bot")) + assertEquals("Mr. Demo", AlertChannels.displayName("Mr. Demo", "mr-demo")) + // The feed often sends the slug as the name: turned into words. + assertEquals("Trip Helper", AlertChannels.displayName("trip-helper", "trip-helper")) + assertEquals("Money Mate Two", AlertChannels.displayName("money_mate-two", "money-mate-two")) + // No name: from the slug. + assertEquals("Demo Bot", AlertChannels.displayName(null, "demo-bot")) + assertEquals("Demo Bot", AlertChannels.displayName("", "demo-bot")) + // A one-word slug-like name gets a capital; a name with capitals is kept as given. + assertEquals("Demo", AlertChannels.displayName("demo", "demo")) + assertEquals("deMo", AlertChannels.displayName("deMo", "demo")) + // Long names are cut for Android's channel list. + assertEquals(40, AlertChannels.displayName("A".repeat(80), "a").length) + } + + @Test + fun aRenamedBotKeepsItsChannelId() { + val before = AlertChannels.route("system.member", AlertMember("demo-bot", "Demo")) + val after = AlertChannels.route("system.member", AlertMember("demo-bot", "Demo Renamed")) + assertEquals(before.id, after.id) + assertEquals("Demo Renamed", after.botName) + } + + @Test + fun slugsAreSafeChannelIds() { + assertEquals("demo-bot", AlertChannels.cleanSlug(" Demo-Bot ")) + assertEquals("a_b_c", AlertChannels.cleanSlug("a b/c")) + assertEquals(60, AlertChannels.cleanSlug("x".repeat(100)).length) + } + + @Test + fun theFeedsChannelAndMemberAreRead() { + val a = note(""","channel":"system.member","member":{"slug":"test-butler","name":"Test Butler"}""") + assertEquals("system.member", a.channel) + assertEquals(AlertMember("test-butler", "Test Butler"), a.member) + assertEquals("bot_test-butler", a.route.id) + assertEquals("Test Butler", a.route.botName) + } + + @Test + fun anOldFeedWithoutTheFieldsStillWorks() { + val a = note(""","body":"hi","priority":"normal"""") + assertNull(a.channel) + assertNull(a.member) + assertEquals("alerts", a.route.id) + assertEquals("Test note", a.title) + } + + @Test + fun oddMemberShapesAreIgnored() { + assertNull(note(""","member":null""").member) + assertNull(note(""","member":"test-butler"""").member) + assertNull(note(""","member":{"name":"No Slug"}""").member) + assertEquals(AlertMember("demo-bot", ""), note(""","member":{"slug":"demo-bot"}""").member) + assertEquals("Demo Bot", note(""","member":{"slug":"demo-bot"}""").route.botName) + assertNull(note(""","channel":null""").channel) + } + + @Test + fun aFeedListParsesTheNewFields() { + val feed = """{"notifications":[ + {"ts":"2026-11-07T10:00:00Z","title":"A","channel":"system.agent"}, + {"ts":"2026-11-07T10:01:00Z","title":"B","channel":"system.monitor"}, + {"ts":"2026-11-07T10:02:00Z","title":"C","member":{"slug":"trip-helper","name":"trip-helper"}} + ]}""" + val routes = AlertFeed.parseFeed(feed)!!.map { it.route } + assertEquals(listOf("news", "watches", "bot_trip-helper"), routes.map { it.id }) + assertEquals("Trip Helper", routes[2].botName) + } +} From 4042e3305ee3ac5b6a316c599c42110a5425357b Mon Sep 17 00:00:00 2001 From: rayvader <42436531+rayvader@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:17:35 +0000 Subject: [PATCH 2/3] docs: made-up Bot names in the channel naming comment --- .../java/io/github/rayvader/kirocrew/core/AlertChannels.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/github/rayvader/kirocrew/core/AlertChannels.kt b/app/src/main/java/io/github/rayvader/kirocrew/core/AlertChannels.kt index 459a70f..e488a38 100644 --- a/app/src/main/java/io/github/rayvader/kirocrew/core/AlertChannels.kt +++ b/app/src/main/java/io/github/rayvader/kirocrew/core/AlertChannels.kt @@ -59,9 +59,9 @@ object AlertChannels { .take(MAX_SLUG) /** - * The channel's name. The feed's `member.name` as given ("Froyo", "Travel Guru"). The feed - * often carries the slug there too ("travel-guru"): a name with no spaces and no capitals - * that is made of `-` or `_` words is turned into words ("Travel Guru"). Empty: from the slug. + * The channel's name. The feed's `member.name` as given ("Test Butler", "Trip Helper"). The feed + * often carries the slug there too ("trip-helper"): a name with no spaces and no capitals + * that is made of `-` or `_` words is turned into words ("Trip Helper"). Empty: from the slug. */ fun displayName(name: String?, slug: String): String { val n = name?.trim()?.replace(Regex("\\s+"), " ").orEmpty() From 0c07faf1337a500ddbcaa3a3f9b04a3a60c92a9b Mon Sep 17 00:00:00 2001 From: rayvader <42436531+rayvader@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:19:03 +0000 Subject: [PATCH 3/3] test: channel dump must not stop the e2e on a missing command cmd notification list_channels exits non-zero on the CI emulator, which ended the feature with no message. dumpsys alone lists the channels. --- .github/e2e/run.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/e2e/run.sh b/.github/e2e/run.sh index 38c53fd..081ac7c 100755 --- a/.github/e2e/run.sh +++ b/.github/e2e/run.sh @@ -488,9 +488,13 @@ PY } # The app's notification channels and their groups, as Android reports them. +# dumpsys lists each app's channels with their group; the cmd form is extra detail where +# Android has it (it exits non-zero where it does not, which must not stop the test). channels_dump() { - { adb shell dumpsys notification --noredact; adb shell cmd notification list_channels "$PKG" 0 2>/dev/null; } > "$OUT/channels.txt" + adb shell dumpsys notification --noredact > "$OUT/channels.txt" || true + adb shell cmd notification list_channels "$PKG" 0 >> "$OUT/channels.txt" 2>/dev/null || true cp "$OUT/channels.txt" "$OUT/logs/$FEATURE_ID-channels.txt" + grep -q "NotificationChannel{" "$OUT/channels.txt" || fail "Android listed no notification channels" } # has_channel ID GROUP: the channel ID exists in GROUP. has_channel() {