refactor: reuse AccountFilterAssembler, pause heavy feeds on background

The notification service no longer creates its own relay subscriptions.
Instead, it relies on the AccountFilterAssembler subscription from the
Compose tree, which covers notifications, gift wraps, metadata, follows,
relay lists, and drafts. This ensures follow/mute list changes are
reflected in notification filtering.

Heavy feed subscriptions (Home, Video, Discovery, ChatroomList) now use
LifecycleAwareKeyDataSourceSubscription which subscribes on ON_START and
unsubscribes on ON_STOP. When the app backgrounds, these feeds pause and
their outbox relays disconnect. Only inbox and DM relays stay connected
via AccountFilterAssembler.

This prevents bandwidth waste on feeds nobody is viewing while the
always-on service keeps relay connections alive.

https://claude.ai/code/session_01LEPfmgGnwjB9a5SDFw5U8t
This commit is contained in:
Claude
2026-04-08 20:43:53 +00:00
parent 4763ae57c0
commit fc6b7871c0
7 changed files with 169 additions and 207 deletions
+44 -64
View File
@@ -26,82 +26,61 @@ NIP-17 DM inbox relays ───────────WebSocket──┘
The service shares the **same `NostrClient` instance** as the UI. This is the key design
decision. When the app is in the foreground, both the UI and the service are collecting
the `relayServices` flow and have active subscriptions. When the app backgrounds, UI
subscriptions drop but the service's subscriptions remain, keeping inbox and DM relay
connections alive. When the app returns to the foreground, the UI piggybacks on the
already-open connections. **Zero reconnection, zero dropped messages.**
the `relayServices` flow. The `AccountFilterAssembler` subscription from the Compose UI
tree stays active as long as the Activity exists (even when stopped/backgrounded),
keeping notification and DM relay connections alive. When the app returns to the
foreground, the UI piggybacks on the already-open connections. **Zero reconnection, zero
dropped messages.**
```
BACKGROUND MODE:
inbox-relay-1 ──WebSocket──> [Service: svc:notif subscription]
inbox-relay-2 ──WebSocket──> [Service: svc:notif subscription]
dm-relay-1 ────WebSocket──> [Service: svc:giftwrap subscription]
BACKGROUND MODE (service running):
inbox-relay-1 ──WebSocket──> [AccountFilterAssembler: notifications, metadata, follows]
inbox-relay-2 ──WebSocket──> [AccountFilterAssembler: notifications, metadata, follows]
dm-relay-1 ────WebSocket──> [AccountFilterAssembler: gift wraps]
(outbox relays disconnected — no Home/Video/Discovery subscriptions)
APP OPENS:
inbox-relay-1 ──WebSocket──> [Same connection] <── UI adds feed subscriptions
inbox-relay-2 ──WebSocket──> [Same connection] <── UI adds feed subscriptions
dm-relay-1 ────WebSocket──> [Same connection] <── UI adds chat subscriptions
outbox-relay-3 ──WebSocket──> [New connection] <── feed-only relay
APP FOREGROUNDS:
inbox-relay-1 ──WebSocket──> [Same connection] <── Home/Discovery subs resume
inbox-relay-2 ──WebSocket──> [Same connection] <── Home/Discovery subs resume
dm-relay-1 ────WebSocket──> [Same connection] <── ChatroomList subs resume
outbox-relay-3 ──WebSocket──> [New connection] <── Home/Video feed relay
```
## Relay Subscriptions
## Subscription Architecture
The service maintains two independent subscriptions on the shared `NostrClient`:
### What the service does
### `svc:notif` — Notification Inbox Relays
The service does NOT create its own relay subscriptions. It only keeps the
`RelayProxyClientConnector` alive by collecting `relayServices`. The actual subscriptions
come from the `AccountFilterAssembler` in the Compose tree (`LoggedInPage`), which
stays active as long as the Activity exists and covers:
Subscribes to the user's NIP-65 inbox relays (from `Account.notificationRelays.flow`)
for notification-relevant event kinds:
- **Metadata** — user profile, relay lists, mute lists, follows (via `AccountMetadataEoseManager`)
- **Follows** — follow list changes that affect notification filtering (via `AccountFollowsLoaderSubAssembler`)
- **Notifications** — mentions, zaps, reactions on NIP-65 inbox relays (via `AccountNotificationsEoseFromInboxRelaysManager`)
- **Gift wraps** — NIP-59 encrypted DMs on NIP-17 DM relays (via `AccountGiftWrapsEoseManager`)
- **Drafts** — draft events (via `AccountDraftsEoseManager`)
- **Summary kinds**: TextNote, Reaction, Repost, LnZap (limit 2000)
- **Per-key kinds**: Reports, Zaps, Channel Messages, Polls, Badges, etc. (limit 500)
- **Per-key kinds 2**: Git events, Highlights, Comments, Calendar events (limit 200)
- **Per-key kinds 3**: Attestation events (limit 10)
This is critical because notification filtering depends on follow lists, mute lists,
and relay configurations. If the service maintained its own isolated subscriptions,
it would miss follow list changes and display notifications from muted users.
All filtered by `p` tag matching the user's pubkey.
### What pauses on background
**Why separate from the UI's subscriptions?** The UI's `AccountNotificationsEoseFromInboxRelaysManager`
is part of `AccountFilterAssembler`, which is driven by the Compose lifecycle. When the
app backgrounds and composables leave composition, these subscriptions are dropped. The
relay pool then disconnects relays that no longer have any active subscriptions. The
service's `svc:notif` subscription ensures notification inbox relays stay connected.
Heavy feed subscriptions use `LifecycleAwareKeyDataSourceSubscription` which subscribes
on `ON_START` and unsubscribes on `ON_STOP`. When the app backgrounds:
### `svc:giftwrap` — DM Inbox Relays
| Subscription | Behavior | Why |
|-------------|----------|-----|
| `AccountFilterAssembler` | **Stays active** | Needed for notifications, DMs, follow/mute list changes |
| `HomeFilterAssembler` | **Pauses** | Home feed data with nobody viewing wastes bandwidth |
| `VideoFilterAssembler` | **Pauses** | Video feed data with nobody viewing wastes bandwidth |
| `DiscoveryFilterAssembler` | **Pauses** | Discovery feed data with nobody viewing wastes bandwidth |
| `ChatroomListFilterAssembler` | **Pauses** | Chatroom list updates with nobody viewing waste bandwidth |
Subscribes to the user's DM inbox relays (from `Account.dmRelays.flow`) for NIP-59
gift-wrapped messages:
- **Kinds**: GiftWrapEvent (1059), EphemeralGiftWrapEvent
- **Tag filter**: `p` tag matching the user's pubkey
- **Lookback**: 2 days from `since` timestamp
The DM relay set is broader than the notification relay set — it combines NIP-17 DM relays
(ChatMessageRelayListEvent, kind 10050), NIP-65 inbox relays, private outbox relays, and
local relays.
**Why?** DM relays may be completely different from notification inbox relays. Without
this subscription, DM relays that aren't also notification inbox relays would disconnect
when the app backgrounds, and incoming encrypted DMs would be missed.
### Reactive Updates
Both subscriptions reactively update when relay lists change:
```kotlin
combine(
account.notificationRelays.flow,
account.dmRelays.flow,
) { notifRelays, dmRelays ->
Pair(notifRelays, dmRelays)
}.collectLatest { (notifRelays, dmRelays) ->
updateNotificationSubscription(account, notifRelays)
updateGiftWrapSubscription(account, dmRelays)
}
```
If the user adds or removes relays from their NIP-65 or NIP-17 lists, the service's
subscriptions are updated immediately. The shared relay pool handles connecting to new
relays and disconnecting from removed ones.
When the feed subscriptions pause, the relay pool automatically disconnects outbox relays
that no longer have any active subscriptions. Only inbox and DM relays stay connected
(because `AccountFilterAssembler` still has active subscriptions on them).
## Foreground Service
@@ -284,7 +263,8 @@ The always-on notification service is **opt-in** (off by default). Users enable
| File | Purpose |
|------|---------|
| `NotificationRelayService.kt` | Foreground service, relay subscriptions, auto-restart |
| `NotificationRelayService.kt` | Foreground service, keeps relay connections alive, auto-restart |
| `LifecycleAwareKeyDataSourceSubscription.kt` | Pauses heavy feed subs when app backgrounds |
| `BootCompletedReceiver.kt` | Restart on boot and app update |
| `AutoRestartReceiver.kt` | Restart via WorkManager when service is destroyed |
| `NotificationCatchUpWorker.kt` | Periodic and on-demand catch-up worker |
@@ -38,38 +38,30 @@ import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterNotificationsToPubkey
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterSummaryNotificationsToPubkey
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.filterGiftWrapsToPubkey
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
/**
* A foreground service that maintains persistent WebSocket connections to the user's
* inbox relays for real-time notification delivery.
*
* This service:
* - Keeps the shared NostrClient alive by collecting the relayServices flow
* - Routes incoming events through EventNotificationConsumer
* - Survives app backgrounding (connections stay open)
* - Uses specialUse foreground service type (no Android 15 time limit)
* This service keeps the shared NostrClient alive by collecting the relayServices flow.
* It does NOT create its own relay subscriptions — instead, it relies on the
* AccountFilterAssembler subscription from the Compose UI tree, which stays active
* as long as the Activity is alive (even when stopped/backgrounded). That subscription
* covers notifications, gift wraps, metadata, follows, relay lists, and drafts.
*
* The key insight is that this service shares the same NostrClient as the UI.
* When the app is in the foreground, both the UI and this service are subscribers.
* When the app backgrounds, UI subscriptions drop but this service's subscriptions
* remain, keeping inbox relay connections alive. No reconnection needed.
* Heavy feed subscriptions (Home, Video, Discovery, ChatroomList) are managed
* separately with lifecycle awareness — they pause when the app backgrounds
* and resume when it foregrounds. This prevents bandwidth waste on feeds
* nobody is viewing.
*
* Auto-restart mechanisms (inspired by ntfy):
* - START_STICKY: Android restarts killed services
@@ -127,9 +119,6 @@ class NotificationRelayService : Service() {
private var connectedRelayCount = 0
private var foregroundStarted = false
private val subIdNotifications = "svc:notif"
private val subIdGiftWraps = "svc:giftwrap"
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
@@ -198,16 +187,6 @@ class NotificationRelayService : Service() {
override fun onDestroy() {
Log.d(TAG, "Service destroyed")
relayServiceCollectorJob?.cancel()
// Clean up our subscriptions so the relay pool can disconnect
// relays that are no longer needed by anyone
try {
Amethyst.instance.client.unsubscribe(subIdNotifications)
Amethyst.instance.client.unsubscribe(subIdGiftWraps)
} catch (e: Exception) {
// App might already be torn down
}
scope.cancel()
if (isEnabled(this)) {
@@ -249,28 +228,32 @@ class NotificationRelayService : Service() {
}
}
/**
* Keeps the relay infrastructure alive. The service collects two flows:
*
* 1. relayServices: Keeps the RelayProxyClientConnector active (connectivity,
* Tor, network changes). Without this, the client disconnects 30s after
* the UI stops collecting.
*
* 2. connectedRelaysFlow: Updates the persistent notification with relay count.
*
* The service does NOT create its own relay subscriptions. Instead, it relies on
* the AccountFilterAssembler subscription that lives in the Compose tree (LoggedInPage).
* That subscription stays active as long as the Activity exists (even when stopped)
* and covers: metadata, follows, notifications (inbox relays), gift wraps (DM relays),
* drafts, and relay list changes. Since the service keeps the client connected,
* those subscriptions remain active on the relays.
*/
private fun startRelayConnection() {
relayServiceCollectorJob?.cancel()
relayServiceCollectorJob =
scope.launch {
// Collecting the relayServices flow keeps the RelayProxyClientConnector alive.
// This is the same flow that ManageRelayServices() composable collects in the UI.
// By collecting it here, relay connections survive app backgrounding.
launch {
Amethyst.instance.relayProxyClientConnector.relayServices.collectLatest {
Log.d(TAG) { "Relay services state updated: $it" }
}
}
// Maintain notification + DM subscriptions on inbox relays.
// When the UI backgrounds, its subscriptions drop and relays that are
// only needed for those subscriptions disconnect. By opening our own
// subscriptions here, we ensure inbox + DM relay connections persist.
launch {
maintainInboxSubscriptions()
}
// Monitor connected relay count to update the notification
launch {
Amethyst.instance.client.connectedRelaysFlow().collectLatest { relays ->
val count = relays.size
@@ -283,99 +266,6 @@ class NotificationRelayService : Service() {
}
}
/**
* Watches the logged-in account's notification inbox relays and DM inbox relays,
* maintaining lightweight subscriptions on them. This ensures these relay connections
* persist even when the UI's subscriptions drop.
*
* Notification relays: NIP-65 inbox + local relays → subscribe to mentions, zaps, etc.
* DM relays: NIP-17 DM relays + NIP-65 inbox + private outbox + local relays → gift wraps
*/
private suspend fun maintainInboxSubscriptions() {
// Wait for account to be available
Amethyst.instance.sessionManager.accountContent.collectLatest { state ->
val account =
(state as? com.vitorpamplona.amethyst.ui.screen.AccountState.LoggedIn)?.account
if (account == null || !account.isWriteable()) {
// No account or read-only — close any existing subscriptions
Amethyst.instance.client.unsubscribe(subIdNotifications)
Amethyst.instance.client.unsubscribe(subIdGiftWraps)
return@collectLatest
}
// Reactively update subscriptions when relay lists change
combine(
account.notificationRelays.flow,
account.dmRelays.flow,
) { notifRelays, dmRelays ->
Pair(notifRelays, dmRelays)
}.collectLatest { (notifRelays, dmRelays) ->
updateNotificationSubscription(account, notifRelays)
updateGiftWrapSubscription(account, dmRelays)
}
}
}
private fun updateNotificationSubscription(
account: Account,
relays: Set<NormalizedRelayUrl>,
) {
if (relays.isEmpty()) {
Amethyst.instance.client.unsubscribe(subIdNotifications)
return
}
val pubkey = account.signer.pubKey
val since = TimeUtils.oneWeekAgo()
val filters = mutableMapOf<NormalizedRelayUrl, List<Filter>>()
relays.forEach { relay ->
val relayFilters =
(
filterSummaryNotificationsToPubkey(relay, pubkey, since) +
filterNotificationsToPubkey(relay, pubkey, since)
).map { it.filter }
if (relayFilters.isNotEmpty()) {
filters[relay] = relayFilters
}
}
if (filters.isNotEmpty()) {
Amethyst.instance.client.subscribe(subIdNotifications, filters)
Log.d(TAG) { "Subscribed to notifications on ${filters.size} relays" }
}
}
private fun updateGiftWrapSubscription(
account: Account,
relays: Set<NormalizedRelayUrl>,
) {
if (relays.isEmpty()) {
Amethyst.instance.client.unsubscribe(subIdGiftWraps)
return
}
val pubkey = account.signer.pubKey
val since = TimeUtils.oneWeekAgo()
val filters = mutableMapOf<NormalizedRelayUrl, List<Filter>>()
relays.forEach { relay ->
val relayFilters =
filterGiftWrapsToPubkey(relay, pubkey, since).map { it.filter }
if (relayFilters.isNotEmpty()) {
filters[relay] = relayFilters
}
}
if (filters.isNotEmpty()) {
Amethyst.instance.client.subscribe(subIdGiftWraps, filters)
Log.d(TAG) { "Subscribed to gift wraps on ${filters.size} DM relays" }
}
}
private fun updateNotification(connectedRelays: Int) {
val notification = buildNotification(connectedRelays)
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -45,5 +45,5 @@ fun ChatroomListFilterAssemblerSubscription(
ChatroomListState(accountViewModel.account)
}
KeyDataSourceSubscription(state, dataSource)
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -46,5 +46,5 @@ fun DiscoveryFilterAssemblerSubscription(
DiscoveryQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
KeyDataSourceSubscription(state, dataSource)
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -50,5 +50,5 @@ fun HomeFilterAssemblerSubscription(
)
}
KeyDataSourceSubscription(state, dataSource)
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -46,5 +46,5 @@ fun VideoFilterAssemblerSubscription(
VideoQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
KeyDataSourceSubscription(state, filterAssembler)
LifecycleAwareKeyDataSourceSubscription(state, filterAssembler)
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayClient.subscriptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
/**
* A lifecycle-aware version of [KeyDataSourceSubscription] that subscribes
* when the lifecycle reaches STARTED and unsubscribes when it reaches STOPPED.
*
* Use this for heavy feed subscriptions (home, video, discovery, chatroom list)
* that should NOT run when the app is in the background. When an always-on
* notification service keeps the relay client connected, these subscriptions
* would otherwise leak bandwidth on feeds nobody is viewing.
*
* Lightweight subscriptions that should always run (account metadata, notifications,
* gift wraps) should continue using the regular [KeyDataSourceSubscription].
*/
@Composable
fun <T> LifecycleAwareKeyDataSourceSubscription(
state: T,
dataSource: ComposeSubscriptionManager<T>,
) {
val lifecycleOwner = LocalLifecycleOwner.current
var isStarted by remember { mutableStateOf(false) }
DisposableEffect(state, lifecycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
if (!isStarted) {
dataSource.subscribe(state)
isStarted = true
}
}
Lifecycle.Event.ON_STOP -> {
if (isStarted) {
dataSource.unsubscribe(state)
isStarted = false
}
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
// If already started (e.g., recomposition while visible), subscribe immediately
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
dataSource.subscribe(state)
isStarted = true
}
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
if (isStarted) {
dataSource.unsubscribe(state)
isStarted = false
}
}
}
}