Skip to content

Push Notifications

LucidPal uses two distinct push notification systems:

  1. Local notifications — trial lifecycle and re-engagement prompts, scheduled on-device via UNUserNotificationCenter and fired by TrialNotificationScheduler.
  2. APNs silent pushcontent-available: 1 payloads sent by the sync API to wake the app and trigger an incremental sync pull when another device pushes data. No visible banner — purely a background wake signal.

Architecture

TrialNotificationScheduler (singleton, @MainActor)
    ├── scheduleIfNeeded(appTrialManager:)   ← called on scene active + trial start
    ├── scheduleDormantCalendarIfNeeded()    ← called on onboarding completion
    ├── cancelAll()                          ← called on Pro upgrade
    └── scheduleForTesting(type:afterSeconds:) ← DevPushTestView only

Notification taps are handled in AppDelegate.userNotificationCenter(_:didReceive:). Each type either:

  • Sets UserDefaultsKeys.siriPendingQuery → consumed by consumePendingSiriQuery() in LucidPalApp → chat pre-seeded on next foreground
  • Sets UserDefaultsKeys.pendingTrialExpiredSheet → consumed by consumePendingTrialExpiredSheet()TrialExpiredSheet presented as a modal

Four Notification Tracks

1. Day 2 — First Nudge (lucidpal.trial.nudge)

Fires: 2 days after trial start
Audience: users who haven't opened the app since installing
Psychology: Fogg Prompt + Reciprocity. Motivation is low at day 2 — the notification must reactivate curiosity, not demand. Passive interruption level.

FieldValue
Title"Your AI is still waiting"
Body"You haven't asked LucidPal anything yet. Try asking about your schedule, writing a message, or anything else."
Tap →Chat opens, pre-seeded: "What can you help me with today?"

The promise: "your AI is waiting" implies the user can act immediately with zero friction.
The delivery: AI responds before the user types a single character.


2. Day 5 — Trial Expiring (lucidpal.trial.expiring)

Fires: 5 days after trial start (2 days remaining)
Psychology: Loss Aversion (Kahneman 2×). The user has had cloud AI for 5 days. Losing it in 2 days must feel painful. Copy leads with loss, not upgrade pitch. Price never mentioned in the notification.

FieldValue
Title"2 days of AI left"
Body"Your cloud AI trial ends in 2 days. There are things you haven't tried yet — voice notes, calendar prep, smart search."
Tap →Chat opens, pre-seeded: "Show me everything I can do with LucidPal AI"

The promise: "things you haven't tried yet" implies the app will show specific features, not a paywall.
The delivery: AI demos capabilities live. Upgrade CTA appears naturally at response end, never as a gate.


3. Day 7 — Trial Expired (lucidpal.trial.expired)

Fires: 7 days after trial start
Psychology: Transparent off-ramp. The worst possible experience is landing on the home screen with no explanation. This notification must deliver the clearest possible status breakdown.

FieldValue
Title"Your cloud AI trial ended"
Body"On-device AI is still yours, free forever. Cloud AI and integrations are available on Pro."
Tap →TrialExpiredSheet presented immediately

The promise: "your cloud AI just turned off" is a factual statement. User expects exact status, not a generic screen.
The delivery: TrialExpiredSheet shows three rows — Cloud AI (red, Turned off), On-device AI (green, Free forever), Live Notes (purple, Still available). One orange upgrade CTA. One dismiss link. Nothing else.


4. Day 30 — Dormant Re-engagement (lucidpal.reengagement.calendar)

Fires: 30 days after onboarding completion
Audience: free users who never engaged after the trial
Psychology: Specificity converts (Nielsen Norman Group). "Stay productive" = ignored. "3 events this week" = tapped. Body is built at schedule time using real EventKit data if access was granted.

FieldValue
Title"LucidPal can prep you for this week"
Body (calendar access)"You have N events this week. I can help you prepare for all of them."
Body (no calendar access)"Ask about your schedule, prep for meetings, set reminders — all from one place."
Tap →Chat opens, pre-seeded: "Help me prepare for my upcoming calendar events this week"

The promise: "I can prep you for all of them" implies the app immediately starts prep.
The delivery: AI fetches events and starts preparing. If calendar not granted, AI explains what it can do and offers to enable it.


Notification Scheduling

swift
// On app active + after trial start
await TrialNotificationScheduler.shared.scheduleIfNeeded(appTrialManager: appTrialManager)

// On onboarding completion
await TrialNotificationScheduler.shared.scheduleDormantCalendarIfNeeded()

// On Pro upgrade — cancel all pending trial notifications
TrialNotificationScheduler.shared.cancelAll()

scheduleIfNeeded is idempotent — it checks UNUserNotificationCenter.pendingNotificationRequests() before scheduling to avoid duplicates. All production notifications use UNCalendarNotificationTrigger.


User taps notification banner

AppDelegate.userNotificationCenter(_:didReceive:)

Sets UserDefaults key (siriPendingQuery OR pendingTrialExpiredSheet)

App becomes active → LucidPalApp.consumePending*()

Chat pre-seeded  OR  TrialExpiredSheet presented

AppDelegate tap handler cases (in userNotificationCenter(_:didReceive:)):

