damus-io/notedeck PR #1296 · WIP

Cross-Platform Push Notifications

Notedeck had zero notification support — users had to keep the app open and actively scroll to see new mentions, replies, or zaps. This PR adds a complete notification pipeline covering three platforms (Android, macOS, Linux) with two Android delivery modes (Firebase Cloud Messaging for battery efficiency, and a native WebSocket fallback for users who refuse Google dependencies). The core insight: decouple event extraction from delivery. A shared Rust pipeline leverages nostrdb (LMDB-backed event store) — events are already ingested by the app's relay infrastructure, so the notification system simply subscribes and polls nostrdb for matching notes, resolves profiles via ndb.get_profile_by_pubkey(), and formats notifications, then hands off to platform-specific backends via a trait boundary. Android goes through JNI to Kotlin; desktop goes through notify-rust or UNUserNotificationCenter. Multi-account support ensures all logged-in accounts receive notifications simultaneously with self-notification suppression.

Android FCM Android Native macOS Linux
2 — KPI Dashboard
Lines Added
Lines Removed
Files Changed
Commits
~23
New Files
194+
Test Lines
CHANGELOG not updated Docs may need update WIP status
3 — Module Architecture
flowchart TD
  subgraph Ingest["Event Ingestion"]
    RL["Relay Messages"]
    RA["remote_api.rs"]
    NDB["nostrdb LMDB"]
  end

  subgraph DesktopPoll["Desktop Poll Model"]
    POLL["poll_notifications"]
    NDBh["ndb_helpers.rs"]
    FMT["manager.rs format"]
    CH["mpsc channel"]
  end

  subgraph Worker["Worker Thread"]
    DD["Dedup LRU"]
    WRK["worker.rs"]
  end

  subgraph Desktop["Desktop Backends"]
    LIN["DesktopBackend\nnotify-rust / libnotify"]
    MAC["MacOSBackend\nUNUserNotificationCenter"]
    IMG["image_cache.rs\nProfile pictures"]
  end

  subgraph Android["Android Path"]
    JNI["JNI Bridge\nchrome/notifications.rs"]
    ANDB["ndb ingest and poll"]
    FCM["FCM Mode\nFirebase via notepush"]
    NAT["Native Mode\nForeground Service"]
  end

  subgraph Kotlin["Kotlin Services"]
    MA["MainActivity.kt"]
    NFS["NotedeckFirebaseMessagingService"]
    NPC["NotepushClient"]
    NS["NotificationsService"]
    NH["NotificationHelper"]
  end

  subgraph Settings["Settings"]
    UI["settings.rs UI"]
    PER["SettingsHandler"]
    PLAT["platform/mod.rs"]
  end

  RL --> RA
  RA --> NDB
  NDB --> POLL
  POLL --> NDBh
  NDBh --> FMT
  FMT --> CH
  CH --> DD
  DD --> WRK
  WRK --> LIN
  WRK --> MAC
  MAC --> IMG

  JNI --> FCM
  JNI --> NAT
  NAT --> NS
  NS --> ANDB
  ANDB --> NDBh
  ANDB -.-> NH
  FCM --> NFS
  NFS --> NPC
  NFS --> NH

  UI --> PER
  UI --> PLAT
  PLAT --> JNI
  PLAT --> WRK
      
Core Pipeline (nostrdb)

Events are already ingested into nostrdb by the app's relay infrastructure. poll_notifications() polls the ndb subscription each frame, ndb_helpers.rs extracts p-tags, profiles, and zap amounts via the native Note API, manager.rs formats localized title/body, then sends NotificationData over an mpsc channel to the worker thread.

Android Dual Mode

FCM: Firebase messages routed through notepush server with NIP-98 auth. Native: Foreground service with direct WebSocket relay connections — no Google dependency.

Desktop Backends

Linux uses notify-rust (libnotify). macOS uses UNUserNotificationCenter with objc2 bindings, supporting profile picture attachments via a dedicated image cache.

