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:
+170
-127
@@ -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
|
||||
|
||||
+122
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user