feat: add DM inbox relay subscriptions to notification service

The service now maintains two independent subscriptions:
- svc:notif: notification events on NIP-65 inbox relays
- svc:giftwrap: NIP-59 gift wraps on NIP-17 DM inbox relays

This ensures DM relays that aren't also notification inbox relays
stay connected when the app backgrounds. Both subscriptions update
reactively when relay lists change.

Also updates PULL_NOTIFICATION.md with detailed "why" explanations
for each resilience layer and documents the DM relay architecture.

https://claude.ai/code/session_01LEPfmgGnwjB9a5SDFw5U8t
This commit is contained in:
Claude
2026-04-08 18:53:08 +00:00
parent 315b1e4942
commit 4763ae57c0
2 changed files with 292 additions and 127 deletions
@@ -38,14 +38,22 @@ 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
/**
@@ -119,6 +127,9 @@ 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() {
@@ -187,6 +198,16 @@ 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)) {
@@ -241,6 +262,14 @@ class NotificationRelayService : Service() {
}
}
// 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 ->
@@ -254,6 +283,99 @@ 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