4 — Notification Pipeline
Event Processing Flow
1
Ingest
remote_api →
nostrdb LMDB
2
Poll
ndb subscription
max 50/frame
3
Filter
Kind check +
p-tag match
4
Suppress
Self-notification
check
5
Resolve
ndb profile lookup
+ format title
6
Dedup
HashSet check
10K LRU
7
Display
Platform-native
notification
Supported Event Kinds
  • Kind 1 — Mentions: "@name mentioned you"
  • Kind 4 / 1059 — DMs: content hidden, "Tap to view"
  • Kind 6 — Reposts: shows original content preview
  • Kind 7 — Reactions: emoji or heart fallback
  • Kind 9735 — Zaps: BOLT11 amount extraction
  • Kind 10002/10050 — Relay list metadata (not displayed)
Deduplication
  • Desktop: O(1) HashSet<String> + VecDeque for insertion-order tracking. 10,000 entry cap with FIFO eviction.
  • Android: Double-layer. Kotlin LinkedHashMap (100 entries, max 1000) as secondary dedup after JNI callback, before UI display.
5 — Multi-Account Support
How It Works

Subscription Filters

Single relay filter with Filter::pubkey(all_pubkey_bytes) monitors all logged-in accounts simultaneously. No per-account connections needed — one subscription covers everyone.

Keypair Storage

signing_keypairs: HashMap<String, FullKeypair> in NOTIFICATION_BRIDGE. Keyed by pubkey hex. Updated on cold start and account switch with ALL account keypairs.

Self-Suppression

If event author is one of our accounts AND all p-tags point to our own accounts, the notification is suppressed. Prevents self-mentions from triggering alerts. Matches desktop manager.rs:192-194.

Account Lifecycle

retrigger_notifications_if_active() called on AccountsAction::Switch and AccountsAction::Remove. Re-invokes set_notification_mode() with updated pubkey list.

SharedPreferences Migration (Android)
Old KeyNew KeyFormatFallback
active_pubkeyactive_pubkeysJSON array of hex stringsWrap old value as ["value"]
registered_pubkeyregistered_pubkeysJSON array of hex stringsWrap old value as ["value"]
6 — Feature Comparison

Notification System

Before
After
  • No notification support on any platform
  • Users must keep app open to see new activity
  • No background processing of relay events
  • No profile picture display for event senders
  • No notification settings in UI
  • Single-account only
  • Full notification pipeline via nostrdb: mentions, replies, reactions, reposts, DMs, zaps
  • Events ingested into nostrdb LMDB; notification system subscribes and polls — no redundant JSON parsing
  • Profile-enriched notifications via ndb.get_profile_by_pubkey() with localized titles/bodies
  • macOS profile picture attachments via UNNotificationAttachment
  • Settings UI with FCM/Native/Disabled radio group and permission badges
  • Multi-account: all logged-in accounts get notifications with self-suppression

Android Architecture

Before
After
  • MainActivity.java — plain Java activity
  • No Firebase integration
  • No Kotlin code
  • No foreground service
  • minSdk unspecified for notification types
  • MainActivity.kt — Kotlin with coroutines, notification controls, deep linking
  • Firebase BOM 32.7, FCM messaging, NIP-98 auth via notepush
  • 7 new Kotlin files (services, receivers, helpers)
  • NotificationsService foreground service with wake lock, WebSocket relay pool
  • minSdk 26 for foreground service type support

Platform API

Before
After
  • No cross-platform notification API
  • No NotificationMode concept
  • platform/mod.rs had no notification functions
  • NotificationMode enum: Fcm, Native, Disabled with index conversion for UI
  • Cross-platform API: enable/disable_notifications, request_permission, get_service_status, check_deep_link
  • cfg-gated implementations: Android via JNI, desktop via NotificationManager
  • Multi-account: set_notification_mode(mode, &pubkey_hexes, &relay_urls)
7 — Notification Flow Diagrams

Desktop Notification Flow

