Compare commits

...
Author SHA1 Message Date
Jose Olarte III c84cce54af Document the retired WAKEUP_PING refresh path as inactive and drop unused refresh auth helpers.
Guides and the debug panel still described /notifications/refresh and api_* scheduling as current after Phase 5; keep Phase 4 cleanup and FCM send-wakeup diagnostics.
2026-09-24 22:35:48 +08:00
Jose Olarte III 4f339eba32 Raise leftover iOS deployment targets from 14.0 to 15.5 so Xcode 27 can build.
Align the project, Share Extension, and CocoaPods `post_install` with the existing 15.5 minimum. Also refresh the CocoaPods embed-frameworks phase and package-lock peer metadata.
2026-09-24 17:17:08 +08:00
Jose Olarte III eb2240b901 Retire WAKEUP_PING refresh consumption and clear leftover api_* schedules on upgrade.
Stop the app from scheduling via the legacy refresh pipeline, and add a one-time Preferences-gated startup cleanup that calls clearApiNotifications() so upgraded installs shed persisted api_* residue without touching daily reminder, dual, AlertSearch, or FCM.
2026-09-22 20:59:23 +08:00
Jose Olarte III 724131d9a8 Retire app consumption of WAKEUP_PING → /notifications/refresh → api_* scheduling.
Phase 2 of legacy notification retirement: drop the production push refresh chain and dead debug/composable paths while keeping FCM registration, AlertSearch, daily reminder, and dual notifications intact.
2026-09-22 20:39:37 +08:00
22 changed files with 469 additions and 1643 deletions
+1 -1
View File
@@ -121,7 +121,7 @@ See [Logging Configuration Guide](doc/logging-configuration.md) for complete det
## Notification Debug Panel (dev builds)
In non-production bundles (for example `vite dev` or a Vite build whose mode is not `production`), the **Notification Debug Panel** at `/dev/notifications` helps you test notification registration, backend refresh, WAKEUP_PING handling, and local schedule inspection on native builds.
In non-production bundles (for example `vite dev` or a Vite build whose mode is not `production`), the **Notification Debug Panel** at `/dev/notifications` helps you test FCM token registration, AlertSearch authorization upload, FCM wakeup delivery (`/debug/send-wakeup`), and local schedule inspection on native builds. The app no longer consumes `WAKEUP_PING` to call `/notifications/refresh` or schedule `api_*` notifications.
**Access:** **Account** → enable **Show All General Advanced Functions** → **Notification Debug Panel**.
+2 -2
View File
@@ -2,9 +2,9 @@
**Created:** 2026-06-02
**Source document:** [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md)
**Purpose:** Plan a future **Android** counterpart guide by mapping what can be reused from the iOS ngrok workflow and what must be written for Android-specific push, permissions, and OS behavior.
**Status:** Planning snapshot from 2026-06-02. Several iOS-guide assumptions (app **Refresh Notifications**, Simulate WAKEUP, `applyNotificationRefreshPayload`, WAKEUP_PING → `/notifications/refresh` → `api_*`) are **retired**. For current Android procedure see [local-android-testing-ngrok.md](./local-android-testing-ngrok.md). Do not treat the reuse tables below as the live app path.
**Status:** Planning only — does not replace or modify the iOS guide.
**Purpose:** Plan a future **Android** counterpart guide by mapping what can be reused from the iOS ngrok workflow and what must be written for Android-specific push, permissions, and OS behavior.
---
+88 -148
View File
@@ -1,8 +1,10 @@
# Local Android Testing with ngrok (notification-wakeup-service)
**Last updated:** 2026-06-02 (verification checklist, end-to-end test)
**Last updated:** 2026-09-24 (retired WAKEUP_PING → refresh → `api_*` consumption)
**Audience:** Developers on **crowd-funder-for-time-pwa**, **daily-notification-plugin**, and **notification-wakeup-service**
**Goal:** Exercise FCM wakeup (`WAKEUP_PING`), FCM token registration, and notification refresh against a Mac-hosted backend reachable from a physical Android device.
**Goal:** Exercise FCM token registration and FCM wakeup **delivery** (`WAKEUP_PING` via `/debug/send-wakeup`) against a Mac-hosted backend reachable from a physical Android device.
> **Retired (do not expect this in the app):** `WAKEUP_PING` → `POST /notifications/refresh` → `applyNotificationRefreshPayload()` → `api_*` local schedules. The app logs ignored push types and does not refresh or schedule from wakeup. `/debug/send-wakeup` remains an FCM diagnostic. Mac `curl` of `/notifications/refresh` only tests the backend. Local schedules come from Static Daily Reminder, New Activity / dual, and the native fetcher.
**See also:** [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md) for the iOS workflow (APNs, Xcode). [android-physical-device-guide.md](./android-physical-device-guide.md) for USB debugging and build/run commands.
@@ -10,7 +12,7 @@
## Architecture overview
End-to-end flow when testing New Activity / silent wake on a physical Android phone:
End-to-end flow when testing FCM registration and wakeup **delivery** on a physical Android phone:
```text
┌─────────────────────┐ HTTPS ┌──────────────────────┐
@@ -19,7 +21,7 @@ End-to-end flow when testing New Activity / silent wake on a physical Android ph
│ wakeup-service │ └──────────┬───────────┘
└──────────┬──────────┘ │
│ │ fetch
│ POST /notifications/refresh │ POST /notifications/register
│ │ POST /notifications/register
│ ▼
│ ┌──────────────────────┐
│ │ crowd-funder-for- │
@@ -28,7 +30,7 @@ End-to-end flow when testing New Activity / silent wake on a physical Android ph
│ └──────────┬───────────┘
│ │
│ FCM data message (WAKEUP_PING) │ daily-notification-plugin
▼ ▼ (local schedule replace)
▼ ▼ (Daily Reminder / dual / fetcher)
┌─────────────────────┐ ┌──────────────────────┐
│ Firebase Cloud │ ──FCM────────► │ Android device │
│ Messaging │ direct │ app.timesafari.app │
@@ -41,18 +43,16 @@ Unlike iOS, Android does **not** use APNs. FCM delivers directly to the app via
| Repo | Role |
|------|------|
| **notification-wakeup-service** | HTTP API: device registration, refresh payload (`nextNotifications`), health, debug wakeup send |
| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register` & `/refresh`, handles `WAKEUP_PING` push |
| **daily-notification-plugin** | Native Android: clear + reschedule local notifications from refresh timestamps |
| **notification-wakeup-service** | HTTP API: device registration, health, debug wakeup send; may still expose `/notifications/refresh` for the backend |
| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register`; logs `WAKEUP_PING` without refresh/`api_*` scheduling |
| **daily-notification-plugin** | Native Android: Daily Reminder, New Activity / dual, native fetcher; Phase 4 `clearApiNotifications()` |
### Android wakeup flow (production path)
### Android wakeup flow (current)
1. **notification-wakeup-service** (or `/debug/send-wakeup`) sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
1. **notification-wakeup-service** `/debug/send-wakeup` sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
2. FCM delivers to the device (best-effort; see [Android Platform Notes](#8-android-platform-notes) and [Battery Optimization Caveats](#9-battery-optimization-caveats)).
3. Capacitor `pushNotificationReceived` fires → `handleCapacitorPushNotificationReceived()` in `NativeNotificationService.ts`.
4. Handler calls `refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING" })`, which `POST`s `{backend}/notifications/refresh` with `testMode` from the debug config.
5. Backend returns `nextNotifications: [{ timestamp }, ...]`.
6. App calls `applyNotificationRefreshPayload()` → **daily-notification-plugin** clears existing local alarms and schedules new ones.
4. Handler logs `[Notifications] push handler ignored type=WAKEUP_PING`. It does **not** POST `/notifications/refresh` or schedule `api_*` notifications.
Console and debug panel lines are prefixed with **`[Notifications]`** (see `NotificationDebugEvents.ts`).
@@ -290,12 +290,11 @@ For a full panel reference (configuration, URL resolution order, authentication,
| Control | Purpose |
|---------|---------|
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) |
| **Test Mode** | Sends `testMode: true/false` in register/refresh JSON bodies (default on when unset in storage) |
| **Test Mode** | Sends `testMode: true/false` in register / send-wakeup JSON bodies (default on when unset in storage) |
| **Skip JWT Authentication (Local Development Only)** | When on, omits `Authorization` headers for local servers that accept unauthenticated requests (default **off**) |
| **Register Token Now** | `POST /notifications/register` with current FCM token and `platform: "android"` |
| **Refresh Notifications** | `POST /notifications/refresh` (same as post-wakeup flow) |
| **Simulate WAKEUP_PING (Local)** | Calls refresh API directly (no FCM) — quick backend + ngrok test |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup` on the backend override URL; server sends a real FCM `WAKEUP_PING` to the panel’s current FCM token (see below) |
| **Upload AlertSearch Authorization** | Uploads AlertSearch delegated JWTs (`/notifications/alert-authorization`) |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; FCM delivery diagnostic only (no app-side refresh/`api_*` scheduling) |
| **Event Log** | Shared `[Notifications]` panel log (100 entries) |
Persistence: `localStorage` keys `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, and `notificationDebug.bypassAuth` (`NotificationDebugConfig.ts`).
@@ -314,19 +313,13 @@ For **local ngrok / localhost**: set the backend URL to your tunnel or `http://1
### testMode
When **Test Mode** is on (default if never saved), register and refresh requests include `"testMode": true`. The backend can route dev traffic separately from production. Turn it off in the panel only if you intentionally want production-mode API behavior against your tunnel.
When **Test Mode** is on (default if never saved), register and send-wakeup requests include `"testMode": true`. The backend can route dev traffic separately from production. Turn it off in the panel only if you intentionally want production-mode API behavior against your tunnel.
### WAKEUP_PING debug controls
Three panel actions exercise different segments of the wakeup pipeline. Use them to bisect failures (see [Troubleshooting §14](#fcm-message-not-received)).
**Send Real WAKEUP_PING** is the remaining panel control for wakeup. Mock refresh, Simulate WAKEUP_PING, Wakeup Ping Simulator, and Flood Test were removed with the retired refresh pipeline.
| Button | Behavior |
|--------|----------|
| **Backend Testing → Simulate WAKEUP_PING (Local)** | Skips FCM; calls refresh API only (ngrok path test) |
| **Backend Testing → Send Real WAKEUP_PING** | Full pipeline: backend `/debug/send-wakeup` → FCM → Capacitor listener → refresh (see below) |
| **Wakeup Ping Simulator** (lower on panel) | Runs production handler with synthetic `WAKEUP_PING` payload (no FCM, no backend wakeup call) |
Use **Simulate WAKEUP_PING (Local)** to verify ngrok + refresh; use **Wakeup Ping Simulator** to verify handler + refresh chaining without FCM; use **Send Real WAKEUP_PING** for end-to-end FCM delivery on device.
Use **Send Real WAKEUP_PING** to confirm backend → FCM → Capacitor listener delivery. Do not expect `/notifications/refresh` or `api_*` rescheduling.
### Send Real WAKEUP_PING
@@ -337,9 +330,9 @@ Use **Simulate WAKEUP_PING (Local)** to verify ngrok + refresh; use **Wakeup Pin
1. Reads the **Current FCM Token** shown in the panel (same token used by **Register Token Now**). If no token is available, the action fails with a panel status message.
2. `POST`s to `{backend override}/debug/send-wakeup` with `deviceId`, `fcmToken`, `platform: "android"`, and `testMode` from the panel config (`NotificationDebugService.sendRealWakeupPing()`).
3. On HTTP success, the **notification-wakeup-service** enqueues a real FCM **data** message with `data.type = "WAKEUP_PING"` to that device.
4. When FCM delivers the message to the app, Capacitor fires `pushNotificationReceived` → `handleCapacitorPushNotificationReceived()` → `refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING" })` → `POST /notifications/refresh` → `applyNotificationRefreshPayload()` (clear + reschedule via **daily-notification-plugin**).
4. When FCM delivers the message to the app, Capacitor fires `pushNotificationReceived` → `handleCapacitorPushNotificationReceived()`, which logs `push handler ignored type=WAKEUP_PING`. There is no `refreshNotificationsWithDiagnostics` and no `applyNotificationRefreshPayload`.
That exercises the full **backend → FCM → Capacitor push listener → refresh request → notification rescheduling** path on Android without manual `curl` on the Mac.
That exercises **backend → FCM → Capacitor push listener** on Android without manual `curl` on the Mac. It does **not** reschedule local notifications.
**Prerequisites:** ngrok backend override saved, **Test Mode** as needed, notification permission granted, **Register Token Now** succeeded, app **backgrounded** (Home — not force-stop) before expecting FCM delivery ([§8](#8-android-platform-notes)).
@@ -348,27 +341,24 @@ That exercises the full **backend → FCM → Capacitor push listener → refres
Filter logcat (prefix is always `[Notifications]`):
```bash
adb logcat | grep -E '\[Notifications\].*(Real WAKEUP_PING|pushNotificationReceived|WAKEUP_PING|Refresh started|Refresh completed)'
adb logcat | grep -E '\[Notifications\].*(Real WAKEUP_PING|push handler ignored)'
```
On a **successful end-to-end** run (HTTP success from the panel, then FCM delivery within ~30–120s), expect these key lines in order:
On a **successful FCM delivery** run (HTTP success from the panel, then FCM delivery within ~30–120s), expect these key lines in order:
```
[Notifications] Real WAKEUP_PING requested
[Notifications] Real WAKEUP_PING success
[Notifications] pushNotificationReceived type=WAKEUP_PING
[Notifications] WAKEUP_PING handler — invoking refresh
[Notifications] Refresh started (WAKEUP_PING)
[Notifications] Refresh completed (WAKEUP_PING) in …ms (scheduled N)
[Notifications] push handler ignored type=WAKEUP_PING
```
Intermediate lines (e.g. `WAKEUP_PING received — will trigger refresh`, `Schedule replacement: …`, `Using authenticated notification request` or auth bypass messages when **Skip JWT Authentication** is on) are normal. ngrok should also show a new `POST /notifications/refresh` after the push is handled.
Auth bypass messages when **Skip JWT Authentication** is on are normal. ngrok should **not** show a new `POST /notifications/refresh` from the app after the push.
#### Send Real WAKEUP_PING vs end-to-end success
#### Send Real WAKEUP_PING vs delivery success
The panel status **“Real WAKEUP_PING sent via backend.”** and the Event Log line **`Real WAKEUP_PING success`** only confirm that the backend **accepted the request and attempted FCM delivery**. They do **not** prove the device received the push or ran refresh.
The panel status **“Real WAKEUP_PING sent via backend.”** and the Event Log line **`Real WAKEUP_PING success`** only confirm that the backend **accepted the request and attempted FCM delivery**. They do **not** prove the device received the push.
**Successful end-to-end delivery** is confirmed only when the subsequent **`pushNotificationReceived`**, **`WAKEUP_PING handler`**, and **`Refresh started (WAKEUP_PING)`** / **`Refresh completed (WAKEUP_PING)`** lines appear in logcat (or Event Log) within the delivery window. If you see `Real WAKEUP_PING success` but not those lines, treat it as an FCM delivery problem ([FCM message not received](#fcm-message-not-received)), not a backend enqueue failure.
**Successful delivery** is confirmed when **`push handler ignored type=WAKEUP_PING`** appears in logcat (or Event Log) within the delivery window. If you see `Real WAKEUP_PING success` but not that line, treat it as an FCM delivery problem ([FCM message not received](#fcm-message-not-received)), not a backend enqueue failure.
### Programmatic override (optional)
@@ -455,15 +445,11 @@ On **API 33+**, `POST_NOTIFICATIONS` is a runtime permission ([section 7](#7-and
### Network connectivity
FCM and your ngrok-backed refresh both need network:
- Device must reach **Google’s FCM endpoints** (mobile data or Wi‑Fi).
- After wake, the app must reach the **ngrok HTTPS URL** for `POST /notifications/refresh`.
- Airplane mode, captive portals, VPNs, or flaky Wi‑Fi cause “server sent wakeup but app never refreshed” symptoms even when FCM eventually arrives.
FCM needs network to Google’s endpoints. Registration needs the ngrok HTTPS URL for `POST /notifications/register`. Airplane mode, captive portals, VPNs, or flaky Wi‑Fi cause “server sent wakeup but app never logged ignored type” symptoms even when FCM eventually arrives.
### Physical device vs emulator
Doze, App Standby, and OEM battery menus are weak or absent on many emulators. Use a **physical device** for wakeup SLA testing; emulators are fine for panel-only register/refresh smoke tests.
Doze, App Standby, and OEM battery menus are weak or absent on many emulators. Use a **physical device** for wakeup SLA testing; emulators are fine for panel-only register smoke tests.
### Logcat
@@ -471,7 +457,7 @@ Doze, App Standby, and OEM battery menus are weak or absent on many emulators. U
adb logcat | grep -E '\[Notifications\]|\[FirebaseMessaging\]|\[NativeNotificationService\]'
```
Expect `pushNotificationReceived type=WAKEUP_PING` and `WAKEUP_PING handler — invoking refresh` after a successful wake. For a panel-driven test, use **Send Real WAKEUP_PING** ([§6](#send-real-wakeup_ping)) instead of manual `curl`.
Expect `push handler ignored type=WAKEUP_PING` after a successful wake. For a panel-driven test, use **Send Real WAKEUP_PING** ([§6](#send-real-wakeup_ping)) instead of manual `curl`.
---
@@ -499,7 +485,7 @@ Apps used infrequently move to **standby** buckets with reduced background netwo
### Manufacturer-specific restrictions
OEMs add layers on top of AOSP. Aggressive battery management can delay **WAKEUP_PING** delivery or prevent the refresh `fetch` from running promptly.
OEMs add layers on top of AOSP. Aggressive battery management can delay **WAKEUP_PING** delivery.
| Vendor | Where to look (names vary by OS version) |
|--------|------------------------------------------|
@@ -515,8 +501,8 @@ If wakeup works on a **Pixel** but fails on an OEM phone, assume battery policy
1. Server accepts `/debug/send-wakeup` → FCM enqueue succeeds.
2. Device may **hold** the message until Doze maintenance or OEM policy allows delivery.
3. `pushNotificationReceived` runs → `refreshNotificationsWithDiagnostics()` → ngrok `POST /notifications/refresh`.
4. Any step can lag under battery savers; use **Simulate WAKEUP_PING (Local)** in the debug panel to separate FCM delay from refresh/API issues; use **Send Real WAKEUP_PING** for the full FCM path ([§6](#send-real-wakeup_ping)).
3. `pushNotificationReceived` runs → `push handler ignored type=WAKEUP_PING`.
4. Any step can lag under battery savers; use **Send Real WAKEUP_PING** for the FCM path ([§6](#send-real-wakeup_ping)).
---
@@ -528,12 +514,10 @@ If wakeup works on a **Pixel** but fails on an OEM phone, assume battery policy
4. Grant notification permission when prompted (or enable in Settings).
5. Open **Notification Debug Panel** → paste ngrok URL → **Save Backend URL**; confirm **Test Mode** is on; enable **Skip JWT Authentication** only if your local backend accepts unauthenticated requests.
6. Tap **Register Token Now** → confirm ngrok `POST /notifications/register` and `[Notifications] Token registration success` in Event Log / logcat.
7. Tap **Refresh Notifications** → confirm `POST /notifications/refresh` and `Refresh completed in Nms (scheduled X)` in Event Log.
8. Optional: tap **Simulate WAKEUP_PING (Local)** to verify ngrok + refresh without FCM.
9. Background the app (Home), then tap **Send Real WAKEUP_PING** (or from the Mac, call **`/debug/send-wakeup`** — see [curl examples](#13-sample-curl-commands)) with the registered `deviceId` / token as required by **notification-wakeup-service**.
10. Watch logcat for `WAKEUP_PING` and `Refresh completed (WAKEUP_PING)` lines ([expected output](#expected-logcat-output)).
11. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate HTTP traffic.
12. Use **Pending Notification Inspector** on the panel to confirm locally scheduled fires after refresh.
7. Background the app (Home), then tap **Send Real WAKEUP_PING** (or from the Mac, call **`/debug/send-wakeup`** — see [curl examples](#13-sample-curl-commands)) with the registered `deviceId` / token as required by **notification-wakeup-service**.
8. Watch logcat for `push handler ignored type=WAKEUP_PING` ([expected output](#expected-logcat-output)).
9. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate HTTP traffic (register and send-wakeup; not app-initiated refresh).
10. Use **Pending Notification Inspector** for Daily Reminder / New Activity / dual schedules — not for retired `api_*` refresh.
For a formal pass/fail sequence, use the [Verification Checklist](#11-verification-checklist) below.
@@ -549,12 +533,12 @@ Use this checklist during development or QA sign-off. Each step lists **actions*
| 2 | Device → backend override | §2 |
| 3 | FCM token registered | §3 |
| 4 | Device record in wakeup service | §4 |
| 5 | Refresh returns schedule data | §5 |
| 6 | Locals scheduled | §6 |
| 5 | Optional backend `/notifications/refresh` curl | §5 (backend only; app does not call this) |
| 6 | Locals scheduled | §6 (Daily Reminder / dual / fetcher — not refresh) |
| 7 | Manual wakeup sends FCM | §7 |
| 8 | WAKEUP_PING → refresh | §8 |
| 9 | Replace after refresh | §9 |
| 10 | Test mode frequent refreshes | §10 |
| 8 | WAKEUP_PING delivered | §8 |
| 9 | _(retired)_ Replace after refresh | skipped |
| 10 | _(retired)_ Test mode frequent refreshes | skipped |
### 1. Backend reachable through ngrok
@@ -578,7 +562,7 @@ curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
3. Confirm **Backend Status → URL** matches the saved ngrok host.
4. Enable **Test Mode** if using dev backend behavior.
**Expected outcome:** **Active** URL in the panel equals your ngrok `https://…` host. Subsequent app requests use that base (not the default `DEFAULT_NOTIFY_API_SERVER`) for `/notifications/register` and `/notifications/refresh`.
**Expected outcome:** **Active** URL in the panel equals your ngrok `https://…` host. Subsequent app requests use that base (not the default `DEFAULT_NOTIFY_API_SERVER`) for `/notifications/register` and `/debug/send-wakeup`.
---
@@ -611,12 +595,11 @@ curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
---
### 5. Refresh endpoint returns schedule data
### 5. Refresh endpoint (backend only; app does not call this)
**Actions:**
1. Tap **Refresh Notifications** in the panel (or curl below).
2. Inspect ngrok response body.
Mac-side curl against **notification-wakeup-service** if you need to confirm the backend still answers. The app has no **Refresh Notifications** button and does not schedule from this payload.
```bash
curl -sS -X POST "$BASE/notifications/refresh" \
@@ -624,18 +607,18 @@ curl -sS -X POST "$BASE/notifications/refresh" \
-d '{"platform":"android","testMode":true}'
```
**Expected outcome:** HTTP **200**; JSON includes `nextNotifications` array with at least one `{ "timestamp": <number> }` in the **future** (epoch ms). Event Log: `Refresh completed in …ms (scheduled N)` with **N ≥ 1**. If `scheduled 0` or empty array, fix backend auth, DID/native fetcher, or `testMode` handling before scheduling tests.
**Expected outcome:** HTTP **200** from the **service** if that route is still deployed. Do **not** expect Event Log `Refresh completed` or `api_*` schedules in the app.
---
### 6. Local notifications are scheduled
### 6. Local notifications are scheduled (not from refresh)
**Actions:**
1. After a successful refresh (step 5), open **Pending Notification Inspector** → **Refresh** (list button).
2. Optionally cross-check logcat for `Schedule replacement applied (N timestamp(s))`.
1. Configure Static Daily Reminder and/or New Activity (dual) in the app, or rely on the native fetcher.
2. Open **Pending Notification Inspector** → **Refresh** (list button).
**Expected outcome:** Inspector lists **N** pending item(s) matching refresh count; each row has a **future** `nextTriggerDate` / wall-clock time. No `Schedule replacement aborted` or `skipped (no valid timestamps)` in Event Log.
**Expected outcome:** Inspector lists pending Daily Reminder / dual / fetcher items. Do not expect `api_*` identifiers after Phase 4 startup cleanup. There is no `Schedule replacement applied` from WAKEUP_PING.
---
@@ -656,11 +639,11 @@ curl -sS -X POST "$BASE/debug/send-wakeup" \
3. Check **notification-wakeup-service** logs for FCM send success (no Admin SDK / token errors).
**Expected outcome:** HTTP **200** (or documented success code) from `/debug/send-wakeup`; Event Log / logcat: `Real WAKEUP_PING success` when using the panel button; server logs indicate FCM message enqueued/sent with `data.type = "WAKEUP_PING"`. This step alone does not prove device delivery (see step 8 and [Send Real WAKEUP_PING vs end-to-end success](#send-real-wakeup_ping-vs-end-to-end-success)).
**Expected outcome:** HTTP **200** (or documented success code) from `/debug/send-wakeup`; Event Log / logcat: `Real WAKEUP_PING success` when using the panel button; server logs indicate FCM message enqueued/sent with `data.type = "WAKEUP_PING"`. This step alone does not prove device delivery (see step 8 and [Send Real WAKEUP_PING vs delivery success](#send-real-wakeup_ping-vs-delivery-success)).
---
### 8. WAKEUP_PING triggers refreshNotifications()
### 8. WAKEUP_PING is delivered (no refresh)
**Actions:**
@@ -669,42 +652,28 @@ curl -sS -X POST "$BASE/debug/send-wakeup" \
3. Filter logcat:
```bash
adb logcat | grep -E 'WAKEUP_PING|pushNotificationReceived|Refresh completed'
adb logcat | grep -E 'WAKEUP_PING|push handler ignored'
```
**Expected outcome (within ~30–120s, longer under Doze/OEM):**
1. `pushNotificationReceived type=WAKEUP_PING` / `WAKEUP_PING received`
2. `WAKEUP_PING handler — invoking refresh`
3. `Refresh started (WAKEUP_PING)`
4. ngrok: new `POST /notifications/refresh`
5. `Refresh completed (WAKEUP_PING) in …ms (scheduled N)`
1. `Real WAKEUP_PING success` (panel path)
2. `push handler ignored type=WAKEUP_PING`
3. ngrok: **no** app-initiated `POST /notifications/refresh`
**Isolation:** **Wakeup Ping Simulator** on the panel should produce lines 2–5 without FCM. **Simulate WAKEUP_PING (Local)** proves refresh only (no handler, source `WAKEUP_PING simulation`). Full expected logcat for **Send Real WAKEUP_PING**: [§6](#expected-logcat-output).
Full expected logcat for **Send Real WAKEUP_PING**: [§6](#expected-logcat-output).
---
### 9. Existing notifications are replaced after refresh
**Actions:**
1. Note pending count and identifiers in **Pending Notification Inspector**.
2. Tap **Refresh Notifications** again (or trigger step 8).
3. Refresh inspector; compare IDs/times to step 1.
**Expected outcome:** Event Log shows `clearAllNotifications` or `cancelAllNotifications` then `Schedule replacement applied`; pending list reflects **new** timestamps (old alarms not accumulated). Total pending count should match latest refresh `N`, not double from duplicate refreshes unless backend returned more slots.
**Retired.** The app no longer replaces local schedules from `/notifications/refresh`. Pending list changes come from Daily Reminder / dual / native fetcher, or from Phase 4 `clearApiNotifications()`.
---
### 10. Test mode produces frequent notification refreshes
**Actions:**
1. Confirm **Test Mode** checked; **Backend Status → testMode: true**.
2. Call refresh twice (panel or curl) with `testMode: true`.
3. Compare `nextNotifications` timestamps in ngrok responses.
**Expected outcome:** With **testMode: true**, **notification-wakeup-service** returns **dev-friendly** schedule data—typically **sooner** fire times than production mode (shorter horizons). Pending Inspector updates to nearer triggers after each refresh. With Test Mode **off**, timestamps should be farther out (production-like); use that contrast to confirm the flag is wired end-to-end.
**Retired.** `testMode` still goes on register and send-wakeup bodies. It does not drive app-side `api_*` refresh cadences.
---
@@ -739,31 +708,23 @@ npm run dev
8. Paste `BASE` → **Save Backend URL**; enable **Test Mode**.
### Phase C — Register and schedule
### Phase C — Register
9. **Register Token Now** → Event Log success; ngrok `POST /notifications/register` **200**; copy `deviceId` from ngrok request body.
10. Confirm device in **notification-wakeup-service** (logs/DB per that repo).
11. **Refresh Notifications** → Event Log `scheduled N`, N ≥ 1; ngrok refresh **200** with `nextNotifications`.
### Phase D — FCM wakeup delivery
12. **Pending Notification Inspector** → **Refresh** → future alarm(s) listed.
11. Background app (Home).
### Phase D — FCM wakeup path
12. Tap **Send Real WAKEUP_PING** in the panel (or Mac: `curl -X POST "$BASE/debug/send-wakeup" -H "Content-Type: application/json" -d '{"deviceId":"…","testMode":true}'`) → panel `Real WAKEUP_PING success` and server FCM enqueue success.
13. Background app (Home).
13. Within 30–120s (longer if unplugged/Doze): logcat shows `push handler ignored type=WAKEUP_PING`; ngrok does **not** show an app `POST /notifications/refresh`.
14. Tap **Send Real WAKEUP_PING** in the panel (or Mac: `curl -X POST "$BASE/debug/send-wakeup" -H "Content-Type: application/json" -d '{"deviceId":"…","testMode":true}'`) → panel `Real WAKEUP_PING success` and server FCM enqueue success.
### Phase E — Optional local notification proof
15. Within 30–120s (longer if unplugged/Doze): logcat shows `pushNotificationReceived` → `Refresh completed (WAKEUP_PING)`; ngrok shows second `POST /notifications/refresh`.
16. Pending Inspector **Refresh** → timestamps updated (replacement, not duplicate stack).
### Phase E — Optional delivery proof
17. If `testMode` returned a trigger within a few minutes, wait for wall-clock fire with app backgrounded; confirm notification appears (permission + channel + exact alarm rules).
18. If FCM step 15 failed but step 11 passed: run **Simulate WAKEUP_PING (Local)** then **Wakeup Ping Simulator** to bisect FCM vs handler; see [Troubleshooting](#14-troubleshooting) and [Send Real WAKEUP_PING vs end-to-end success](#send-real-wakeup_ping-vs-end-to-end-success).
14. Enable Daily Reminder or New Activity; wait for wall-clock fire with app backgrounded; confirm the OS notification is **not** the retired generic `api_*` “Reminder” copy.
### End-to-end pass criteria
@@ -771,9 +732,9 @@ npm run dev
|-------|------|
| A | Health OK local + ngrok |
| B | Override URL + testMode active |
| C | Register + refresh + pending list populated |
| D | Wakeup curl OK → logcat refresh chain → pending updated |
| E | Optional visible notification at scheduled time |
| C | Register succeeded |
| D | Wakeup curl/panel OK → logcat ignored-type line |
| E | Optional visible Daily Reminder / New Activity notification |
---
@@ -804,7 +765,7 @@ curl -sS -X POST "$BASE/notifications/register" \
}'
```
### Refresh (mirror app payload)
### Refresh (backend only; app does not consume)
```bash
curl -sS -X POST "$BASE/notifications/refresh" \
@@ -815,19 +776,7 @@ curl -sS -X POST "$BASE/notifications/refresh" \
}'
```
Example success body shape (actual fields may vary by service version):
```json
{
"shouldNotify": true,
"nextNotifications": [
{ "timestamp": 1710000000000 },
{ "timestamp": 1710003600000 }
]
}
```
The app schedules those timestamps via **daily-notification-plugin** (`applyNotificationRefreshPayload` in `NativeNotificationService.ts`).
This exercises **notification-wakeup-service** only. The app does **not** call `applyNotificationRefreshPayload` or schedule `api_*` from the response.
### Send wakeup push (debug)
@@ -842,7 +791,7 @@ curl -sS -X POST "$BASE/debug/send-wakeup" \
}'
```
Confirm parameters (token vs `deviceId`, auth headers) in that repo’s README or OpenAPI spec. The server must send FCM data including `type: "WAKEUP_PING"` to match `handleCapacitorPushNotificationReceived`.
Confirm parameters (token vs `deviceId`, auth headers) in that repo’s README or OpenAPI spec. The server may still send FCM data including `type: "WAKEUP_PING"`; the app logs it as ignored and does not refresh.
---
@@ -899,35 +848,28 @@ Compare with panel **Backend Status** and Event Log error text.
### Refresh endpoint failures
**Symptoms:** **Refresh Notifications** fails; Event Log HTTP error; no `scheduled X` line; ngrok missing `POST /notifications/refresh`.
**Symptoms:** Mac `curl` of `/notifications/refresh` fails; ngrok missing that path. This is a **backend** issue. The app does not POST refresh.
**Likely causes:** Stale ngrok URL; backend down; 404/wrong path; JWT/native fetcher not configured; refresh auth failure.
**Verification:** `curl` health and refresh from the Mac; confirm the service still ships that route.
**Verification:**
1. **Simulate WAKEUP_PING (Local)** — if this fails, problem is ngrok/refresh API, not FCM.
2. ngrok inspect for refresh status code and response body.
3. Logcat: `refreshNotifications failed` or JWT errors from `configureNativeFetcherIfReady()`.
**Fixes:** Fix backend URL and health; ensure active DID and endorser settings ([notification-from-api-call.md](./notification-from-api-call.md)); confirm `testMode` if backend requires it.
**Fixes:** Fix backend URL and health. App scheduling uses Daily Reminder / dual / native fetcher, not this payload.
---
### FCM message not received
**Symptoms:** `/debug/send-wakeup` or panel **Send Real WAKEUP_PING** shows success (`Real WAKEUP_PING success` in Event Log); no `pushNotificationReceived` / `WAKEUP_PING` in logcat within 2 minutes; refresh never triggered.
**Symptoms:** `/debug/send-wakeup` or panel **Send Real WAKEUP_PING** shows success (`Real WAKEUP_PING success` in Event Log); no `push handler ignored type=WAKEUP_PING` in logcat within 2 minutes.
**Likely causes:** Force-stopped app; wrong FCM token on server; Doze/OEM delay; no Google Play services; payload missing `data.type = "WAKEUP_PING"`; device offline.
**Note:** `Real WAKEUP_PING success` only means the backend accepted and sent the FCM request. Missing downstream refresh logs indicates a **delivery** failure, not a failed wakeup API call ([§6](#send-real-wakeup_ping-vs-end-to-end-success)).
**Note:** `Real WAKEUP_PING success` only means the backend accepted and sent the FCM request. Missing the ignored-type log indicates a **delivery** failure, not a failed wakeup API call ([§6](#send-real-wakeup_ping-vs-delivery-success)).
**Verification:**
1. App **backgrounded** (Home), not force-stopped.
2. Panel FCM token matches token used by server/register.
3. **Simulate WAKEUP_PING (Local)** works → isolates FCM path from refresh/API.
4. Wait 30–120s (longer on Doze/OEM).
5. Battery **Unrestricted** and OEM autostart enabled for test device.
3. Wait 30–120s (longer on Doze/OEM).
4. Battery **Unrestricted** and OEM autostart enabled for test device.
**Fixes:** Re-register token; relaunch app; relax battery settings ([section 9](#9-battery-optimization-caveats)); confirm **notification-wakeup-service** message format; test on Pixel vs suspect OEM policy.
@@ -947,30 +889,28 @@ Compare with panel **Backend Status** and Event Log error text.
### Notifications not appearing
**Symptoms:** Refresh succeeds (`scheduled X` in log) but no visible notification at fire time; Pending Inspector empty or stale.
**Symptoms:** Daily Reminder or New Activity configured but no visible notification at fire time; Pending Inspector empty or stale.
**Likely causes:** Permission denied (display blocked); exact alarm permission on Android 12+; timestamps in past; plugin schedule error; DND/channel settings.
**Verification:**
1. Permission granted ([section 7](#7-android-notification-permissions)).
2. Pending Notification Inspector after refresh.
3. Logcat: `Schedule replacement applied` vs aborted messages.
4. Confirm `nextNotifications` timestamps are in the future (curl refresh response).
2. Pending Notification Inspector for Daily Reminder / dual identifiers (not `api_*` after Phase 4 cleanup).
**Fixes:** Grant `POST_NOTIFICATIONS`; check `SCHEDULE_EXACT_ALARM` / alarm permission per plugin docs; fix refresh payload; test with nearer timestamps via backend `testMode`.
**Fixes:** Grant `POST_NOTIFICATIONS`; check `SCHEDULE_EXACT_ALARM` / alarm permission per plugin docs. Do not expect wakeup refresh payloads to populate the inspector.
---
### Duplicate notifications
**Symptoms:** Multiple identical local notifications; Event Log shows repeated refresh lines.
**Symptoms:** Multiple identical local notifications.
**Likely causes:** Multiple `WAKEUP_PING` deliveries; repeated manual **Refresh**; flood test; separate Daily Reminder vs New Activity schedules.
**Likely causes:** Separate Daily Reminder vs New Activity schedules; duplicate dual config; leftover `api_*` before Phase 4 cleanup.
**Verification:** Event Log count of refresh completions; ngrok inspect for duplicate `POST /notifications/refresh`.
**Verification:** Pending Inspector identifiers; see [notification-new-activity-lay-of-the-land.md](./notification-new-activity-lay-of-the-land.md) for product-level double-schedule issues.
**Fixes:** Each refresh should **replace** schedule (clear + schedule)—if duplicates persist, check plugin logs; see [notification-new-activity-lay-of-the-land.md](./notification-new-activity-lay-of-the-land.md) for product-level double-schedule issues.
**Fixes:** Confirm Daily Reminder vs dual settings. WAKEUP_PING no longer stacks `api_*` refreshes.
---
@@ -1006,7 +946,7 @@ Compare with panel **Backend Status** and Event Log error text.
| `src/services/notifications/NotificationDebugEvents.ts` | Panel event log + `logNotification()` |
| `src/services/notifications/notificationLog.ts` | Structured log helpers |
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
| `src/services/notifications/NativeNotificationService.ts` | `refreshNotifications`, `WAKEUP_PING`, `applyNotificationRefreshPayload` |
| `src/services/notifications/NativeNotificationService.ts` | Push delivery hook (logs ignored types; no refresh) |
| `src/services/notifications/firebaseMessagingClient.ts` | Capacitor push listeners, permission, token registration |
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
| `src/main.capacitor.ts` | Native push init at startup |
+32 -51
View File
@@ -1,14 +1,16 @@
# Local iOS Testing with ngrok (notification-wakeup-service)
**Last updated:** 2026-05-18
**Last updated:** 2026-09-24 (retired WAKEUP_PING → refresh → `api_*` consumption)
**Audience:** Developers on **crowd-funder-for-time-pwa**, **daily-notification-plugin**, and **notification-wakeup-service**
**Goal:** Exercise silent push wake (`WAKEUP_PING`), FCM token registration, and notification refresh against a Mac-hosted backend reachable from a physical iPhone.
**Goal:** Exercise FCM token registration and silent-push **delivery** (`WAKEUP_PING` via `/debug/send-wakeup`) against a Mac-hosted backend reachable from a physical iPhone.
> **Retired (do not expect this in the app):** `WAKEUP_PING` → `POST /notifications/refresh` → `applyNotificationRefreshPayload()` → `api_*` local schedules. The app logs ignored push types and does not refresh or schedule from wakeup. `/debug/send-wakeup` remains an FCM/APNs diagnostic. Mac `curl` of `/notifications/refresh` only tests the backend.
---
## Architecture overview
End-to-end flow when testing New Activity / silent wake on a physical iPhone:
End-to-end flow when testing FCM registration and wakeup **delivery** on a physical iPhone:
```text
┌─────────────────────┐ HTTPS ┌──────────────────────┐
@@ -17,7 +19,7 @@ End-to-end flow when testing New Activity / silent wake on a physical iPhone:
│ wakeup-service │ └──────────┬───────────┘
└──────────┬──────────┘ │
│ │ fetch
│ POST /notifications/refresh │ POST /notifications/register
│ │ POST /notifications/register
│ ▼
│ ┌──────────────────────┐
│ │ crowd-funder-for- │
@@ -26,7 +28,7 @@ End-to-end flow when testing New Activity / silent wake on a physical iPhone:
│ └──────────┬───────────┘
│ │
│ FCM data message (WAKEUP_PING) │ daily-notification-plugin
▼ ▼ (local schedule replace)
▼ ▼ (Daily Reminder / dual / fetcher)
┌─────────────────────┐ ┌──────────────────────┐
│ Firebase Cloud │ ──APNs──────► │ iPhone (physical) │
│ Messaging │ silent push │ app.timesafari │
@@ -37,18 +39,16 @@ End-to-end flow when testing New Activity / silent wake on a physical iPhone:
| Repo | Role |
|------|------|
| **notification-wakeup-service** | HTTP API: device registration, refresh payload (`nextNotifications`), health, debug wakeup send |
| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register` & `/refresh`, handles `WAKEUP_PING` push |
| **daily-notification-plugin** | Native iOS/Android: clear + reschedule local notifications from refresh timestamps |
| **notification-wakeup-service** | HTTP API: device registration, health, debug wakeup send; may still expose `/notifications/refresh` |
| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register`; logs `WAKEUP_PING` without refresh/`api_*` scheduling |
| **daily-notification-plugin** | Native iOS/Android: Daily Reminder, New Activity / dual, native fetcher; Phase 4 `clearApiNotifications()` |
### Silent wake sequence (production path)
### Silent wake sequence (current)
1. Backend (or `/debug/send-wakeup`) sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
1. Backend `/debug/send-wakeup` sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
2. APNs delivers to the device (best-effort; see iOS caveats below).
3. Capacitor `pushNotificationReceived` fires → `handleCapacitorPushNotificationReceived()`.
4. App calls `POST {backend}/notifications/refresh` with `testMode` (from debug config).
5. Backend returns `nextNotifications: [{ timestamp }, ...]`.
6. App calls `applyNotificationRefreshPayload()` → plugin clears and schedules new local alarms.
4. Handler logs `[Notifications] push handler ignored type=WAKEUP_PING`. It does **not** POST `/notifications/refresh` or schedule `api_*` notifications.
Console and debug panel lines are prefixed with **`[Notifications]`** (see `NotificationDebugEvents.ts`).
@@ -297,12 +297,11 @@ For a full panel reference (configuration, URL resolution order, authentication,
| Control | Purpose |
|---------|---------|
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) |
| **Test Mode** | Sends `testMode: true/false` in register/refresh JSON bodies (default on when unset in storage) |
| **Test Mode** | Sends `testMode: true/false` in register / send-wakeup JSON bodies (default on when unset in storage) |
| **Skip JWT Authentication (Local Development Only)** | When on, omits `Authorization` headers for local servers that accept unauthenticated requests (default **off**) |
| **Register Token Now** | `POST /notifications/register` with current FCM token |
| **Refresh Notifications** | `POST /notifications/refresh` (same as post-wakeup flow) |
| **Simulate WAKEUP_PING (Local)** | Calls refresh API directly (no FCM) — quick backend test |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; server sends real FCM `WAKEUP_PING` (Android doc has full flow) |
| **Upload AlertSearch Authorization** | Uploads AlertSearch delegated JWTs |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; FCM/APNs delivery diagnostic only |
| **Event Log** | Shared `[Notifications]` panel log (100 entries) |
Persistence: `localStorage` keys `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, and `notificationDebug.bypassAuth` (`NotificationDebugConfig.ts`).
@@ -352,7 +351,7 @@ This section is a quick verification checklist for the detailed Firebase/APNs se
| **GoogleService-Info.plist** | Present in the iOS target if using Firebase iOS SDK paths in your build |
| **FCM token** | Confirm **Register Token Now** succeeds in the debug panel and ngrok shows `POST /notifications/register` |
Silent/data pushes used for wake typically use a **content-available** style payload; confirm **notification-wakeup-service** and Firebase message format match what `handleCapacitorPushNotificationReceived` expects (`data.type === "WAKEUP_PING"`).
Silent/data pushes used for wake typically use a **content-available** style payload; confirm **notification-wakeup-service** and Firebase message format. The app logs `WAKEUP_PING` as ignored and does not refresh.
---
@@ -378,14 +377,9 @@ Silent/data pushes used for wake typically use a **content-available** style pay
- **Low Power Mode** can reduce background execution.
- **Focus / Do Not Disturb** may affect notification presentation (separate from silent data wake, but confusing during tests).
### Two “Simulate WAKEUP_PING” buttons
### Send Real WAKEUP_PING (FCM/APNs diagnostic)
| Button | Behavior |
|--------|----------|
| **Backend Testing → Simulate WAKEUP_PING** | Skips FCM; calls refresh API only (ngrok path test) |
| **Wakeup Ping Simulator** (lower on panel) | Runs production handler with synthetic `WAKEUP_PING` payload |
Use the backend button to verify ngrok + refresh; use the simulator to verify handler + refresh chaining.
**Send Real WAKEUP_PING** posts `/debug/send-wakeup`. Delivery is confirmed by `push handler ignored type=WAKEUP_PING`. Mock refresh, Simulate WAKEUP_PING, and Wakeup Ping Simulator were removed.
---
@@ -395,11 +389,10 @@ Use the backend button to verify ngrok + refresh; use the simulator to verify ha
2. Start **ngrok** and copy the HTTPS URL.
3. Set URL + **Test Mode** in the Notification Debug Panel; confirm **Backend Status**.
4. Tap **Register Token Now** → confirm ngrok request and `[Notifications] Token registration success`.
5. Tap **Refresh Notifications** → confirm `Refresh completed in Nms (scheduled X)` in Event Log and ngrok `POST /notifications/refresh`.
6. From the backend, call **`/debug/send-wakeup`** (see curl below) with the registered `deviceId` / FCM token as required by that service.
7. Watch **Xcode console** for `[Notifications] pushNotificationReceived type=WAKEUP_PING` and refresh timing lines.
8. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate requests.
9. Use **Pending Notification Inspector** on the panel to see locally scheduled fires after refresh.
5. From the backend, call **`/debug/send-wakeup`** (see curl below) with the registered `deviceId` / FCM token as required by that service, or tap **Send Real WAKEUP_PING**.
6. Watch **Xcode console** for `[Notifications] push handler ignored type=WAKEUP_PING`.
7. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate requests (register and send-wakeup; not app-initiated refresh).
8. Use **Pending Notification Inspector** for Daily Reminder / dual / fetcher schedules.
---
@@ -430,7 +423,7 @@ curl -sS -X POST "$BASE/notifications/register" \
}'
```
### Refresh (mirror app payload)
### Refresh (backend only; app does not consume)
```bash
curl -sS -X POST "$BASE/notifications/refresh" \
@@ -441,19 +434,7 @@ curl -sS -X POST "$BASE/notifications/refresh" \
}'
```
Example success body shape (actual fields may vary by service version):
```json
{
"shouldNotify": true,
"nextNotifications": [
{ "timestamp": 1710000000000 },
{ "timestamp": 1710003600000 }
]
}
```
The app schedules those timestamps via **daily-notification-plugin** (`applyNotificationRefreshPayload` in `NativeNotificationService.ts`).
This exercises **notification-wakeup-service** only. The app does **not** call `applyNotificationRefreshPayload` or schedule `api_*` from the response.
### Send wakeup push (debug)
@@ -478,8 +459,8 @@ Confirm parameters (token vs deviceId, auth headers) in that repo’s README or
| Symptom | Checks |
|---------|--------|
| Network error in Event Log | ngrok running? URL saved without typo/trailing slash? |
| HTTP 404 | Tunnel port matches backend `PORT`; path is `/notifications/refresh` |
| Mac `curl` of `/notifications/refresh` fails | ngrok running? URL saved without typo/trailing slash? |
| HTTP 404 | Tunnel port matches backend `PORT`; path is `/notifications/refresh` (backend route; the app does not call it) |
| CORS (web only) | Native Capacitor fetch usually avoids browser CORS; if testing in Safari PWA, configure CORS on the service |
| ngrok browser warning | Free tier may show an interstitial for browser clients; native `fetch` from the app is usually unaffected |
@@ -495,15 +476,15 @@ Confirm parameters (token vs deviceId, auth headers) in that repo’s README or
- App **backgrounded**, not force-quit
- Physical device, correct provisioning profile
- APNs key uploaded to Firebase; bundle ID matches
- FCM message includes `data.type = "WAKEUP_PING"` (see `NativeNotificationService.ts`)
- FCM message includes `data.type = "WAKEUP_PING"` (logged as ignored in `NativeNotificationService.ts`)
- Server actually sent to the **same** FCM token shown in the debug panel
- Wait 30–120s — delivery is not instant
- Try **Simulate WAKEUP_PING** (refresh API) to isolate app/plugin from FCM/APNs
- Confirm Xcode shows `push handler ignored type=WAKEUP_PING` (there is no Simulate WAKEUP_PING / refresh API in the app)
### Notifications duplicating
- Multiple refresh calls (flood test, repeated wakeups) each **replace** schedule via clear + schedule — check Event Log for repeated refreshes
- Separate issue: Daily Reminder vs New Activity both scheduling — see `doc/notification-new-activity-lay-of-the-land.md`
- Daily Reminder vs New Activity both scheduling — see `doc/notification-new-activity-lay-of-the-land.md`
- Leftover `api_*` before Phase 4 cleanup (startup `clearApiNotifications()`)
### Stale ngrok URL
@@ -525,7 +506,7 @@ Confirm parameters (token vs deviceId, auth headers) in that repo’s README or
| `src/services/notifications/NotificationDebugEvents.ts` | Panel event log + `logNotification()` |
| `src/services/notifications/notificationLog.ts` | Structured log helpers |
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
| `src/services/notifications/NativeNotificationService.ts` | Refresh, `WAKEUP_PING`, schedule replace |
| `src/services/notifications/NativeNotificationService.ts` | Push delivery hook (logs ignored types; no refresh) |
| `src/services/notifications/firebaseMessagingClient.ts` | Capacitor push listeners |
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
| `src/main.capacitor.ts` | Native push init at startup |
+14 -19
View File
@@ -1,16 +1,16 @@
# Notification Debug Panel
**Created:** 2026-07-07
**Updated:** 2026-07-22
**Audience:** Developers testing notification registration, refresh, and WAKEUP_PING flows on native (iOS/Android) dev builds.
**Updated:** 2026-09-24
**Audience:** Developers testing notification registration, AlertSearch authorization upload, and FCM delivery diagnostics on native (iOS/Android) dev builds.
The **Notification Debug Panel** is a dev-only UI for exercising the same notification orchestration paths the production app uses: FCM token registration, backend refresh, wakeup handling, and local schedule inspection. It does not duplicate scheduling logic.
The **Notification Debug Panel** is a dev-only UI for FCM token registration, AlertSearch authorization upload, FCM wakeup delivery diagnostics (`/debug/send-wakeup`), and local schedule inspection. It does not schedule notifications from `WAKEUP_PING`. The production `WAKEUP_PING` → `/notifications/refresh` → `api_*` path was retired; leftover `api_*` schedules are cleared once at startup (Phase 4).
---
## Notification API base URL
Notification HTTP calls (`/notifications/register`, `/notifications/refresh`, `/debug/send-wakeup`, etc.) do **not** use `APP_SERVER`. They use a dedicated Notification API host, resolved at runtime by `getNotificationApiBaseUrl()` in `NotificationDebugConfig.ts`.
Notification HTTP calls (`/notifications/register`, `/notifications/alert-authorization`, `/debug/send-wakeup`, etc.) do **not** use `APP_SERVER`. They use a dedicated Notification API host, resolved at runtime by `getNotificationApiBaseUrl()` in `NotificationDebugConfig.ts`. The app does not call `/notifications/refresh`.
### Configuration constants
@@ -65,7 +65,7 @@ Settings persist in `localStorage` via `NotificationDebugConfig.ts`:
| `notificationDebug.testMode` | `true` | Sent in JSON request bodies (`testMode: true/false`) |
| `notificationDebug.bypassAuth` | `false` | When `true`, omit JWT `Authorization` headers on notification API calls |
All notification API requests (`/notifications/register`, `/notifications/refresh`, `/debug/send-wakeup`, etc.) obtain headers through `getNotificationApiHeaders()` in `notificationApiAuth.ts`.
All notification API requests (`/notifications/register`, `/notifications/alert-authorization`, `/debug/send-wakeup`, etc.) obtain headers through `getNotificationApiHeaders()` in `notificationApiAuth.ts`.
### Notification Backend URL
@@ -75,7 +75,7 @@ Leave empty to use the configured build default (`DEFAULT_NOTIFY_API_SERVER`, fr
### Test Mode
When enabled (default if never saved), register and refresh requests include `"testMode": true` in the JSON body. The backend can use this to return dev-friendly schedules or route test traffic separately from production.
When enabled (default if never saved), register and send-wakeup requests include `"testMode": true` in the JSON body. The backend can use this to route test traffic separately from production.
Test Mode is **independent of authentication**. It does not control whether `Authorization` headers are sent.
@@ -124,9 +124,8 @@ Example: `https://abc123.ngrok-free.app` or `http://127.0.0.1:3000`
| Action | What it does |
|--------|----------------|
| **Register Token Now** | `POST {backend}/notifications/register` with current FCM token, `deviceId`, `platform`, and `testMode`. Forces re-registration (bypasses duplicate-token skip). |
| **Refresh Notifications** | `POST {backend}/notifications/refresh` — same path used after a real WAKEUP_PING. Applies returned schedule to the native plugin. |
| **Simulate WAKEUP_PING (Local)** | Calls the refresh API directly (no FCM). Quick test of backend URL + auth + refresh parsing without push delivery. |
| **Send Real WAKEUP_PING** | `POST {backend}/debug/send-wakeup`; server sends a real FCM data message with `data.type = "WAKEUP_PING"`. Exercises backend → FCM → Capacitor listener → refresh → reschedule. Background the app before expecting delivery. |
| **Upload AlertSearch Authorization** | Mints and uploads delegated AlertSearch JWTs (`POST /notifications/alert-authorization`). Requires an active `did:ethr` identity; Test Mode is not used. |
| **Send Real WAKEUP_PING** | `POST {backend}/debug/send-wakeup`; server sends a real FCM data message with `data.type = "WAKEUP_PING"`. FCM delivery diagnostic only — the app logs `push handler ignored type=…` and does **not** call `/notifications/refresh` or schedule `api_*` notifications. Background the app before expecting delivery. |
**Current FCM Token** displays the last token from Capacitor/Firebase registration. **Event Log** shows the last 100 `[Notifications]` messages (also visible in logcat / Xcode console on native).
@@ -136,12 +135,8 @@ Example: `https://abc123.ngrok-free.app` or `http://127.0.0.1:3000`
| Section | Purpose |
|---------|---------|
| **Mock Timing Presets** | Interval for mock refresh timestamps (30 sec – 10 min). |
| **Trigger Mock Refresh** | Applies synthetic future timestamps locally — no backend call. |
| **Wakeup Ping Simulator** | Runs the production push handler with a synthetic `WAKEUP_PING` payload (no FCM, no backend). |
| **Flood Test** | Runs 20 sequential mock refreshes (stress test). |
| **Pending Notification Inspector** | Lists locally scheduled notifications (iOS; Android may show unavailable). |
| **Clear Notifications** | Clears/cancels all plugin-scheduled notifications on native. |
| **Pending Notification Inspector** | Lists locally scheduled notifications (Daily Reminder, New Activity / dual, and any leftover `api_*` until Phase 4 cleanup). |
| **Clear Notifications** | Clears/cancels plugin-scheduled notifications on native. Does not replace the one-time `api_*` startup cleanup. |
---
@@ -198,11 +193,11 @@ The app deferred registration because JWT could not be built (no active DID or e
Same as above. Confirm the **Active** URL in the panel matches your running tunnel or local server port.
### Register succeeds but Send Real WAKEUP_PING does not trigger refresh
### Register succeeds but Send Real WAKEUP_PING does not show delivery
**Real WAKEUP_PING success** only means the backend accepted the wakeup request and attempted FCM delivery. Missing `pushNotificationReceived` / `Refresh completed (WAKEUP_PING)` indicates an FCM delivery or background execution issue — not necessarily a bad wakeup API call.
**Real WAKEUP_PING success** only means the backend accepted the wakeup request and attempted FCM delivery. It does **not** schedule local `api_*` notifications. Delivery is confirmed when logcat / Event Log shows `push handler ignored type=WAKEUP_PING` (the retired refresh chain is gone).
**Checks:** App backgrounded (not force-stopped); FCM token matches registration; **Simulate WAKEUP_PING (Local)** works (isolates FCM from refresh API).
**Checks:** App backgrounded (not force-stopped); FCM token matches registration; Firebase / Play services available.
See platform-specific guides for extended ngrok and FCM workflows:
@@ -222,7 +217,7 @@ See platform-specific guides for extended ngrok and FCM workflows:
| `src/services/notifications/notificationApiDebugMode.ts` | Auth bypass gate |
| `src/services/notifications/NotificationDebugService.ts` | Panel action handlers |
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
| `src/services/notifications/NativeNotificationService.ts` | `POST /notifications/refresh`, WAKEUP_PING handler |
| `src/services/notifications/NativeNotificationService.ts` | Push delivery hook (logs ignored types; no refresh) |
---
+8 -8
View File
@@ -177,7 +177,7 @@
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
3FE25897CF40A571D4AC2ACE /* [CP] Copy Pods Resources */,
2B3F98670AF3508A35AC3248 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -294,19 +294,19 @@
shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" \n";
showEnvVarsInLog = 0;
};
3FE25897CF40A571D4AC2ACE /* [CP] Copy Pods Resources */ = {
2B3F98670AF3508A35AC3248 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
name = "[CP] Embed Pods Frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-resources.sh\"\n";
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
92977BEA1068CC097A57FC77 /* [CP] Check Pods Manifest.lock */ = {
@@ -455,7 +455,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -512,7 +512,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -596,7 +596,7 @@
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -634,7 +634,7 @@
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
+4
View File
@@ -82,6 +82,10 @@ post_install do |installer|
assertDeploymentTarget(installer)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
deployment_target = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f
if deployment_target > 0.0 && deployment_target < 15.5
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5'
end
config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
merge_sqlite_omit_load_extension_definition(config)
strip_system_sqlite_from_pod_config(config)
+1 -1
View File
@@ -177,6 +177,6 @@ SPEC CHECKSUMS:
TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: abe640043e6b8adea745693d980ead03251912f9
PODFILE CHECKSUM: 5736811d271d5309d3e2de8f3eefdbb6632086a3
COCOAPODS: 1.16.2
+108 -793
View File
File diff suppressed because it is too large Load Diff
+2 -133
View File
@@ -88,25 +88,6 @@
Manually mints and uploads 100 delegated day JWTs. Requires an active
did:ethr identity and JWT authentication; Test Mode is not used.
</p>
<button
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onBackendRefresh"
>
Refresh Notifications
</button>
<button
class="w-full text-md bg-gradient-to-b from-violet-400 to-violet-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onSimulateWakeupRefresh"
>
Simulate WAKEUP_PING (Local)
</button>
<p class="text-xs text-slate-500">
Local simulation only — calls the refresh API directly (no FCM push).
</p>
<button
class="w-full text-md bg-gradient-to-b from-amber-400 to-amber-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
@@ -128,8 +109,8 @@
{{ realWakeupStatus.message }}
</p>
<p v-else class="text-xs text-slate-500">
Full pipeline — backend `/debug/send-wakeup` → FCM → WAKEUP_PING
handler.
FCM delivery diagnostic only — backend `/debug/send-wakeup`. The app
no longer schedules api_* notifications from WAKEUP_PING.
</p>
</div>
@@ -177,70 +158,6 @@
</div>
</div>
<!-- SECTION F: Mock Timing Presets -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Mock Timing Presets</h2>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presets"
:key="preset.ms"
class="px-3 py-2 rounded border border-slate-300 bg-white text-sm"
:class="{
'border-blue-500 ring-1 ring-blue-300': intervalMs === preset.ms,
}"
@click="intervalMs = preset.ms"
>
{{ preset.label }}
</button>
</div>
<div class="text-xs text-slate-500 mt-2">
Selected interval: <b>{{ intervalLabel }}</b>
</div>
</div>
<!-- SECTION A: Mock Refresh Controls -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Mock Refresh Controls</h2>
<button
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onMockRefresh"
>
Trigger Mock Refresh
</button>
</div>
<!-- SECTION B: Wakeup Ping Simulator -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Wakeup Ping Simulator</h2>
<p class="text-xs text-slate-500 mb-2">
Exercises the production push handler (not the refresh API shortcut
above).
</p>
<button
class="w-full text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onWakeupPing"
>
Simulate WAKEUP_PING
</button>
</div>
<!-- SECTION C: Flood Test -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Flood Test</h2>
<button
class="w-full text-md bg-gradient-to-b from-rose-400 to-rose-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onFloodTest"
>
Run 20 Refreshes
</button>
</div>
<!-- SECTION D: Pending Notification Inspector -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
@@ -358,14 +275,6 @@ type PendingInfo = {
wallClockSource?: string | null;
};
const presets = [
{ label: "30 sec", ms: 30_000 },
{ label: "1 min", ms: 60_000 },
{ label: "5 min", ms: 5 * 60_000 },
{ label: "10 min", ms: 10 * 60_000 },
];
const intervalMs = ref<number>(60_000);
const busy = ref(false);
const pending = ref<PendingInfo[]>([]);
const pendingInspectorMessage = ref<string | null>(null);
@@ -395,11 +304,6 @@ const truncatedFcmToken = computed(() => {
const eventLog = ref<string[]>([]);
let unsubscribeEventLog: (() => void) | undefined;
const intervalLabel = computed(() => {
const preset = presets.find((p) => p.ms === intervalMs.value);
return preset?.label ?? `${intervalMs.value}ms`;
});
function formatIsoMs(ms: number | null | undefined): string {
if (ms == null || !Number.isFinite(ms)) {
return "";
@@ -423,27 +327,6 @@ async function refreshPending(): Promise<void> {
pendingInspectorMessage.value = result.inspectorUnavailableMessage ?? null;
}
async function onMockRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.triggerMockRefresh(intervalMs.value);
await refreshPending();
});
}
async function onWakeupPing(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.simulateWakeupPing();
await refreshPending();
});
}
async function onFloodTest(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.runFloodTest(intervalMs.value);
await refreshPending();
});
}
async function onClearNotifications(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.clearNotifications();
@@ -514,20 +397,6 @@ async function onUploadAlertAuthorization(): Promise<void> {
});
}
async function onBackendRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.triggerBackendRefresh();
await refreshPending();
});
}
async function onSimulateWakeupRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.simulateWakeupViaRefresh();
await refreshPending();
});
}
function formatRealWakeupStatusMessage(
result: Awaited<
ReturnType<typeof NotificationDebugService.sendRealWakeupPing>
-134
View File
@@ -1,134 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { inject, onBeforeUnmount, onMounted } from "vue";
import { NotificationIface } from "../constants/app";
import { registerToken } from "@/services/notifications/NotificationService";
import { refreshNotifications } from "@/services/notifications/NativeNotificationService";
/**
* Vue 3 composable for notifications
* Provides a concise API for common notification patterns
*/
export const NOTIFICATION_TIMEOUTS = {
BRIEF: 1000, // Very brief toasts ("Sent..." messages)
SHORT: 2000, // Short notifications (clipboard copies, quick confirmations)
STANDARD: 3000, // Standard notifications (success messages, general info)
LONG: 5000, // Longer notifications (errors, warnings, important info)
VERY_LONG: 7000, // Very long notifications (complex operations)
MODAL: -1, // Modal confirmations (no auto-dismiss)
} as const;
export function useNotifications() {
// Inject the notify function from the app
const notify =
inject<(notification: NotificationIface, timeout?: number) => void>(
"notify",
);
if (!notify) {
throw new Error(
"useNotifications must be used within a component that has $notify available",
);
}
let refreshTimer: number | undefined = undefined;
let refreshInFlight: Promise<void> | null = null;
async function refreshNotificationsDebounced(): Promise<void> {
if (refreshTimer != null) {
window.clearTimeout(refreshTimer);
}
refreshTimer = window.setTimeout(() => {
if (!refreshInFlight) {
refreshInFlight = refreshNotifications().finally(() => {
refreshInFlight = null;
});
}
}, 300);
}
const onResume = () => {
void refreshNotificationsDebounced();
};
onMounted(() => {
void refreshNotificationsDebounced();
document.addEventListener("resume", onResume);
});
onBeforeUnmount(() => {
document.removeEventListener("resume", onResume);
if (refreshTimer != null) {
window.clearTimeout(refreshTimer);
}
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function success(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function error(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function warning(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function info(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function toast(_title: string, _text?: string, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function copied(_item: string, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function sent(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function confirm(
_text: string,
_onYes: () => Promise<void>,
_timeout?: number,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function confirmationSubmitted(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function genericError(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function genericSuccess(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function alreadyConfirmed(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function cannotConfirmIssuer(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function cannotConfirmHidden(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function notRegistered(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function notAGive(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function notificationOff(
_title: string,
_callback: (success: boolean) => Promise<void>,
_timeout?: number,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function downloadStarted(_format: string = "Dexie", _timeout?: number) {}
return {
success,
error,
warning,
info,
toast,
copied,
sent,
confirm,
confirmationSubmitted,
genericError,
genericSuccess,
alreadyConfirmed,
cannotConfirmIssuer,
cannotConfirmHidden,
notRegistered,
notAGive,
notificationOff,
downloadStarted,
/** POST FCM token to `/notifications/register` (same as startup native hook). */
registerFcmToken: registerToken,
refreshNotifications: refreshNotificationsDebounced,
};
}
+4 -1
View File
@@ -78,7 +78,10 @@ export interface NotificationRegisterRequest extends NotifyApiTestModeFlag {
}
/**
* Body of `POST /notifications/refresh`. The service finds the caller's device
* Body of `POST /notifications/refresh`. The Capacitor app no longer calls this
* route (WAKEUP_PING → refresh → `api_*` scheduling was retired). These types
* remain because notification-wakeup-service still exposes the endpoint.
* The service finds the caller's device
* by `deviceId`, or by `fcmToken` when no `deviceId` is sent, and answers 400
* when neither is present and non-empty. When both are sent they must name the
* same device, or the answer is 404.
+1 -1
View File
@@ -23,7 +23,7 @@ import { LRUCache } from "lru-cache";
import * as R from "ramda";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { NOTIFICATION_TIMEOUTS } from "../composables/useNotifications";
import { NOTIFICATION_TIMEOUTS } from "../utils/notificationUtils";
import { createNotifyHelpers } from "../utils/notify";
import { NOTIFY_PERSONAL_DATA_ERROR } from "../constants/notifications";
import { Contact } from "../db/tables/contacts";
+3
View File
@@ -48,6 +48,7 @@ import {
configureNativeFetcherIfReady,
initializeNativePushAndFirebaseMessaging,
onNotificationAuthMayBeReady,
runLegacyApiNotificationsCleanupOnce,
} from "@/services/notifications";
logger.log("[Capacitor] 🚀 Starting initialization");
@@ -361,6 +362,8 @@ setTimeout(async () => {
);
await registerDeepLinkListener();
logger.info(`[Main] 🎉 Deep link system fully initialized!`);
// One-time: remove persisted legacy api_* schedules from pre-retirement installs
await runLegacyApiNotificationsCleanupOnce();
// Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token, delivery listeners)
await initializeNativePushAndFirebaseMessaging();
// Configure native fetcher for API-driven daily notifications (activeDid + JWT)
@@ -13,30 +13,8 @@
import { Capacitor } from "@capacitor/core";
import type { PushNotificationSchema } from "@capacitor/push-notifications";
import type {
NotificationRefreshRequest,
NotificationRefreshResponse,
} from "@/interfaces/notifyApi";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { getOrCreateDeviceId } from "./deviceId";
import { REMINDER_ID_DAILY_REMINDER } from "./reminderIds";
import { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
import {
getNotificationApiBaseUrl,
getTestMode,
} from "./NotificationDebugConfig";
import {
logRefreshFailure,
logRefreshStarted,
logRefreshSuccess,
logScheduleReplacement,
} from "./notificationLog";
import {
getNotificationApiHeaders,
logSkippingRefreshDueToMissingAuth,
notificationApiFailureMessage,
readNotificationApiBody,
} from "./notificationApiAuth";
import { logNotification } from "./NotificationDebugEvents";
/**
@@ -563,200 +541,14 @@ export class NativeNotificationService implements NotificationServiceInterface {
}
}
export type RefreshNotificationsResult = {
ok: boolean;
scheduledCount: number;
status?: number;
errorMessage?: string;
};
/**
* Re-applies native API fetcher credentials (JWT pool, active DID) so background
* notification workers can run. No UI; safe from push handlers while backgrounded.
*/
export async function refreshNotificationsWithDiagnostics(options?: {
source?: string;
}): Promise<RefreshNotificationsResult> {
const startedAt = performance.now();
const source = options?.source;
logRefreshStarted(source);
if (!Capacitor.isNativePlatform()) {
const errorMessage = "not a native platform";
logRefreshFailure(startedAt, errorMessage, undefined, source);
return {
ok: false,
scheduledCount: 0,
errorMessage,
};
}
try {
const auth = await getNotificationApiHeaders("refresh");
if (!auth.ok) {
logSkippingRefreshDueToMissingAuth();
logRefreshFailure(startedAt, auth.message, undefined, source);
return {
ok: false,
scheduledCount: 0,
errorMessage: auth.message,
};
}
let deviceId: string | undefined;
try {
deviceId = await getOrCreateDeviceId();
} catch (err) {
logger.warn(
"[NativeNotificationService] Could not obtain deviceId; skipping refresh",
err,
);
}
if (!deviceId) {
// The service finds the device by deviceId or fcmToken and answers 400
// without either, so there is no request worth sending.
const errorMessage = "no deviceId (cannot identify this device)";
logRefreshFailure(startedAt, errorMessage, undefined, source);
return { ok: false, scheduledCount: 0, errorMessage };
}
const body: NotificationRefreshRequest = {
deviceId,
platform: Capacitor.getPlatform(),
testMode: getTestMode(),
};
const baseUrl = getNotificationApiBaseUrl();
const res = await fetch(`${baseUrl}/notifications/refresh`, {
method: "POST",
headers: auth.headers,
body: JSON.stringify(body),
});
if (!res.ok) {
const errorMessage = notificationApiFailureMessage(
res.status,
await readNotificationApiBody(res),
);
logger.warn("[NativeNotificationService] refreshNotifications failed", {
status: res.status,
statusText: res.statusText,
errorMessage,
});
logRefreshFailure(startedAt, errorMessage, res.status, source);
return {
ok: false,
scheduledCount: 0,
status: res.status,
errorMessage,
};
}
const payload = (await res.json()) as NotificationRefreshResponse;
const scheduledCount = Array.isArray(payload?.nextNotifications)
? payload.nextNotifications.length
: 0;
await applyNotificationRefreshPayload(payload);
logRefreshSuccess(startedAt, scheduledCount, source);
return { ok: true, scheduledCount };
} catch (err) {
logger.error("[NativeNotificationService] Refresh failed", err);
const message = err instanceof Error ? err.message : String(err);
logRefreshFailure(startedAt, message, undefined, source);
return { ok: false, scheduledCount: 0, errorMessage: message };
}
}
export async function refreshNotifications(): Promise<void> {
await refreshNotificationsWithDiagnostics();
}
export type NotificationRefreshPayload = {
shouldNotify?: boolean;
nextNotifications?: Array<{ timestamp?: number }>;
};
// `handleCapacitorPushNotificationReceived` and `applyNotificationRefreshPayload` are used by
// DEV notification simulation tooling; they must stay production-safe because that tooling
// exercises real flows. (`applyNotificationRefreshPayload` is also used by production refresh.)
/**
* Apply a "refresh notifications" payload by clearing and scheduling timestamps via the native plugin.
*
* This is the shared implementation used by:
* - production refresh flow (`refreshNotifications` fetching from backend)
* - dev-only debug flows (mock refresh with local payloads)
*
* Important: This function intentionally mirrors production behavior and does not introduce
* any scheduling logic in UI layers.
*/
export async function applyNotificationRefreshPayload(
payload: unknown,
): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return;
}
const data = payload as NotificationRefreshPayload;
const nextNotifications = data?.nextNotifications;
if (!Array.isArray(nextNotifications)) {
return;
}
const timestamps = nextNotifications
.map((n) => (n as { timestamp?: unknown })?.timestamp)
.filter((t): t is number => typeof t === "number" && Number.isFinite(t));
if (timestamps.length === 0) {
logNotification("Schedule replacement skipped (no valid timestamps)");
return;
}
// Keep existing behavior: ensure background worker credentials are current.
await configureNativeFetcherIfReady();
logScheduleReplacement(timestamps.length);
if (typeof DailyNotification.clearApiNotifications !== "function") {
logger.warn(
"[NativeNotificationService] API notification clear unavailable (plugin clearApiNotifications missing); cannot replace schedule",
);
logNotification(
"Schedule replacement aborted (API notification clear unavailable on plugin)",
);
return;
}
logNotification("Clearing API notifications before refresh");
await DailyNotification.clearApiNotifications();
logNotification("Cleared API notifications");
if (typeof DailyNotification.scheduleApiNotifications !== "function") {
logger.warn(
"[NativeNotificationService] scheduleApiNotifications not available on plugin; cannot apply timestamps",
);
logNotification(
"Schedule replacement aborted (scheduleApiNotifications unavailable)",
);
return;
}
await DailyNotification.scheduleApiNotifications({ timestamps });
logNotification(
`Schedule replacement applied (${timestamps.length} timestamp(s))`,
);
}
/**
* Silent FCM/APNs data push: refresh native notification pipeline when requested by backend.
* Capacitor push delivery hook. Legacy WAKEUP_PING → /notifications/refresh
* consumption was retired; AlertSearch uses visible FCM notifications and is
* not handled here. Keep logging for diagnostics without scheduling api_* work.
*/
export async function handleCapacitorPushNotificationReceived(
notification: PushNotificationSchema,
): Promise<void> {
if (notification.data?.type === "WAKEUP_PING") {
logNotification("WAKEUP_PING handler — invoking refresh");
await refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING" });
return;
}
const type =
typeof notification.data?.type === "string"
? notification.data.type
@@ -1,14 +1,12 @@
/**
* DEV-only notification testing utilities.
*
* IMPORTANT:
* This service intentionally routes through the same production notification
* orchestration paths used by refresh flows, wakeup pushes, and replacement.
* Avoid adding duplicate scheduling logic here.
* Legacy WAKEUP_PING → /notifications/refresh → api_* tooling was removed in
* the Phase 2 retirement. Remaining helpers cover FCM registration, AlertSearch
* authorization upload, backend URL overrides, and pending-notification inspection.
*/
import { Capacitor } from "@capacitor/core";
import type { PushNotificationSchema } from "@capacitor/push-notifications";
import type {
DebugSendWakeupRequest,
DebugSendWakeupResponse,
@@ -38,12 +36,6 @@ import {
notificationApiFailureMessage,
readNotificationApiBody,
} from "./notificationApiAuth";
import {
applyNotificationRefreshPayload,
handleCapacitorPushNotificationReceived,
refreshNotificationsWithDiagnostics,
type NotificationRefreshPayload,
} from "./NativeNotificationService";
import { truncateFcmTokenForLog } from "./notificationLog";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin";
@@ -179,19 +171,10 @@ export const NotificationDebugService = {
return result;
},
async triggerBackendRefresh(): Promise<void> {
await refreshNotificationsWithDiagnostics({ source: "debug panel" });
},
/** Local simulation: same API call as a WAKEUP_PING handler (no push payload). */
async simulateWakeupViaRefresh(): Promise<void> {
logNotification("WAKEUP_PING simulation (local refresh API only)");
await refreshNotificationsWithDiagnostics({
source: "WAKEUP_PING simulation",
});
},
/** Full pipeline: backend `/debug/send-wakeup` → FCM → native WAKEUP_PING handler. */
/**
* Backend `/debug/send-wakeup` → FCM only. App no longer consumes WAKEUP_PING
* for api_* scheduling; kept for FCM delivery diagnostics until backend Phase 3.
*/
async sendRealWakeupPing(): Promise<SendRealWakeupPingResult> {
logNotification("Real WAKEUP_PING requested");
@@ -264,62 +247,6 @@ export const NotificationDebugService = {
}
},
generateMockNotifications(
intervalMs: number = 60_000,
): NotificationRefreshPayload {
const now = Date.now();
const future1 = now + intervalMs;
const future2 = now + intervalMs * 2;
return {
shouldNotify: true,
nextNotifications: [{ timestamp: future1 }, { timestamp: future2 }],
};
},
async triggerMockRefresh(intervalMs?: number): Promise<void> {
logNotification("Mock refresh requested");
const payload = this.generateMockNotifications(intervalMs);
const timestamps = payload.nextNotifications?.map((n) => n.timestamp) ?? [];
logNotification(`Mock payload generated (${timestamps.length} timestamps)`);
if (!Capacitor.isNativePlatform()) {
logNotification("Mock refresh skipped: not running on native platform");
return;
}
await applyNotificationRefreshPayload(payload);
logNotification("Mock refresh applied");
},
async simulateWakeupPing(): Promise<void> {
logNotification("Simulating WAKEUP_PING (production push handler)");
if (!Capacitor.isNativePlatform()) {
logNotification("WAKEUP_PING simulation skipped: not native platform");
return;
}
const notification = {
title: "WAKEUP_PING",
body: "",
id: "dev_wakeup_ping",
data: { type: "WAKEUP_PING" },
} as unknown as PushNotificationSchema;
await handleCapacitorPushNotificationReceived(notification);
},
async runFloodTest(intervalMs?: number): Promise<void> {
logNotification("Flood test started (20 sequential refreshes)");
for (let i = 0; i < 20; i++) {
logNotification(`Flood iteration ${i + 1}/20`);
await this.triggerMockRefresh(intervalMs);
}
logNotification("Flood test completed");
},
async clearNotifications(): Promise<void> {
logNotification("Clear notifications (debug panel)");
+1
View File
@@ -42,6 +42,7 @@ export { uploadAlertSearchAuthorization } from "./alertAuthorization";
export type { AlertAuthorizationUploadResult } from "./alertAuthorization";
export { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
export { runLegacyApiNotificationsCleanupOnce } from "./legacyApiNotificationsCleanup";
export {
deferFcmRegistration,
flushDeferredFcmRegistration,
@@ -0,0 +1,101 @@
import { Capacitor } from "@capacitor/core";
import { Preferences } from "@capacitor/preferences";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import {
LEGACY_API_NOTIFICATIONS_CLEANUP_KEY,
runLegacyApiNotificationsCleanupOnce,
} from "./legacyApiNotificationsCleanup";
jest.mock("@capacitor/core", () => ({
Capacitor: {
isNativePlatform: jest.fn(),
},
}));
jest.mock("@capacitor/preferences", () => ({
Preferences: {
get: jest.fn(),
set: jest.fn(),
},
}));
jest.mock("@/plugins/DailyNotificationPlugin", () => ({
DailyNotification: {
clearApiNotifications: jest.fn(),
},
}));
jest.mock("@/utils/logger", () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
},
}));
const isNativePlatform = Capacitor.isNativePlatform as jest.Mock;
const preferencesGet = Preferences.get as jest.Mock;
const preferencesSet = Preferences.set as jest.Mock;
const clearApiNotifications =
DailyNotification.clearApiNotifications as jest.Mock;
describe("runLegacyApiNotificationsCleanupOnce", () => {
beforeEach(() => {
jest.clearAllMocks();
isNativePlatform.mockReturnValue(true);
preferencesGet.mockResolvedValue({ value: null });
preferencesSet.mockResolvedValue(undefined);
clearApiNotifications.mockResolvedValue(undefined);
});
it("no-ops on non-native platforms", async () => {
isNativePlatform.mockReturnValue(false);
await runLegacyApiNotificationsCleanupOnce();
expect(preferencesGet).not.toHaveBeenCalled();
expect(clearApiNotifications).not.toHaveBeenCalled();
expect(preferencesSet).not.toHaveBeenCalled();
});
it("skips cleanup when the migration marker is already set", async () => {
preferencesGet.mockResolvedValue({ value: "1" });
await runLegacyApiNotificationsCleanupOnce();
expect(clearApiNotifications).not.toHaveBeenCalled();
expect(preferencesSet).not.toHaveBeenCalled();
});
it("clears api_* state and writes the marker only after success", async () => {
await runLegacyApiNotificationsCleanupOnce();
expect(clearApiNotifications).toHaveBeenCalledTimes(1);
expect(preferencesSet).toHaveBeenCalledWith({
key: LEGACY_API_NOTIFICATIONS_CLEANUP_KEY,
value: "1",
});
});
it("does not write the marker when clearApiNotifications fails", async () => {
clearApiNotifications.mockRejectedValue(new Error("native failure"));
await runLegacyApiNotificationsCleanupOnce();
expect(clearApiNotifications).toHaveBeenCalledTimes(1);
expect(preferencesSet).not.toHaveBeenCalled();
});
it("does not write the marker when clearApiNotifications is missing", async () => {
const original = DailyNotification.clearApiNotifications;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (DailyNotification as any).clearApiNotifications;
await runLegacyApiNotificationsCleanupOnce();
expect(preferencesSet).not.toHaveBeenCalled();
DailyNotification.clearApiNotifications = original;
});
});
@@ -0,0 +1,85 @@
/**
* One-time upgrade cleanup for legacy API-managed notification schedules
* (`api_*`) left behind by the retired WAKEUP_PING → /notifications/refresh path.
*
* Uses DailyNotification.clearApiNotifications(), which cancels only `api_*`
* pending/delivered state (and Android persisted api_* schedule rows). It does
* not touch daily reminders, dual/New Activity schedules, AlertSearch, or FCM.
*/
import { Capacitor } from "@capacitor/core";
import { Preferences } from "@capacitor/preferences";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { logger } from "@/utils/logger";
/** Preferences key; written only after a successful native cleanup. */
export const LEGACY_API_NOTIFICATIONS_CLEANUP_KEY =
"legacy_api_notifications_cleanup_v1";
const DONE_VALUE = "1";
let inFlight: Promise<void> | null = null;
/**
* Idempotent startup hook. Safe when zero `api_*` schedules exist.
* On failure, leaves the marker unset so a later launch can retry.
*/
export async function runLegacyApiNotificationsCleanupOnce(): Promise<void> {
if (inFlight) {
return inFlight;
}
inFlight = runCleanup().finally(() => {
inFlight = null;
});
return inFlight;
}
async function runCleanup(): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return;
}
try {
const existing = await Preferences.get({
key: LEGACY_API_NOTIFICATIONS_CLEANUP_KEY,
});
if (existing.value === DONE_VALUE) {
return;
}
} catch (error) {
logger.warn(
"[legacyApiNotificationsCleanup] Could not read migration marker; skipping this launch",
error,
);
return;
}
const clearApiNotifications = (
DailyNotification as {
clearApiNotifications?: () => Promise<void>;
}
).clearApiNotifications;
if (typeof clearApiNotifications !== "function") {
logger.warn(
"[legacyApiNotificationsCleanup] clearApiNotifications unavailable; will retry on a later launch",
);
return;
}
try {
await clearApiNotifications.call(DailyNotification);
await Preferences.set({
key: LEGACY_API_NOTIFICATIONS_CLEANUP_KEY,
value: DONE_VALUE,
});
logger.info(
"[legacyApiNotificationsCleanup] Cleared legacy api_* notification state",
);
} catch (error) {
logger.warn(
"[legacyApiNotificationsCleanup] Cleanup failed; will retry on next startup",
error,
);
}
}
@@ -11,7 +11,7 @@ import { getNotificationDebugOverrideHeaders } from "./NotificationDebugConfig";
import { shouldBypassNotificationAuth } from "./notificationApiDebugMode";
import { logNotification } from "./NotificationDebugEvents";
export type NotificationRequestKind = "register" | "refresh";
export type NotificationRequestKind = "register";
export type NotificationApiHeadersResult =
| {
@@ -133,32 +133,17 @@ export async function getNotificationApiHeaders(
};
}
export function logNotificationRequestAuthenticated(
kind: NotificationRequestKind,
): void {
logNotification(
kind === "register"
? "Register request authenticated"
: "Refresh request authenticated",
);
}
export function logNotificationAuthFailure(
kind: NotificationRequestKind,
_kind: NotificationRequestKind,
message: string,
): void {
const verb = kind === "register" ? "Register" : "Refresh";
logNotification(`${verb} auth unavailable: ${message}`);
logNotification(`Register auth unavailable: ${message}`);
}
export function logWaitingForAuthBeforeRegistration(): void {
logNotification("Waiting for auth before registration");
}
export function logSkippingRefreshDueToMissingAuth(): void {
logNotification("Skipping refresh due to missing auth");
}
export function httpAuthErrorMessage(status: number): string {
if (status === 401) {
return "unauthorized (expired or invalid auth)";
@@ -1,5 +1,5 @@
/**
* Defers notification register/refresh until app auth (active DID + Bearer) is available.
* Defers FCM token registration until app auth (active DID + Bearer) is available.
* Bounded retries avoid racing startup and prevent infinite loops.
*/
@@ -24,9 +24,6 @@ export function logPushNotificationReceived(notification: {
title: notification.title,
dataType: type,
});
if (type === "WAKEUP_PING") {
logNotification("WAKEUP_PING received — will trigger refresh");
}
}
export function logPushNotificationActionPerformed(action: {
@@ -74,44 +71,6 @@ export function logTokenRegistrationFailure(
});
}
export function logRefreshStarted(source?: string): void {
logNotification(source ? `Refresh started (${source})` : "Refresh started");
}
function elapsedMsSince(startedAt: number): number {
return performance.now() - startedAt;
}
export function logRefreshSuccess(
startedAt: number,
scheduledCount: number,
source?: string,
): void {
const elapsedMs = Math.round(elapsedMsSince(startedAt));
const message = source
? `Refresh completed (${source}) in ${elapsedMs}ms (scheduled ${scheduledCount})`
: `Refresh completed in ${elapsedMs}ms (scheduled ${scheduledCount})`;
logNotification(message);
}
export function logRefreshFailure(
startedAt: number,
errorMessage: string,
status?: number,
source?: string,
): void {
const statusPart = status != null ? ` HTTP ${status}` : "";
const elapsedMs = Math.round(elapsedMsSince(startedAt));
const message = source
? `Refresh failed (${source}) in ${elapsedMs}ms: ${errorMessage}${statusPart}`
: `Refresh failed in ${elapsedMs}ms: ${errorMessage}${statusPart}`;
logNotification(message);
}
export function logNotificationClearing(method: string): void {
logNotification(`Clearing notifications via ${method}`);
}
export function logScheduleReplacement(count: number): void {
logNotification(`Schedule replacement: ${count} notification(s)`);
}