feat(notifications): open the subscriptions screen from the ongoing notification

Tapping the always-on notification landed on whatever tab was last open,
which does not answer the question that notification raises. It now
deep-links to Active Relay Subscriptions via an `activesubs` route, and
the screen gets its own entry in All Settings.

The expanded breakdown also held its last value across reconnects. It is
derived from the *connected* relays, so a drop to zero — the "connecting"
state — emptied it and collapsed the expanded view to a single line
exactly when someone was most likely reading it. What each connection is
for does not change while it re-establishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-30 21:00:29 -04:00
co-authored by Claude Opus 5
parent 0ad4726d41
commit 5f6d09425d
4 changed files with 33 additions and 3 deletions
@@ -36,6 +36,7 @@ import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
@@ -79,6 +80,10 @@ class NotificationRelayService : Service() {
companion object {
private const val TAG = "NotificationRelayService"
private const val CHANNEL_ID = "notification_relay_service"
/** Parsed back into `Route.ActiveSubscriptions` by `MainActivity.uriToRoute`. */
private const val ACTIVE_SUBSCRIPTIONS_URI = "activesubs"
private const val NOTIFICATION_ID = 9832
private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE"
@@ -141,6 +146,9 @@ class NotificationRelayService : Service() {
private var relayServiceCollectorJob: Job? = null
private var connectedRelayCount = 0
/** Last non-empty per-job breakdown, kept so a reconnect does not blank the expanded view. */
private var lastBreakdown: List<String> = emptyList()
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
@@ -336,9 +344,12 @@ class NotificationRelayService : Service() {
pluralStringRes(this, R.plurals.always_on_notif_connected, connectedRelays, connectedRelays)
}
// Tapping goes to the screen that answers the question the notification raises — "why is it
// connected to N relays" — rather than to whatever tab was last open.
val openAppIntent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
data = ACTIVE_SUBSCRIPTIONS_URI.toUri()
}
val pendingIntent =
PendingIntent.getActivity(
@@ -351,8 +362,16 @@ class NotificationRelayService : Service() {
// Expanded only. The collapsed line stays the bare count it has always been — that is all
// most people want from an ongoing notification — and the per-job breakdown appears solely
// when someone deliberately expands it to ask why the phone is talking to N relays.
// Skipped entirely when nothing is attributed yet, so we never render an empty section.
val breakdown = RelayPurposeSummary.lines(this).takeIf { it.isNotEmpty() }
//
// Held across reconnects rather than recomputed blindly: the breakdown is derived from the
// *connected* relays, so a drop to zero (the "connecting…" state) would otherwise empty it and
// the expanded view would collapse to a single line exactly when someone is most likely
// looking at it. What each connection is *for* does not change while it is re-establishing,
// so the last known answer is still the right one; only the count above it goes stale, and
// that count is already labelled "connecting".
val fresh = RelayPurposeSummary.lines(this)
if (fresh.isNotEmpty()) lastBreakdown = fresh
val breakdown = fresh.ifEmpty { lastBreakdown }.takeIf { it.isNotEmpty() }
return NotificationCompat
.Builder(this, CHANNEL_ID)
@@ -203,6 +203,13 @@ fun isWalletConnectRoute(uri: String) = uri.startsWith("dlnwc?value=") || uri.st
fun isMarmotGroupRoute(uri: String) = uri.startsWith("marmot:")
/**
* Tapping the always-on service notification. That notification's whole subject is the relay
* connections it is holding open, so it lands on the screen that explains them rather than on
* whatever tab happened to be last open.
*/
fun isActiveSubscriptionsRoute(uri: String) = uri.startsWith("activesubs", true) || uri.startsWith("nostr:activesubs", true)
private val MARMOT_HEX = Regex("^[0-9a-fA-F]+$")
fun uriToRoute(
@@ -213,6 +220,9 @@ fun uriToRoute(
val scrollTo = runCatching { java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("scrollTo") }.getOrNull()
return Route.Notification(scrollToEventId = scrollTo)
}
if (isActiveSubscriptionsRoute(uri)) {
return Route.ActiveSubscriptions
}
if (isHashtagRoute(uri)) {
return Route.Hashtag(uri.removePrefix(NOSTR_URI_PREFIX).removePrefix("hashtag?id=").lowercase())
}
@@ -592,7 +592,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ActiveSubscriptions> { ActiveSubscriptionsScreen() }
composableFromEndArgs<Route.ActiveSubscriptions> { ActiveSubscriptionsScreen(accountViewModel, nav) }
composableFromEnd<Route.EventSync> { EventSyncScreen(accountViewModel, nav) }
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
@@ -104,6 +104,7 @@ fun buildSettingsCatalog(
symEntry(R.string.calendar_reminder_settings_title, MaterialSymbols.CalendarMonth, R.string.calendar_reminder_search_keywords, Route.CalendarReminderSettings),
symEntry(R.string.ots_explorer_settings, MaterialSymbols.Search, R.string.ots_explorer_search_keywords, Route.OtsSettings),
symEntry(R.string.namecoin_settings, MaterialSymbols.Security, R.string.namecoin_search_keywords, Route.NamecoinSettings),
symEntry(R.string.active_subs_title, MaterialSymbols.CloudSync, R.string.active_subs_search_keywords, Route.ActiveSubscriptions),
symEntry(R.string.resource_usage_title, MaterialSymbols.Bolt, R.string.resource_usage_search_keywords, Route.ResourceUsage),
),
)