sequenceDiagram
  participant R as Relay
  participant RA as remote_api
  participant NDB as nostrdb (LMDB)
  participant NM as NotificationManager
  participant W as Worker Thread
  participant B as Backend

  R->>RA: RelayMessage Event
  RA->>NDB: ingest event
  Note over NM: poll_notifications() called each frame
  NM->>NDB: poll_for_notes(sub, 50)
  NDB-->>NM: Note keys
  NM->>NDB: get_note_by_key (Note API)
  NM->>NM: Kind check + p-tag match via ndb_helpers
  NM->>NM: Self-suppress check (all accounts)
  NM->>NDB: get_profile_by_pubkey
  NM->>NM: Format title and body
  NM->>W: mpsc send NotificationData
  W->>W: Dedup check via LRU
  W->>B: display notification
  Note over B: Linux: notify-rust
  Note over B: macOS: UNUserNotificationCenter
      

Android FCM Flow

sequenceDiagram
  participant U as User
  participant MA as MainActivity.kt
  participant NPC as NotepushClient
  participant NP as Notepush Server
  participant FCM as Firebase
  participant FMS as FirebaseMessagingService
  participant JNI as Rust JNI Bridge
  participant NH as NotificationHelper

  U->>MA: Enable FCM mode
  MA->>MA: Iterate all account pubkeys
  MA->>NPC: registerDevice per account (NIP-98 auth)
  NPC->>NP: POST register
  NP-->>NPC: OK

  Note over NP: Later, event arrives
  NP->>FCM: Push notification
  FCM->>FMS: onMessageReceived
  FMS->>JNI: nativeProcessNostrEvent
  JNI-->>FMS: Parsed event data
  FMS->>NH: showNotification
  NH->>U: System notification with deep link
      

Android Native WebSocket Flow

sequenceDiagram
  participant U as User
  participant MA as MainActivity.kt
  participant NS as NotificationsService
  participant JNI as Rust JNI Bridge
  participant RP as Relay Pool
  participant NDB as nostrdb (LMDB)
  participant NH as NotificationHelper

  U->>MA: Enable Native mode
  MA->>MA: Store active_pubkeys JSON
  MA->>NS: Start foreground service
  NS->>NS: Acquire wake lock
  NS->>JNI: nativeStartSubscriptions(pubkeysJson, relaysJson)
  JNI->>RP: Connect to relays
  JNI->>RP: Subscribe with all pubkey filters
  Note over JNI: Worker loop: drain → ingest → poll
  RP-->>JNI: Relay message (raw JSON)
  JNI->>NDB: process_event_with (ingest)
  Note over JNI: Sleep 200ms for LMDB write
  JNI->>NDB: poll_for_notes(sub, 50)
  NDB-->>JNI: Note via Note API
  JNI->>JNI: Self-suppress + ndb_helpers
  JNI->>NDB: get_profile_by_pubkey
  JNI-->>NS: onNostrEvent callback
  NS->>NH: showNotification
  NH->>U: System notification with deep link
      
8 — Android JNI Bridge
Why Globals Are Required

JNI extern "C" functions have fixed signatures determined by the Java Native Interface specification. They cannot receive Rust struct references as parameters. All shared state must be accessible via globals. The PR consolidates this into two minimal statics:

NOTIFICATION_BRIDGE

notedeck/src/platform/android.rs

  • fcm_token: RwLock<Option<String>>
  • signing_keypairs: RwLock<Option<HashMap<String, FullKeypair>>>
  • permission_pending: AtomicBool
  • permission_granted: AtomicBool
  • deep_link: RwLock<Option<DeepLinkInfo>>

SHARED_STATE

notedeck_chrome/src/notifications.rs

  • running: AtomicBool
  • generation: AtomicU32
  • connected_count: AtomicI32
  • thread_handle: Mutex<Option<JoinHandle>>
  • java_callback: Mutex<Option<JavaCallback>>
  • ndb: RwLock<Option<Ndb>> — nostrdb clone for worker thread
