248 lines
12 KiB
Markdown
248 lines
12 KiB
Markdown
# 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 during `configure()`.
|
||
- **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)*
|
||
|
||
```ts
|
||
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)**
|
||
|
||
```kotlin
|
||
// 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" }
|
||
```
|
||
|
||
```swift
|
||
// 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 to `setWindow(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.*
|