swift
} else if identifier.hasPrefix("lucidpal.trial.nudge") {
    UserDefaults.standard.set("What can you help me with today?", forKey: UserDefaultsKeys.siriPendingQuery)
} else if identifier.hasPrefix("lucidpal.trial.expiring") {
    UserDefaults.standard.set("Show me everything I can do with LucidPal AI", forKey: UserDefaultsKeys.siriPendingQuery)
} else if identifier.hasPrefix("lucidpal.trial.expired") {
    UserDefaults.standard.set(true, forKey: UserDefaultsKeys.pendingTrialExpiredSheet)
} else if identifier.hasPrefix("lucidpal.reengagement.calendar") {
    UserDefaults.standard.set("Help me prepare for my upcoming calendar events this week", forKey: UserDefaultsKeys.siriPendingQuery)
}

TrialExpiredSheet

Views/TrialExpiredSheet.swift — presented as a .sheet from RootView when pendingTrialExpiredSheet is true.

Callbacks:

  • onUpgrade: () -> Void — opens upgrade sheet (Pro paywall)
  • onDismiss: () -> Void — closes the sheet, user continues with on-device AI

Testing (Dev + TestFlight)

Access via the Dev Tools drawer (left-edge handle → "Test Deep Links").

DevPushTestView (Views/DevPushTestView.swift) — opened as a sheet from DevTierDrawer. Visible in DEBUG and TestFlight builds only (BuildEnvironment.showDeveloperSection).

Each of the 4 rows displays:

LabelColorContent
ConceptBlueThe psychological mechanic driving this notification
PromiseOrangeWhat the notification copy implies the user will see
ExpectationPurpleWhat the app must deliver immediately when tapped

How to test:

  1. Open dev drawer → "Test Deep Links"
  2. Tap "Fire in 3 seconds" on any row
  3. Immediately background the app (Home gesture or swipe up)
  4. Notification fires as a banner — tap it
  5. Verify the deep link delivers on the promise

The fire button disables after first tap (green checkmark). Re-open the sheet to reset.

swift
// Called from DevPushTestView
await TrialNotificationScheduler.shared.scheduleForTesting(type: .nudge, afterSeconds: 3)

scheduleForTesting uses UNTimeIntervalNotificationTrigger (not calendar-based) for reliable sub-minute delivery in development.



APNs Silent Sync Push

Overview

When a device pushes a sync batch (PUT /sync/batch), the API immediately sends a content-available: 1 silent push to all other registered devices for that user. The receiving app wakes in the background, pulls the delta, and becomes up-to-date within seconds — no user action required.

Flow

Device A edits a note

SyncManager.syncNowDebounced() fires after 2 s

PUT /sync/batch → lucidpal-api

ApnsService.sendSilentSync(tokens)  [via c.executionCtx.waitUntil]

APNs HTTP/2 → content-available: 1 → Device B

AppDelegate.didReceiveRemoteNotification(fetchCompletionHandler:)

AppDelegate.onSilentPush() → SyncManager.syncNow()

Pull delta → note appears on Device B

Device Token Registration

iOS registers for remote notifications at launch (UIApplication.shared.registerForRemoteNotifications()). On success, AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken fires:

swift
AppDelegate.onDeviceTokenRegistered = { token in
    Task { await syncManager.registerDeviceToken(token) }
}

SyncManager.registerDeviceToken(_:) calls PUT /sync/device-token with the hex token string and environment (sandbox in DEBUG builds, production in release). Tokens are stored in the device_push_tokens table keyed by (user_id, token).

API Worker Side

ApnsService (in src/services/apns.service.ts) generates an ES256 JWT signed with the .p8 key and fires POST https://api[.sandbox].push.apple.com/3/device/{token} with:

json
{ "aps": { "content-available": 1 } }

The push fires via waitUntil — non-blocking, does not delay the PUT /sync/batch response. Failed tokens (expired, invalid) are ignored individually; other tokens still receive the push.

Required Worker Secrets

APNS_KEY_ID, APNS_TEAM_ID, APNS_PRIVATE_KEY_BASE64, APNS_BUNDLE_ID — see secrets.md. All four are optional; omitting them disables silent push but does not break sync (background refresh and foreground trigger still work).

Key Files

FileRole
Services/SyncManager.swiftregisterDeviceToken, unregisterDeviceToken
App/AppDelegate.swiftdidRegisterForRemoteNotificationsWithDeviceToken, didReceiveRemoteNotification
src/services/apns.service.tsES256 JWT generation + APNs HTTP/2 push
src/repositories/device-push-token.repository.tsdevice_push_tokens table CRUD
src/routes/sync.tsPUT /sync/device-token, DELETE /sync/device-token

Local Notification Key Files

FileRole
Services/TrialNotificationScheduler.swiftSchedules, deduplicates, and cancels all 4 tracks
Views/TrialExpiredSheet.swiftModal for trial-expired tap — status breakdown + upgrade CTA
Views/DevPushTestView.swiftDev/TF test panel — concept/promise/expectation per notification
Views/DevTierDrawer.swiftHouses the "Test Deep Links" entry point
App/AppDelegate.swiftLocal notification tap handler + remote notification handler
App/LucidPalApp.swiftConsumes pending flags on scene active
ViewModels/UserDefaultsKeys.swiftpendingTrialExpiredSheet key

Rules

  • Never send a push notification that asks for money. The notification delivers value (briefing, status, feature reveal). The upgrade prompt lives inside the app, after the user taps.
  • Every notification must deliver on its exact promise within 1 second of the app foregrounding. If the tap lands on a generic screen, the notification series is dead — users stop tapping.
  • scheduleDormantCalendarIfNeeded must be called only once (idempotent via pending-ID check). Do not call it on every app open.
  • Cancel all trial notifications immediately on Pro upgrade via cancelAll(). A trial-expiry notification firing after upgrade is brand damage.

Internal — not for distribution