JNI FunctionDirectionPurpose
nativeOnFcmTokenRefreshedJava → RustStores FCM token for notepush registration
nativeProcessNostrEventJava → RustParses event JSON, creates notification data
nativeSignNip98AuthJava → RustSigns NIP-98 HTTP auth. Accepts pubkeyHex for per-account keypair lookup.
nativeStartSubscriptionsJava → RustSpawns worker thread with relay pool. Parses pubkey JSON array.
onNostrEventRust → JavaDelivers extracted event to Kotlin for notification display
onRelayStatusChangedRust → JavaUpdates connected relay count in foreground service
9 — Kotlin Service Layer
MainActivity.kt

Entry point for notification mode switching. enableFcmNotifications(json) iterates pubkey array, calls registerDevice() per account. enableNativeNotifications(json, relays) stores pubkeys and starts foreground service. Handles deep-link intents.

NotificationsService.kt

Foreground service for Native mode. Wake lock (10-min timeout, 8-min renewal). Creates 4 notification channels. Reads active_pubkeys from SharedPreferences with backward compat fallback. Returns START_STICKY.

NotepushClient.kt

HTTP client for notepush API. registerDevice(pubkey, token) and unregisterDevice(pubkey) with NIP-98 auth. Passes pubkeyHex to nativeSignNip98Auth() for per-account keypair selection.

NotificationHelper.kt

Rich notification construction. Kind-specific titles and channels. Profile image LruCache<String, Bitmap> (100 entries). Robohash fallback. Deep-link PendingIntent with event metadata. Group summaries.

FCM Service

NotedeckFirebaseMessagingService. On token refresh: reads registered_pubkeys JSON array (fallback to registered_pubkey), iterates re-registration for each account.

BootReceiver.kt

Restarts notification service after device reboot. Checks both active_pubkeys (new) and active_pubkey (legacy) to determine if service should start.

Channel IDNameImportanceEvent Kinds
CHANNEL_NOTIFICATIONSNotificationsHIGH (heads-up)Kind 1, 6, 7
CHANNEL_DMSDirect MessagesHIGHKind 4, 1059
CHANNEL_ZAPSZapsDEFAULTKind 9735
CHANNEL_SERVICEBackground ServiceLOW (no badge)Foreground service status
10 — Desktop Notification Manager
Manager (manager.rs)

Main integration point. poll_notifications() called once per frame from the app event loop. Creates an ndb subscription on start() for notification kinds.

  • Holds ndb_sub: Option<Subscription> + monitored_pubkeys: Vec<[u8; 32]>
  • Polls ndb.poll_for_notes(sub, 50) each frame
  • Fetches Note via ndb.get_note_by_key()
  • Finds target account via ndb_helpers::find_target_ptag()
  • Self-suppression: drops if note.pubkey() matches our accounts
  • Profile lookup: ndb_helpers::lookup_profile_ndb()
  • Zap amounts: ndb_helpers::extract_zap_amount_from_note()
  • Formats localized title/body, sends NotificationData to worker via mpsc
Worker Thread (worker.rs)

Background thread receiving pre-processed notification data.

  • Event loop with 1-second timeout on channel recv
  • Dedup check via record_event_if_new()
  • Image caching on macOS (NotificationImageCache)
  • Delegates to NotificationBackend::send_notification()
  • Checks running flag for graceful shutdown
macOS: UNUserNotificationCenter

Dynamic ObjC delegate class created at runtime for willPresentNotification. Uses OnceLock for hot-reload safety — reuses existing class if already registered. Completion handler returns presentation options bitmask (ALERT|SOUND|BANNER|LIST). Permission via requestAuthorizationWithOptions. App Nap disabled via OnceLock guard.

macOS: Image Attachments

UNNotificationAttachment moves the original file, so avatars are copied to temp first. Image cache uses SHA-256 hash of URL as filename. 7-day TTL, max 5MB per image, HTTPS-only validation (non-HTTPS URLs rejected with warning), 500 memory entries.

11 — Settings UI Integration
Android Path

