12 KiB
TimeSafari — Native-First Notification System
📝 SANITY CHECK IMPROVEMENTS APPLIED: This document has been updated to clarify current background fetch infrastructure status and iOS implementation completeness.
Status: Ready for implementation
Date: 2025-09-07
Author: Matthew Raymer
Executive Summary
Ship a single, Native-First notification system: OS-scheduled background prefetch at T–lead + pre-armed local notifications. Web-push is retired.
What we deliver
- Closed-app delivery: Pre-armed locals fire even if the app is closed.
- Freshness: One prefetch attempt per slot at T–lead; ETag/TTL controls; skip when stale.
- Android precision: Exact alarms with permission; windowed fallback (±10m) otherwise.
- Resilience: Re-arm after reboot/time-change (Android receivers; iOS on next wake/silent push).
- Cross-platform: Same TS API (iOS/Android/Electron). Electron is best-effort while running.
Success signals
- High delivery reliability, minute-precision on Android with permission.
- Prefetch budget hit rate at T–lead; zero stale deliveries beyond TTL.
Strategic Plan
Goal
Deliver 1..M daily notifications with OS background prefetch at T–lead (see Glossary) and rolling-window safety (see Glossary) so messages display with fresh content even when the app is closed.
Tenets
- Reliability first: OS delivers once scheduled; no JS at delivery time.
- Freshness with guardrails: Prefetch at T–lead (see Glossary); enforce TTL-at-fire (see Glossary); ETag-aware.
- Single system: One TS API; native adapters swap under the hood.
- Platform honesty: Android exactness via permission; iOS best-effort budget.
- No delivery-time network: Local notifications display pre-rendered content only; never fetch at delivery.
Architecture (high level)
App (Vue/TS) → Orchestrator (policy) → Native Adapters:
- SchedulerNative — AlarmManager (Android) / UNUserNotificationCenter (iOS)
- BackgroundPrefetchNative — WorkManager (Android) / BGTaskScheduler (+ silent push) (iOS)
- DataStore — SQLite
Storage: Current: SharedPreferences (Android) / UserDefaults (iOS) + in-memory cache (see Glossary → Tiered Storage). Planned: one shared SQLite file (see Glossary → Shared DB); the app owns schema/migrations; the plugin opens the same path with WAL (see Glossary → WAL); background writes are short & serialized. (Keep the "(Planned)" label until Phase 1 ships.)
SQLite Ownership & Concurrency (Planned)
- One DB file: The plugin will open the same path the app uses (no second DB).
- Migrations owned by app: The app executes schema migrations and bumps
PRAGMA user_version
(see Glossary → PRAGMA user_version). The plugin never migrates; it asserts the expected version. - WAL mode: Open DB with
journal_mode=WAL
,synchronous=NORMAL
,busy_timeout=5000
,foreign_keys=ON
. WAL (see Glossary → WAL) allows foreground reads while a background job commits quickly. - Single-writer discipline: Background jobs write in short transactions (UPSERT per slot), then return.
- Encryption (optional): If using SQLCipher, the same key is used by both app and plugin. Do not mix encrypted and unencrypted openings.
Note: Currently using SharedPreferences (Android) / UserDefaults (iOS) with in-memory cache. See Implementation Roadmap → Phase 1.1 for migration steps.
Scheduling & T–lead (Planned)
- Arm rolling window (see Glossary → Rolling window). (Planned)
- Exactly one online-first fetch at T–lead (see Glossary → T–lead) with 12s timeout; ETag/304 respected. (Planned)
- If fresh and within TTL-at-fire (see Glossary → TTL), (re)arm; otherwise keep prior content. (Planned)
- If the OS skips the wake, the pre-armed local still fires from cache. (Planned)
Note: Current implementation has basic scheduling and WorkManager infrastructure (Android) but lacks T–lead prefetch logic, rolling window logic, and iOS background tasks. See Implementation Roadmap → Phase 1-2.
Policies (Mixed Implementation)
- TTL-at-fire (see Glossary → TTL): Before arming for T, if
(T − fetchedAt) > ttlSeconds
→ skip. (Planned) - Android exactness (see Glossary → Exact alarm): (Partial) — permission flow exists; finalize ±10m window & deep-link to settings.
- Reboot/time change: (Partial) — Android receivers partially present; iOS via next wake/silent push.
- No delivery-time network (see Glossary → No delivery-time network): Local notifications display pre-rendered content only; never fetch at delivery. (Implemented)
Implementation Guide
1) Interfaces (TS stable)
- SchedulerNative:
scheduleExact({slotId, whenMs, title, body, extra})
,scheduleWindow(..., windowLenMs)
,cancelBySlot
,rescheduleAll
,capabilities()
- BackgroundPrefetchNative:
schedulePrefetch(slotId, atMs)
,cancelPrefetch(slotId)
- DataStore: SQLite adapters (notif_contents, notif_deliveries, notif_config)
- Public API:
configure
,requestPermissions
,runFullPipelineNow
,reschedule
,getState
DB Path & Adapter Configuration (Planned)
-
Configure option:
dbPath: string
(absolute path or platform alias) will be passed from JS to the plugin duringconfigure()
. -
Shared tables:
notif_contents(slot_id, payload_json, fetched_at, etag, …)
notif_deliveries(slot_id, fire_at, delivered_at, status, error_code, …)
notif_config(k, v)
-
Open settings:
journal_mode=WAL
synchronous=NORMAL
busy_timeout=5000
foreign_keys=ON
Note: Currently using SharedPreferences/UserDefaults (see Glossary → Tiered Storage). Database configuration is planned for Phase 1.
See Implementation Roadmap → Phase 1.1 for migration steps and schema.
Type (TS) extension (Planned)
export type ConfigureOptions = {
// …existing fields…
dbPath: string; // shared DB file the plugin will open
storage: 'shared'; // canonical value; plugin-owned DB is not used
};
Note: Current NotificationOptions
interface exists but lacks dbPath
configuration. This will be added in Phase 1.
Plugin side (pseudo)
// Android open
val db = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READWRITE)
db.execSQL("PRAGMA journal_mode=WAL")
db.execSQL("PRAGMA synchronous=NORMAL")
db.execSQL("PRAGMA foreign_keys=ON")
db.execSQL("PRAGMA busy_timeout=5000")
// Verify schema version
val uv = rawQuery("PRAGMA user_version").use { it.moveToFirst(); it.getInt(0) }
require(uv >= MIN_EXPECTED_VERSION) { "Schema version too old" }
// iOS open (FMDB / SQLite3)
// Set WAL via PRAGMA after open; check user_version the same way.
2) Templating & Arming
- Render
title/body
before scheduling; pass via SchedulerNative. - Route all arming through SchedulerNative to centralize Android exact/window semantics.
3) T–lead (single attempt)
T–lead governs prefetch, not arming. We arm one-shot locals as part of the rolling window so closed-app delivery is guaranteed. At T–lead = T − prefetchLeadMinutes, the native background job attempts one 12s ETag-aware fetch. If fresh content arrives and will not violate TTL-at-fire, we (re)arm the upcoming slot; if the OS skips the wake, the pre-armed local still fires with cached content.
- Compute T–lead =
whenMs - prefetchLeadMinutes*60_000
. BackgroundPrefetchNative.schedulePrefetch(slotId, atMs=T–lead)
.- On wake: ETag fetch (timeout 12s), persist, optionally cancel & re-arm if within TTL.
- Never fetch at delivery time.
4) TTL-at-fire
TTL-at-fire: Before arming for time T, compute T − fetchedAt
. If that exceeds ttlSeconds
, do not arm (skip). This prevents posting stale notifications when the app has been closed for a long time.
if (whenMs - fetchedAt) > ttlSeconds*1000 → skip
5) Android specifics
- Request
SCHEDULE_EXACT_ALARM
; deep-link if denied; fallback tosetWindow(start,len)
(±10m). - Receivers:
BOOT_COMPLETED
,TIMEZONE_CHANGED
,TIME_SET
→ recompute & re-arm for next 24h and schedule T–lead prefetch.
6) iOS specifics
BGTaskScheduler
for T–lead prefetch (best-effort). Optional silent push nudge.- Locals:
UNCalendarNotificationTrigger
(one-shots); no NSE mutation for locals.
7) Network & Timeouts
- Content fetch: 12s timeout; single attempt at T–lead; ETag/304 respected.
- ACK/Error: 8s timeout, fire-and-forget.
8) Electron
- Notifications while app is running; recommend Start-on-Login. No true background scheduling when fully closed.
9) Telemetry
- Record
scheduled|shown|error
; ACK deliveries (8s timeout); include slot/times/TZ/app version.
Capability Matrix
Capability | Android (Native) | iOS (Native) | Electron | Web |
---|---|---|---|---|
Multi-daily locals (closed app) | ✅ | ✅ | ✅ (app running) | — |
Prefetch at T–lead (app closed) | ✅ WorkManager | ⚠️ BGTask (best-effort) | ✅ (app running) | — |
Re-arm after reboot/time-change | ✅ Receivers | ⚠️ On next wake/silent push | ✅ Start-on-Login | — |
Minute-precision alarms | ✅ with exact permission | ❌ not guaranteed | ✅ timer best-effort | — |
Delivery-time mutation for locals | ❌ | ❌ | — | — |
ETag/TTL enforcement | ✅ | ✅ | ✅ | — |
Rolling-window safety | ✅ | ✅ | ✅ | — |
Acceptance Criteria
Core
- Closed-app delivery: Armed locals fire at T with last rendered content. No delivery-time network.
- T–lead prefetch: Single background attempt at T–lead; if skipped, delivery still occurs from cache.
- TTL-at-fire: No armed local violates TTL at T.
Android
- Exact permission path: With
SCHEDULE_EXACT_ALARM
→ within ±1m; else ±10m window. - Reboot recovery: After reboot, receivers re-arm next 24h and schedule T–lead prefetch.
- TZ/DST change: Recompute & re-arm; future slots align to new wall-clock.
iOS
- BGTask budget respected: Prefetch often runs but may be skipped; delivery still occurs via rolling window.
- Force-quit caveat: No background execution after user terminate; delivery still occurs if pre-armed.
Electron
- Running-app rule: Delivery only while app runs; with Start-on-Login, after reboot the orchestrator re-arms and subsequent slots deliver.
Network
- Content fetch timeout 12s; ACK/Error 8s; no retries inside lead; ETag honored.
Observability
- Log/telemetry for
scheduled|shown|error
; ACK payload includes slot, times, device TZ, app version.
DB Sharing (Planned)
- Shared DB visibility: A background prefetch writes
notif_contents
; the foreground UI immediately reads the same row. - WAL overlap: With the app reading while the plugin commits, no user-visible blocking occurs.
- Version safety: If
user_version
is behind, the plugin emits an error and does not write (protects against partial installs).
Note: Currently using SharedPreferences/UserDefaults with in-memory cache. SQLite sharing is planned for Phase 1.
Web-Push Cleanup
Web-push functionality has been retired due to unreliability. All web-push related code paths and documentation sections should be removed or marked as deprecated. See web-push-cleanup-guide.md
for detailed cleanup steps.
This document consolidates the Native-First notification system strategy, implementation details, capabilities, and acceptance criteria into a single comprehensive reference.