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.
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
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.
FCM: Firebase messages routed through notepush server with NIP-98 auth. Native: Foreground service with direct WebSocket relay connections — no Google dependency.
Linux uses notify-rust (libnotify). macOS uses UNUserNotificationCenter with objc2 bindings, supporting profile picture attachments via a dedicated image cache.
nostrdb LMDB
max 50/frame
p-tag match
check
+ format title
10K LRU
notification
- 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)
- Desktop: O(1)
HashSet<String>+VecDequefor 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.
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.
| Old Key | New Key | Format | Fallback |
|---|---|---|---|
active_pubkey | active_pubkeys | JSON array of hex strings | Wrap old value as ["value"] |
registered_pubkey | registered_pubkeys | JSON array of hex strings | Wrap old value as ["value"] |
Notification System
- 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
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)
NotificationsServiceforeground service with wake lock, WebSocket relay pool- minSdk 26 for foreground service type support
Platform API
- No cross-platform notification API
- No
NotificationModeconcept platform/mod.rshad no notification functions
NotificationModeenum: 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)
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
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: AtomicBoolpermission_granted: AtomicBooldeep_link: RwLock<Option<DeepLinkInfo>>
SHARED_STATE
notedeck_chrome/src/notifications.rs
running: AtomicBoolgeneration: AtomicU32connected_count: AtomicI32thread_handle: Mutex<Option<JoinHandle>>java_callback: Mutex<Option<JavaCallback>>ndb: RwLock<Option<Ndb>>— nostrdb clone for worker thread
| JNI Function | Direction | Purpose |
|---|---|---|
nativeOnFcmTokenRefreshed | Java → Rust | Stores FCM token for notepush registration |
nativeProcessNostrEvent | Java → Rust | Parses event JSON, creates notification data |
nativeSignNip98Auth | Java → Rust | Signs NIP-98 HTTP auth. Accepts pubkeyHex for per-account keypair lookup. |
nativeStartSubscriptions | Java → Rust | Spawns worker thread with relay pool. Parses pubkey JSON array. |
onNostrEvent | Rust → Java | Delivers extracted event to Kotlin for notification display |
onRelayStatusChanged | Rust → Java | Updates connected relay count in foreground service |
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.
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.
HTTP client for notepush API. registerDevice(pubkey, token) and unregisterDevice(pubkey) with NIP-98 auth. Passes pubkeyHex to nativeSignNip98Auth() for per-account keypair selection.
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.
NotedeckFirebaseMessagingService. On token refresh: reads registered_pubkeys JSON array (fallback to registered_pubkey), iterates re-registration for each account.
Restarts notification service after device reboot. Checks both active_pubkeys (new) and active_pubkey (legacy) to determine if service should start.
| Channel ID | Name | Importance | Event Kinds |
|---|---|---|---|
CHANNEL_NOTIFICATIONS | Notifications | HIGH (heads-up) | Kind 1, 6, 7 |
CHANNEL_DMS | Direct Messages | HIGH | Kind 4, 1059 |
CHANNEL_ZAPS | Zaps | DEFAULT | Kind 9735 |
CHANNEL_SERVICE | Background Service | LOW (no badge) | Foreground service status |
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
NotificationDatato worker via mpsc
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
runningflag for graceful shutdown
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.
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.
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.
Uses selected account pubkey. Calls enable_notifications() or disable_notifications() on the NotificationManager. Updates settings.set_notifications_enabled() for persistence.
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.
Rust — crates/notedeck/src/notifications/ (12 files, 2,638 lines)
Rust — Platform layer (3 files, 1,060 lines added)
Rust — Chrome and other crates (modified, 1,058 lines)
Kotlin — Android (7 new files, 1,821 lines)
Build & Config (5 files)
- 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)
manager.rs(367 lines) — event filtering and title/body formatting has no testsworker.rs(162 lines) — dedup logic and worker loop untestedmacos.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
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.
NotificationBackendtrait inbackend.rscleanly 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+HashSetbounded dedup prevents unbounded memory growth. Much better than the originalConcurrentHashMapapproach flagged by CodeRabbit. - BOLT11 code reuse. Shared
parse_bolt11_msats()viandb_helpers::extract_zap_amount_from_note()delegates to existingzaps/zap.rsinstead of duplicating thelightning_invoicedependency. - 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.rsis 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.rsin 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
MediaCachere-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
MacOSBackendcontains unsafeSend/Syncimplementations 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
notepushserver 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
NotificationsServiceforeground 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
BootReceiverauto-starts the notification service on boot. Is there a mechanism to prevent this from running if the user has uninstalled/disabled notifications?
- 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 usendb.get_profile_by_pubkey()instead of maintaining a separate profile cache.ndb_helpers.rsprovides 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
UNNotificationAttachmentrequires local files in their original image format. MediaCache re-encodes everything to WebP, which the notification system can't use. Documented inimage_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
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 duringauto_enable_notifications()— the worker thread needs anNdbclone 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_notificationswill start dropping notifications silently. - Android
NotificationsServiceforeground service must display a persistent notification within 5 seconds ofstartForegroundcall or Android will kill it (OS constraint, not code constraint). - On macOS, the bundle ID must be set correctly for
UNUserNotificationCenterto 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.rspropagates signing keypairs to the JNI bridge viaset_signing_keypairs()— changing account switching logic requires updating notification re-registration.try_process_events_core()innotedeck/src/app.rscallspoll_notifications()each frame — depends on nostrdb having already ingested events fromremote_api.rs.ndb_helpers.rsdelegates tocrate::note::get_p_tags(),crate::name::get_display_name(), andcrate::zaps::parse_bolt11_msats()— changes to these shared utilities affect notification formatting.- The
SettingsHandlerpersistence andplatform/mod.rsAPI layer are coupled viaNotificationMode— adding a new mode requires changes in both. - Android deep linking connects
NotificationHelperintent creation toMainActivityintent parsing tochrome/notifications.rsJNI bridge tonotedeck_columns/nav.rsnavigation. 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).
onMessageReceivedgates onnotification_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 + HashSetdedup 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
BootReceiverchecksSharedPreferencesfor 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. nativeStartSubscriptionsparses 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 + Sync—jni::JavaVMis thread-safe per JNI spec, andjni::objects::GlobalRefis 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/AtomicU8withSeqCstordering for simplicity in single-writer scenarios. - Ndb is Clone (Arc-backed) —
set_ndb()stores a clone inSharedState.ndbfor 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.