Collects all account pubkeys inline: accounts.cache.accounts().map(|a| a.key.pubkey.hex()). Gets relay URLs from get_selected_account_relay_urls(). Calls platform::set_notification_mode(mode, &pubkey_hexes, &relay_urls). Handles mutual exclusivity, JNI calls, and SharedPreferences persistence.

Desktop Path

Uses selected account pubkey. Calls enable_notifications() or disable_notifications() on the NotificationManager. Updates settings.set_notifications_enabled() for persistence.

Auto-Enable on Startup

Chrome::auto_enable_notifications() called during new_with_apps() construction. On Android: reads persisted mode from SharedPreferences, collects all current account pubkeys, calls set_notification_mode() to restore services. Idempotent — safe to call on every launch. On desktop: checks notifications_enabled setting, creates NotificationManager if enabled.

12 — File Map
Rust — crates/notedeck/src/notifications/ (12 files, 2,638 lines)
notifications/
mod.rs +242 — Service orchestration, worker lifecycle
manager.rs +414 — ndb subscription, poll_notifications(), title/body formatting
ndb_helpers.rs +85 — nostrdb wrappers: p-tags, profiles, zap amounts
types.rs +157 — Core data structures
extraction.rs +239 — JSON relay message parsing (Android compat)
worker.rs +162 — Background notification display thread
backend.rs +98 — NotificationBackend trait
desktop.rs +129 — Linux notify-rust backend
macos.rs +514 — macOS UNUserNotificationCenter + OnceLock safety
image_cache.rs +430 — Profile picture disk cache (HTTPS-only, macOS)
profiles.rs +100 — npub decoding, mention resolution
bolt11.rs +68 — Zap amount extraction
Rust — Platform layer (3 files, 1,060 lines added)
platform/
mod.rs +238 — NotificationMode API stubs
desktop_notifications.rs +74 — Desktop settings persistence
android.rs +748 / -4 — Android JNI notification bridge (multi-keypair)
Rust — Chrome and other crates (modified, 1,058 lines)
notedeck_chrome/
src/notifications.rs +886 — Android JNI notification bridge
src/chrome.rs +73
src/lib.rs +3
notedeck_columns/
src/app.rs +69 — Lifecycle integration
src/nav.rs +9 / -7
src/ui/settings.rs +434 / -3 — Notification settings UI
notedeck/
src/app.rs +30 / -21 — Event loop integration
src/context.rs +7
src/account/accounts.rs +46
src/zaps/zap.rs +8 — Shared BOLT11 parser
Kotlin — Android (7 new files, 1,821 lines)
android/app/src/main/java/com/damus/notedeck/
MainActivity.kt +468
MainActivity.java -238
service/
NotificationsService.kt +408 — Foreground WebSocket service
NotificationHelper.kt +361 — Rich notification display
NotedeckFirebaseMessagingService.kt +239 — FCM handler
NotepushClient.kt +227 — HTTP client for notepush API
NotificationActionReceiver.kt +64 — Action intents
BootReceiver.kt +54 — Auto-start on boot
Build & Config (5 files)
Cargo.lock +127 / -273
Cargo.toml +5
crates/notedeck/Cargo.toml +10
crates/notedeck_chrome/Cargo.toml +10
android/app/build.gradle +32 / -5
android/build.gradle +2
AndroidManifest.xml +62
res/values/strings.xml +5
13 — Test Coverage
Before
After
  • No notification-related tests
  • BOLT11 parsing tested elsewhere (zaps module)
  • 194 lines of test code across 2 crates
  • BOLT11 parsing tests (unit tests for zap amount extraction)
  • Event extraction tests (relay message parsing, p-tag extraction)
  • Android JNI bridge tests (53 test lines)
  • Image cache tests (40 test lines)
Coverage Gaps
  • manager.rs (367 lines) — event filtering and title/body formatting has no tests
  • worker.rs (162 lines) — dedup logic and worker loop untested
  • macos.rs (488 lines) — no macOS backend tests (hard to unit test, but integration tests possible)
  • profiles.rs (100 lines) — profile caching and mention resolution untested
  • Settings UI persistence — no tests for mode switch roundtrip
  • No integration tests for end-to-end notification flow
