Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/e2e/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
68 changes: 68 additions & 0 deletions .github/e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,74 @@ 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.
# 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 > "$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() {
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() {
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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). |
Expand All @@ -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. |
Expand Down
68 changes: 59 additions & 9 deletions app/src/main/java/io/github/rayvader/kirocrew/Notifier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -39,25 +42,66 @@ 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)
.apply {
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 &&
Expand All @@ -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}"
Expand All @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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_<slug>`), 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_<slug>, 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 ("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()
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" }
}
}
Loading
Loading