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
+170 -127
View File
@@ -1,54 +1,119 @@
# Always-On Notification Service
Amethyst's always-on notification service maintains persistent WebSocket connections to the
user's inbox relays, ensuring real-time delivery of DMs, zaps, mentions, and other
notifications without depending on an external push server.
user's inbox relays and DM relays, ensuring real-time delivery of DMs, zaps, mentions, and
other notifications without depending on an external push server.
## Why
The existing push notification system (`push.amethyst.social`) can only monitor relays it
knows about. Private, paid, or obscure inbox relays get missed entirely. The only way to
guarantee 100% notification coverage is for the device itself to maintain connections to
the user's NIP-65 inbox relays.
the user's NIP-65 inbox relays and NIP-17 DM relays.
## Architecture
```
User's inbox relays <──WebSocket──> [NotificationRelayService]
|
v
EventNotificationConsumer
|
v
Android Notification
NIP-65 notification inbox relays ──WebSocket──
├──> [NotificationRelayService]
NIP-17 DM inbox relays ───────────WebSocket──┘ |
v
EventNotificationConsumer
|
v
Android Notification
```
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. When the app backgrounds, UI subscriptions drop but the service
keeps collecting, so inbox relay connections stay alive. When the app returns to the
foreground, the UI piggybacks on the already-open connections. **Zero reconnection, zero
dropped messages.**
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.**
```
BACKGROUND MODE:
inbox-relay-1 ──WebSocket──> [Service keeps collecting relayServices flow]
inbox-relay-2 ──WebSocket──> [Service keeps collecting relayServices flow]
inbox-relay-1 ──WebSocket──> [Service: svc:notif subscription]
inbox-relay-2 ──WebSocket──> [Service: svc:notif subscription]
dm-relay-1 ────WebSocket──> [Service: svc:giftwrap subscription]
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
```
## Relay Subscriptions
The service maintains two independent subscriptions on the shared `NostrClient`:
### `svc:notif` — Notification Inbox Relays
Subscribes to the user's NIP-65 inbox relays (from `Account.notificationRelays.flow`)
for notification-relevant event kinds:
- **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)
All filtered by `p` tag matching the user's pubkey.
**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.
### `svc:giftwrap` — DM Inbox Relays
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.
## Foreground Service
`NotificationRelayService` is a foreground service with `specialUse` type:
- **No time limit**: Unlike `dataSync` (6-hour limit on Android 15), `specialUse` has no
timeout restriction.
timeout restriction. **Why it matters:** A notification service must run indefinitely.
The `dataSync` type would force the service to stop after 6 cumulative hours per 24-hour
period, making it useless for always-on notifications.
- **BOOT_COMPLETED safe**: Can be started from boot receivers on Android 15+, unlike
`dataSync` which is restricted.
`dataSync` which is restricted. **Why it matters:** Without this, the service couldn't
auto-restart after a reboot on modern Android.
- **START_STICKY**: Android will restart the service if it's killed by the system.
- **Persistent notification**: Shows "Connected to N inbox relays" with a Pause action.
Uses `IMPORTANCE_LOW` so it's silent and unobtrusive.
@@ -59,161 +124,138 @@ On Android 12+, starting a foreground service from the background can throw
`ForegroundServiceStartNotAllowedException`. The service catches this gracefully and stops
itself rather than crashing the app.
**Why it matters:** Without this catch, if the watchdog alarm or WorkManager tries to
restart the service while the app lacks the background-start exemption (e.g., battery
optimization is active), the app would crash with an unhandled exception.
### Redundant startForeground()
`startForeground()` is called from both `onCreate()` and `onStartCommand()` as a safety
net. In rare edge cases, `onStartCommand()` can fire before `onCreate()` completes
(observed in ntfy issue #1520). The `foregroundStarted` flag prevents double invocation.
**Why it matters:** If `startForeground()` isn't called within 5 seconds of
`startForegroundService()`, the app crashes with an ANR. The redundant call ensures the
foreground notification is posted regardless of which lifecycle method runs first.
## 8-Layer Auto-Restart Defense
Android (and OEM battery optimizers) will aggressively try to kill background services.
The notification service uses 8 independent mechanisms to stay alive:
The notification service uses 8 independent mechanisms to stay alive. Each addresses a
specific kill vector that the others don't cover:
### Layer 1: START_STICKY
```kotlin
override fun onStartCommand(...): Int {
...
return START_STICKY
}
```
**What:** When Android kills the service due to memory pressure, `START_STICKY` tells the
system to recreate it with a null intent.
When Android kills the service due to memory pressure, `START_STICKY` tells the system
to recreate it. The service will receive a new `onStartCommand()` call with a null intent.
**Why needed:** This is the baseline restart mechanism provided by Android. However, it's
unreliable in practice — many OEMs (Xiaomi MIUI, Huawei EMUI, Samsung One UI, Oppo
ColorOS) override this behavior and prevent sticky service restarts. That's why we need
the remaining 7 layers.
### Layer 2: onTaskRemoved() Alarm
```kotlin
override fun onTaskRemoved(rootIntent: Intent?) {
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
alarmManager.set(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + 1000,
pendingIntent,
)
}
```
**What:** When the user swipes the app from recents, schedules a 1-second alarm to restart
the service.
When the user swipes the app from recents, some OEMs (Xiaomi, Huawei, Samsung) kill the
foreground service. This schedules a 1-second alarm to restart the service immediately.
Without this, the service would stay dead until the next watchdog or WorkManager cycle.
**Why needed:** On stock Android, swiping from recents only removes the task but leaves
the foreground service running. However, many OEMs treat swipe-from-recents as a force
stop, killing the foreground service. `START_STICKY` won't help because some OEMs block
sticky restarts after a task removal. The alarm bypasses this by scheduling the restart
through `AlarmManager`, which is a separate system that OEM modifications rarely touch.
### Layer 3: onDestroy() Broadcast
```kotlin
override fun onDestroy() {
sendBroadcast(Intent(this, AutoRestartReceiver::class.java))
}
```
**What:** When the service is destroyed for any reason, it broadcasts to
`AutoRestartReceiver`, which enqueues a one-time WorkManager task with a network
connectivity constraint.
When the service is destroyed for any reason, it broadcasts to `AutoRestartReceiver`,
which enqueues a one-time WorkManager task with a network connectivity constraint. This
catches kills that `START_STICKY` might miss (e.g., OEM battery optimizers that prevent
service restart).
**Why needed:** This catches the gap between `START_STICKY` and `onTaskRemoved()`.
If the system kills the service during normal operation (not from recents), `START_STICKY`
should restart it — but if the OEM blocks that restart, the broadcast fires a WorkManager
task as a backup. WorkManager is harder for OEMs to suppress because it's part of
Google Play Services infrastructure.
### Layer 4: AlarmManager Watchdog (5 minutes)
```kotlin
alarmManager.setInexactRepeating(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS,
WATCHDOG_INTERVAL_MS, // 5 minutes
pendingIntent,
)
```
**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME_WAKEUP` alarm every 5
minutes. The receiver checks if the service should be running and restarts it.
`ServiceWatchdogManager` fires an alarm every 5 minutes. The `WatchdogReceiver` checks
if the service should be running and restarts it if needed. Uses
`ELAPSED_REALTIME_WAKEUP` to wake the device from sleep.
**Why needed:** This is the "belt and suspenders" layer. If all of the above layers fail
(sticky restart blocked, alarm from `onTaskRemoved` didn't fire, broadcast wasn't
delivered), the watchdog will catch it within 5 minutes. Uses `ELAPSED_REALTIME_WAKEUP` to
wake the device from sleep, ensuring the check happens even in Doze.
### Layer 5: WorkManager Periodic Catch-Up (15 minutes)
```kotlin
PeriodicWorkRequestBuilder<NotificationCatchUpWorker>(
15, TimeUnit.MINUTES,
).setConstraints(networkConstraint).build()
```
**What:** Runs every 15 minutes with a network connectivity constraint. Ensures relay
connections are active and restarts the foreground service if needed.
`NotificationCatchUpWorker` runs every 15 minutes (WorkManager's minimum interval) with
a network connectivity constraint. It collects `relayServices` to ensure connections are
active, waits briefly for events to flow, and also restarts the foreground service if
it's supposed to be running but isn't.
**Why needed:** WorkManager survives process death and device reboots — it's the most
persistent scheduling mechanism on Android. Even if the app process is completely dead,
WorkManager (backed by JobScheduler) will eventually wake it. The 15-minute interval is
WorkManager's minimum, ensuring regular catch-up even if the foreground service has been
dead for a while.
### Layer 6: Network-Available One-Time Worker
```kotlin
OneTimeWorkRequestBuilder<NotificationCatchUpWorker>()
.setConstraints(networkConnectedConstraint)
.build()
```
**What:** When `AutoRestartReceiver` fires, it enqueues a one-time WorkManager task that
runs as soon as network connectivity is available.
When the `AutoRestartReceiver` fires, it enqueues a one-time WorkManager task that runs
as soon as network connectivity is available. This means the service restarts immediately
when the device comes back online, rather than waiting up to 15 minutes for the periodic
worker.
**Why needed:** If the service dies during a network outage, there's no point restarting it
immediately (the relays won't connect). This worker waits for connectivity and restarts
then, rather than waiting up to 15 minutes for the next periodic worker. This is especially
important after airplane mode, tunnel/elevator scenarios, or switching between WiFi and
cellular.
### Layer 7: Boot and Package Receivers
```kotlin
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED,
"android.intent.action.QUICKBOOT_POWERON",
Intent.ACTION_MY_PACKAGE_REPLACED,
-> NotificationRelayService.start(context)
}
```
**What:** `BootCompletedReceiver` restarts the service after device reboot
(`BOOT_COMPLETED`, `QUICKBOOT_POWERON`) and app update (`MY_PACKAGE_REPLACED`).
`BootCompletedReceiver` restarts the service after:
- **Device reboot** (`BOOT_COMPLETED`, `QUICKBOOT_POWERON`)
- **App update** (`MY_PACKAGE_REPLACED`) — without this, the service stays dead after
Play Store updates until the user manually opens the app
**Why needed:** After a reboot, no services are running — `START_STICKY` doesn't apply
across reboots. The boot receiver is the only way to restart. After an app update, the
old process is killed and the new version's services don't auto-start. Without
`MY_PACKAGE_REPLACED`, users would need to manually open the app after every Play Store
update to restore notifications.
### Layer 8: FCM / UnifiedPush (existing)
The existing push notification system (`PushNotificationReceiverService` for Play,
`PushMessageReceiver` for F-Droid) continues to work as a complementary wakeup mechanism.
Push notifications from the server can wake the app process even if all other layers fail.
**What:** The existing push notification system continues to work alongside the always-on
service.
**Why needed:** FCM is the only mechanism that survives a force stop (because Google Play
Services handles delivery outside the app's process). If the user explicitly force-stops
Amethyst from Settings, all 7 layers above are disabled. Only FCM can still deliver
notifications until the user opens the app again.
## WakeLock During Notification Processing
```kotlin
private inline fun <T> withWakeLock(block: () -> T): T {
val wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"amethyst:notification_processing",
)
wakeLock.acquire(10 * 60 * 1000L) // 10-minute timeout
try {
return block()
} finally {
if (wakeLock.isHeld) wakeLock.release()
}
}
```
`EventNotificationConsumer` acquires a `PARTIAL_WAKE_LOCK` with a 10-minute timeout when
processing incoming notifications. This ensures the CPU stays awake long enough to
decrypt NIP-59 gift wraps, verify signatures, resolve accounts, and dispatch the Android
notification even if the device is in Doze mode.
processing incoming notifications.
**Why needed:** When a notification event arrives from a relay, the app needs to decrypt
NIP-59 gift wraps, verify signatures, look up accounts, resolve display names, load
profile pictures, and construct the Android notification. In Doze mode, the CPU can sleep
between alarm windows. Without a WakeLock, the CPU could sleep mid-processing, causing the
notification to be delayed or lost. The 10-minute timeout is generous to handle slow
decryption (especially with external signers) while preventing indefinite wake locks from
battery drain.
## Battery Optimization Exemption
The service works best when the app is exempted from Android's battery optimizations
(Doze). Without the exemption, Android may restrict network access and defer alarms even
for foreground services.
The service works best when the app is exempted from Android's battery optimizations (Doze).
**Why needed:** Even with a foreground service, Android can restrict network access during
Doze maintenance windows. The battery optimization exemption tells Android that this app's
network activity is user-expected and should not be deferred. Without it, relay connections
may be broken during Doze, causing missed notifications that only arrive when the device
exits Doze (which can be hours for a stationary, charging device).
`BatteryOptimizationHelper` checks the exemption status and provides a method to launch
the system settings dialog:
```kotlin
BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context)
BatteryOptimizationHelper.requestBatteryOptimizationExemption(context)
```
When the always-on service is enabled but the app isn't whitelisted, the settings screen
shows a warning banner with a "Fix now" button that opens the system battery optimization
settings directly.
the system settings dialog. When the always-on service is enabled but the app isn't
whitelisted, the settings screen shows a warning banner with a "Fix now" button.
Messaging apps are explicitly listed as a valid use case for this exemption in Google Play
policy.
@@ -242,7 +284,7 @@ The always-on notification service is **opt-in** (off by default). Users enable
| File | Purpose |
|------|---------|
| `NotificationRelayService.kt` | Foreground service, connection lifecycle, auto-restart |
| `NotificationRelayService.kt` | Foreground service, relay subscriptions, auto-restart |
| `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 |
@@ -269,8 +311,9 @@ The always-on notification service is **opt-in** (off by default). Users enable
This implementation draws from battle-tested patterns in:
- **ntfy** — `onTaskRemoved()` alarm, `onDestroy()` broadcast restart,
- **ntfy** (millions of users)`onTaskRemoved()` alarm, `onDestroy()` broadcast restart,
`ForegroundServiceStartNotAllowedException` handling, redundant `startForeground()`,
battery optimization guidance, WakeLock during processing
- **Pokey** — `specialUse` foreground service type, `MY_PACKAGE_REPLACED` restart
- **Pokey** (Nostr notification app)`specialUse` foreground service type,
`MY_PACKAGE_REPLACED` restart
- **Signal** — Hybrid FCM + persistent WebSocket architecture
@@ -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