14 — Code Review

Good

  • nostrdb integration. Events already ingested by the relay infrastructure — the notification system subscribes and polls instead of re-parsing JSON. Profile lookups via ndb.get_profile_by_pubkey() eliminate the old manual profile cache.
  • Clean trait boundary. NotificationBackend trait in backend.rs cleanly abstracts platform differences. Adding a new platform (Windows, iOS) requires implementing one trait — no changes to the pipeline.
  • Channel-based architecture. Using mpsc channel between the main event loop and worker thread is the right pattern — decouples hot path from potentially slow notification display.
  • Bounded LRU dedup. VecDeque + HashSet bounded dedup prevents unbounded memory growth. Much better than the original ConcurrentHashMap approach flagged by CodeRabbit.
  • BOLT11 code reuse. Shared parse_bolt11_msats() via ndb_helpers::extract_zap_amount_from_note() delegates to existing zaps/zap.rs instead of duplicating the lightning_invoice dependency.
  • Cfg-gated cleanly. Android and desktop paths are mutually exclusive via #[cfg(target_os = "android")]. No dead code on either platform.
  • Multi-account done right. Single relay subscription covers all accounts. Self-notification suppression matches desktop logic. Account lifecycle hooks keep services in sync.
  • Well-structured commits. 25 commits, each focused on a specific subsystem. Easy to review incrementally.

Bad

  • 8,343 lines in a single PR. Even with good commit structure, this is extremely large for review. The Android and desktop paths could have been separate PRs.
  • No CHANGELOG entry. A feature this significant needs a CHANGELOG entry.
  • Limited test coverage. Only 194 test lines for 8,000+ code lines (~2.3% ratio). The notification formatting logic in manager.rs is untested — this is where localization bugs would hide.
  • Firebase BOM 32.7 is dated. The PR deliberately defers upgrading to 34.x, but this creates tech debt from day one.
  • Wake lock with 10-minute timeout. While fixed from the original unbounded acquire, 10 minutes is still aggressive for a notification service that should be event-driven.

Ugly

  • 886-line notifications.rs in notedeck_chrome. This single file handles all Android JNI bridging. It will be painful to maintain as notification types or Android APIs evolve.
  • image_cache.rs can't reuse MediaCache. The PR adds a 430-line image cache because the existing MediaCache re-encodes to WebP, but macOS needs original format files. This is a fair constraint, but it's a significant surface area to maintain in parallel.
  • Unsafe Send/Sync on macOS delegate. The MacOSBackend contains unsafe Send/Sync implementations with comments explaining the safety invariants. Any future refactor could invalidate these invariants silently.
  • Multiple concurrent state management patterns. Arc<AtomicBool> for stop signals, RwLock<Option<WorkerHandle>> for lifecycle, Arc<RwLock<HashMap>> in image cache, plus JNI global statics. Each is individually justified but together they create a complex concurrency landscape.

Questions

  • Is the notepush server a damus.io hosted service? What happens if it goes down — do FCM notifications silently fail?
  • The NIP-98 auth signing happens on the client. Is the signing keypair the user's actual Nostr private key? What's the security model for exposing it to the JNI bridge?
  • Why is the NotificationsService foreground service using its own relay pool instead of sharing the app's existing relay connections?
  • What's the battery impact of the native WebSocket mode? Has it been profiled on different Android versions?
  • The BootReceiver auto-starts the notification service on boot. Is there a mechanism to prevent this from running if the user has uninstalled/disabled notifications?
15 — Decision Log
Decision
Channel-based worker architecture instead of direct notification dispatch in the event loop
Rationale
Reviewer feedback (kernelkind #3) explicitly requested channel-based communication rather than storing the manager in app state. The mpsc pattern decouples event processing (hot path) from notification display (potentially slow, involves disk I/O for profile pictures on macOS).
Alternatives considered
Direct dispatch from event loop (rejected: blocks event processing). Storing NotificationManager in AppContext (rejected: reviewer feedback).
Confidence
High sourced from PR description and review feedback
Decision
Dual Android notification modes (FCM + Native WebSocket) instead of FCM-only
Rationale
FCM requires Google Play Services, which some Nostr users intentionally avoid (de-Googled phones, GrapheneOS). The native mode provides a Google-free alternative via direct WebSocket relay connections in a foreground service.
Alternatives considered
FCM-only (rejected: excludes de-Googled users). UnifiedPush (not mentioned, could be future work).
Confidence
High sourced from PR description
Decision
Multi-account notifications via single relay subscription with all pubkeys
Rationale
Nostr relay filters natively accept arrays of pubkeys. A single Filter::pubkey(all_pubkey_bytes) monitors all accounts simultaneously without per-account connections. Self-notification suppression checks if the event author is one of our accounts and all p-tags point only to our accounts.
Alternatives considered
Per-account relay connections (rejected: wasteful, doesn't scale). Single-account only (rejected: desktop already handles multi-account).
Confidence
High sourced from implementation
Decision
Replace serde_json parsing with nostrdb Note API for both desktop and Android notification processing
Rationale
Events are already ingested into nostrdb's LMDB store by the app's relay infrastructure (remote_api.rs). Re-parsing the same JSON in the notification system is redundant. The new approach creates an ndb subscription for notification kinds and polls it each frame (desktop) or in a drain-ingest-poll loop (Android). Profile lookups use ndb.get_profile_by_pubkey() instead of maintaining a separate profile cache. ndb_helpers.rs provides thin wrappers delegating to existing utilities (get_p_tags, get_display_name, parse_bolt11_msats).
Alternatives considered
Keeping JSON parsing (rejected: duplicates work already done by nostrdb ingestion). Using ndb only for profiles but keeping JSON for events (rejected: half-measure that still requires serde_json).
Confidence
High implemented and tested
Decision
Separate image cache instead of reusing existing MediaCache
Rationale
macOS UNNotificationAttachment requires local files in their original image format. MediaCache re-encodes everything to WebP, which the notification system can't use. Documented in image_cache.rs:6-19.
Alternatives considered
Extending MediaCache with a "preserve original" mode (rejected: would complicate MediaCache's interface for a single consumer).
Confidence
Medium inferred from code comment and review response
Decision
Java-to-Kotlin conversion of MainActivity as a prerequisite
Rationale
FCM and notification services use Kotlin coroutines and suspend functions. Keeping MainActivity in Java would require awkward interop. The conversion is mechanical with no behavior changes.
Alternatives considered
Keeping Java and using callback-based APIs (rejected: more boilerplate, harder to maintain).
Confidence
Medium inferred from commit message (96ec45e5)
Decision
Defer Firebase BOM 34.x and Kotlin plugin 2.x upgrades
Rationale
BOM 34.x requires KTX migration, and Kotlin 2.x is a major version bump. Both carry risk that's orthogonal to the notification feature. Marked as "too risky for this PR" in review response.
Alternatives considered
Upgrading in this PR (rejected: increases blast radius of an already large PR).
Confidence
Medium inferred from CodeRabbit review response
16 — Re-entry Context
Key Invariants
  • set_signing_keypairs() must be called after accounts are loaded — otherwise NIP-98 auth fails on cold start. On Android, select_account_internal() populates all keypairs for all accounts.
  • On Android, set_ndb() must be called during auto_enable_notifications() — the worker thread needs an Ndb clone to ingest events and poll subscriptions.
  • Desktop poll_notifications() polls max 50 notes per frame — high-volume relays may queue more, but the subscription retains unread notes until the next poll cycle.
  • Android worker loop sleeps 200ms between ingest and poll to allow nostrdb LMDB writes to complete. Reducing this may cause missed events.
  • The mpsc channel between the main event loop and worker must not block — if the worker dies, the channel will fill and poll_notifications will start dropping notifications silently.
  • Android NotificationsService foreground service must display a persistent notification within 5 seconds of startForeground call or Android will kill it (OS constraint, not code constraint).
  • On macOS, the bundle ID must be set correctly for UNUserNotificationCenter to deliver notifications. Development builds with mismatched bundle IDs will silently drop all notifications.
  • When accounts are added/removed, retrigger_notifications_if_active() must be called to update relay subscriptions with the new pubkey set.
Non-obvious Coupling
  • accounts.rs propagates signing keypairs to the JNI bridge via set_signing_keypairs() — changing account switching logic requires updating notification re-registration.
  • try_process_events_core() in notedeck/src/app.rs calls poll_notifications() each frame — depends on nostrdb having already ingested events from remote_api.rs.
  • ndb_helpers.rs delegates to crate::note::get_p_tags(), crate::name::get_display_name(), and crate::zaps::parse_bolt11_msats() — changes to these shared utilities affect notification formatting.
  • The SettingsHandler persistence and platform/mod.rs API layer are coupled via NotificationMode — adding a new mode requires changes in both.
  • Android deep linking connects NotificationHelper intent creation to MainActivity intent parsing to chrome/notifications.rs JNI bridge to notedeck_columns/nav.rs navigation. Four files must agree on the deep link format.
  • SharedPreferences keys (active_pubkeys, registered_pubkeys) are shared between Kotlin code and Rust JNI — renaming or restructuring requires both sides to agree.
Gotchas
  • The macOS image cache preserves original image format on disk. If a profile URL returns a format that macOS doesn't support for notification attachments, the notification will display without an image (no crash, but no picture).
  • onMessageReceived gates on notification_mode == FCM. If the user disables FCM but the server hasn't been told yet, incoming FCM messages are silently dropped — correct behavior, but can confuse debugging.
  • The VecDeque + HashSet dedup in the worker has a fixed capacity. Under extremely high event volume, eviction could cause duplicate notifications for events that were evicted and then re-received.
  • Android BootReceiver checks SharedPreferences for the notification mode. If the app data is cleared but the receiver is still registered, it will attempt to start the service in the default (disabled) mode — effectively a no-op, but it still runs briefly.
  • nativeStartSubscriptions parses the first argument as JSON array with bare-string fallback — if the JSON format changes, both Kotlin and Rust must be updated in lockstep.
  • Android worker loop ingests raw relay JSON into nostrdb then polls for notes. The 200ms sleep between ingest and poll is calibrated to nostrdb's LMDB write latency — on slow storage (e.g. old eMMC), events may be missed if the sleep is reduced.
Don't Forget
  • Add CHANGELOG entry before merging
  • Update README or docs with notification feature documentation
  • Remove WIP status from PR title when ready
  • Complete remaining test plan items: Android build testing, macOS notification delivery, Linux notification delivery
  • Consider adding integration tests for the notification pipeline before merging
  • Profile battery usage of native WebSocket mode on Android before recommending it as default
  • Firebase BOM 34.x and Kotlin 2.x upgrades as follow-up PR
  • Test multi-account scenarios: 2+ accounts with Native mode, FCM mode, account add/remove while notifications active
Thread Safety Design Notes
  • NOTIFICATION_BRIDGE uses RwLock (std::sync, not tokio) because accessed from JNI threads, not the render loop.
  • JavaCallback is marked unsafe impl Send + Syncjni::JavaVM is thread-safe per JNI spec, and jni::objects::GlobalRef is valid across threads per JNI §5.1.1.
  • Generation counter (AtomicU32) prevents old workers from processing events after restart — worker exits loop if its generation doesn't match the current value.
  • Desktop PERMISSION_GRANTED / DESKTOP_MODE use AtomicBool / AtomicU8 with SeqCst ordering for simplicity in single-writer scenarios.
  • Ndb is Clone (Arc-backed)set_ndb() stores a clone in SharedState.ndb for the Android worker thread. The clone shares the same LMDB environment, so writes from one clone are immediately visible to the other after the LMDB transaction commits.
  • RelayPool is non-Send — created and owned entirely within the worker thread. Never crosses thread boundaries.
Generated by visual-explainer · PR damus-io/notedeck#1296