Compare commits

..
38 changed files with 2727 additions and 2066 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ This guard is in `android/app/build.gradle`. Do NOT change this conditional to a
`google-services.json` is intentionally excluded from git (`android/.gitignore`). Never commit it.
Full details, incident history, and F-Droid notes: `doc/development/android-firebase-gms.md`
Full details, incident history, and F-Droid notes: `doc/android-firebase-gms.md`
## Android Build — MLKit Barcode Scanner
+44 -22
View File
@@ -1084,6 +1084,11 @@ npm run build:ios:assets # Generate assets only
npm run build:ios:deploy # Build and deploy to connected device
```
The iOS asset command reads source artwork from `resources/` explicitly. It
generates the default 1024×1024 App Store icon, from which Xcode derives the
required device sizes, and installs any available appearance variants under
`ios/App/App/Assets.xcassets/AppIcon.appiconset/`.
#### Automated Build Script
The recommended way to build for iOS is using the automated build script:
@@ -1127,7 +1132,7 @@ If you need to build manually or want to understand the individual steps:
##### 0. First time (or if dependencies change)
- `pkgx +rubygems.org +pod zsh`
- `pkgx +rubygems.org +pod /bin/zsh`
- ... and you may have to fix these, especially with pkgx:
@@ -1141,7 +1146,7 @@ export GEM_PATH=$shortened_path
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
```bash
cd ios/App && xcrun agvtool new-version 70 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.4;/g" App.xcodeproj/project.pbxproj && cd -
cd ios/App && xcrun agvtool new-version 72 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.6;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
@@ -1162,14 +1167,20 @@ npm run build:ios:prod
3.2. Use Xcode to release.
- Someday: Under "General" we want to rename a bunch of things to "Time Safari"
- Someday: Under "General" we want to rename a bunch of things to "Giftopia"
- Choose Product -> Destination -> Any iOS Device
- Choose Product -> Archive
- This will trigger a build and take time, needing user's "login" keychain
password (user's login password), repeatedly.
- Required password entry 26 times.
- It can get stuck on uploading SPI; try canceling and restarting.
- If it fails with `building for 'iOS', but linking in dylib
(.../.pkgx/zlib.net/v1.3.0/lib/libz.1.3.dylib) built for 'macOS'` then run
XCode outside that terminal (ie. not with `npx cap open ios`).
- Before distributing, confirm the archive carries production server
defaults. Xcode packages whatever web bundle is in `ios/App/App/public`,
even one from a `--test` build:
`./scripts/check-prod-bundle.sh ios/App/App/public`
- Click Distribute -> App Store Connect
- In AppStoreConnect, add the build to the distribution. You may have to remove
the current build with the "-" when you hover over it, then "Add Build" with the
@@ -1319,6 +1330,7 @@ npm run build:android:prod # Production build (builds for production e
# Auto-run builds
npm run build:android:test:run # Test build with auto-run (builds then runs on emulator)
npm run build:android:prod:run # Production build with auto-run (builds then runs on emulator)
# Auto-run targets the one connected device; with none or several it prompts, or exits 9 without a terminal
# Debug and release builds
npm run build:android:debug # Debug build (builds debug APK)
@@ -1422,8 +1434,8 @@ The recommended way to build for Android is using the automated build script:
##### 1. Bump the version in package.json, then update these versions & run:
```bash
perl -p -i -e 's/versionCode .*/versionCode 70/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.4.4"/g' android/app/build.gradle
perl -p -i -e 's/versionCode .*/versionCode 72/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.4.6"/g' android/app/build.gradle
```
##### 2. Build
@@ -1442,35 +1454,38 @@ npx cap open android
##### 4. Use Android Studio to build and run on emulator or device
## Android Build from the console
#### Android Build from the console
```bash
cd android
./gradlew clean
./gradlew build -Dlint.baselines.continue=true
cd -
```
`cd android`
... or, to create the `aab` file, `bundle` instead of `build`:
* Note that this currently fails: `./gradlew build`
To create the `aab` file, `bundle` instead of `build` (but not signed and not usable in Play Store):
```bash
./gradlew bundleDebug -Dlint.baselines.continue=true
```
... or, to create a signed release:
So, to create a signed `aab` release:
- Setup by adding the app/gradle.properties.secrets file (see properties at top
of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
- Then `bundleRelease`:
- Then `bundleRelease` or `bundleDebug`:
```bash
cd android
# if you use pkgx: pkgx +openjdk.org /bin/zsh
./gradlew bundleRelease -Dlint.baselines.continue=true -PfirebaseEnabled
cd -
```
... and find your `aab` file at app/build/outputs/bundle/release
* Gradle packages whatever web bundle was last synced into
`android/app/src/main/assets/public`, even one from a `--test` build. Before
uploading, confirm it has production server defaults:
`./scripts/check-prod-bundle.sh android/app/build/outputs/bundle/release/app-release.aab`
* `build-android.sh` runs `./gradlew clean`, which deletes any earlier `aab`, so
run `bundleRelease` after the build script finishes.
* Note that F-Droid builds should omit `-PfirebaseEnabled`.
At play.google.com/console:
@@ -1607,10 +1622,13 @@ Use the commands above to check and fix code quality issues.
4. **Native Build**: Platform-specific compilation
5. **Package Creation**: APK/IPA generation
`resources/` is the canonical source for app icons and splash screens.
Do not keep a legacy top-level `assets/` directory unless it is intentionally
used: `@capacitor/assets` prioritizes `assets/` over `resources/`, which can
prevent the canonical assets from being discovered.
`resources/` is the canonical source for app icons and splash screens. Build
scripts select it explicitly with `--assetPath resources`. Use the same option
when invoking the generator directly:
```bash
npx capacitor-assets generate --assetPath resources
```
## Architecture Environment Configuration
@@ -1822,7 +1840,7 @@ npm run build:android:assets
- Configure signing keys
- Check device/emulator setup
## Additional Resources
## Additional Documentation
- [Electron Build Patterns](doc/electron-build-patterns.md)
- [iOS Build Scripts](doc/ios-build-scripts.md)
@@ -2212,6 +2230,8 @@ command chaining, following DRY principles.
- `7` - Asset generation failed
- `8` - Android Studio launch failed
- `9` - Resource check failed
- `10` - Production bundle has test/dev server defaults (`--prod` only; see
`scripts/check-prod-bundle.sh`)
### A.4 build-ios.sh
@@ -2272,6 +2292,8 @@ command chaining, following DRY principles.
- **Clean Build**: Removes Xcode build artifacts and DerivedData
- **Asset Generation**: Creates platform-specific assets
- **Simulator Support**: Launches iOS Simulator for testing
- **Production Bundle Check**: With `--prod`, exits with code `10` if the synced
web bundle has test/dev server defaults (`scripts/check-prod-bundle.sh`)
### A.5 common.sh
+7 -2
View File
@@ -6,13 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [?] - 2026
## [1.4.6] - 2026.09.27
### Fixed
- Incorrect 'test' server URLs on production.
## [1.4.5] - 2026.09.24
### Added
- Full flow for setting up SMS notifications: phone registration and code
verification, delegated alertSearch JWT batches with a send hour, and
revocation to stop the texts
- Notification debug panel action to mint and upload a push-channel alertSearch
authorization, built on the same batch minter as the SMS channel
authorization, built on the same batch minter as the SMS channel (hidden by dev flag)
### Fixed
- Native build scripts set NODE_ENV, so `import.meta.env.DEV` matches the build
mode instead of always reporting a production build
+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 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.
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.
**Access:** **Account** → enable **Show All General Advanced Functions** → **Notification Debug Panel**.
+2 -2
View File
@@ -37,8 +37,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 70
versionName "1.4.4"
versionCode 72
versionName "1.4.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -54,5 +54,9 @@
{
"pkg": "SharedImage",
"classpath": "app.timesafari.sharedimage.SharedImagePlugin"
},
{
"pkg": "NotificationInspector",
"classpath": "app.timesafari.notifications.NotificationInspectorPlugin"
}
]
@@ -1,5 +1,6 @@
package app.timesafari.notifications;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
@@ -12,5 +13,20 @@ public class NotificationInspectorPlugin extends Plugin {
call.unimplemented(
"Pending notification inspection is currently implemented on iOS only");
}
}
/**
* Whether this build carries a Firebase config, i.e. was built with
* -PfirebaseEnabled. Without it, PushNotifications.register() throws on the
* Capacitor plugin thread and kills the app, so JS must check this first.
* The google-services Gradle plugin generates the google_app_id resource,
* which is what Firebase's own auto-initialization looks for.
*/
@PluginMethod
public void isFirebaseConfigured(PluginCall call) {
int resId = getContext().getResources().getIdentifier(
"google_app_id", "string", getContext().getPackageName());
JSObject ret = new JSObject();
ret.put("configured", resId != 0);
call.resolve(ret);
}
}
+16
View File
@@ -42,6 +42,16 @@ This means a single Play Store AAB (`bundleRelease -PfirebaseEnabled`) covers bo
**F-Droid** is stricter: their build policy rejects any APK with GMS dependencies at the binary level, even with graceful degradation. F-Droid submission would require a separate `assembleRelease` build (no flag) and a dedicated F-Droid listing.
## Push registration in builds without Firebase
A build without `-PfirebaseEnabled` contains the `@capacitor/push-notifications` plugin, but no Firebase config. In that build, `PushNotifications.register()` throws `Default FirebaseApp is not initialized` on the Capacitor plugin thread, which kills the app; a JS `try/catch` cannot intercept it.
The JS therefore asks native code first. `NotificationInspector.isFirebaseConfigured()` (`android/app/src/main/java/app/timesafari/notifications/NotificationInspectorPlugin.java`) reports whether the `google_app_id` string resource exists; the google-services Gradle plugin generates that resource only when Firebase is enabled, and it is the same signal Firebase's auto-initialization uses. `firebaseMessagingClient.ts` skips `register()` on Android when it is absent (or when the check fails), logging `No Firebase config in this Android build; skipping push registration`. Listener setup and the notification permission request run either way, since local daily notifications need that permission.
`NotificationInspector` must be listed in `scripts/restore-local-plugins.js`: `MainActivity` registers plugins after the Capacitor bridge is built, so a plugin missing from `capacitor.plugins.json` reports "not implemented on android" and the check falls back to skipping push.
Any code that calls `PushNotifications.register()` or other `FirebaseMessaging` APIs on Android must go through the same check.
## The `google-services.json` file
- Gitignored (`android/.gitignore` line 80) — never commit it
@@ -64,3 +74,9 @@ This is an accepted trade-off. Removing it would require either forking the plug
Jose Olarte III's `notify-api` branch placed a production `google-services.json` in `android/` to test Firebase Cloud Messaging. The branch was never merged to `master`, but because the file is gitignored it persisted on disk after switching branches. At the time, the Gradle conditional activated Firebase based on file presence alone (no opt-in flag), so all subsequent local builds embedded Firebase and required Google Play Services. This silently broke APK/Aurora/Zapstore distribution.
**Fix applied:** deleted `google-services.json` from disk, changed the Gradle conditional to require `-PfirebaseEnabled`, and documented the rule in `AGENTS.md`.
## Incident: September 2026
After the June fix, local builds without `-PfirebaseEnabled` (including every `scripts/build-android.sh` build and the FOSS APKs from v1.4.4 on) crashed at launch as soon as notification permission was granted, because startup code called `PushNotifications.register()` unconditionally. Found while running `npm run build:android:prod:run` on an emulator.
**Fix applied:** the `isFirebaseConfigured()` gate described in "Push registration in builds without Firebase", plus adding `NotificationInspector` to `scripts/restore-local-plugins.js` so the check is reachable on Android.
+2 -2
View File
@@ -2,10 +2,10 @@
**Created:** 2026-06-02
**Source document:** [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md)
**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.
**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 only — does not replace or modify the iOS guide.
---
## Executive summary
+148 -88
View File
@@ -1,10 +1,8 @@
# Local Android Testing with ngrok (notification-wakeup-service)
**Last updated:** 2026-09-24 (retired WAKEUP_PING → refresh → `api_*` consumption)
**Last updated:** 2026-06-02 (verification checklist, end-to-end test)
**Audience:** Developers on **crowd-funder-for-time-pwa**, **daily-notification-plugin**, and **notification-wakeup-service**
**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.
**Goal:** Exercise FCM wakeup (`WAKEUP_PING`), FCM token registration, and notification refresh against a Mac-hosted backend reachable from a physical Android device.
**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.
@@ -12,7 +10,7 @@
## Architecture overview
End-to-end flow when testing FCM registration and wakeup **delivery** on a physical Android phone:
End-to-end flow when testing New Activity / silent wake on a physical Android phone:
```text
┌─────────────────────┐ HTTPS ┌──────────────────────┐
@@ -21,7 +19,7 @@ End-to-end flow when testing FCM registration and wakeup **delivery** on a physi
│ wakeup-service │ └──────────┬───────────┘
└──────────┬──────────┘ │
│ │ fetch
│ │ POST /notifications/register
│ POST /notifications/refresh │ POST /notifications/register
│ ▼
│ ┌──────────────────────┐
│ │ crowd-funder-for- │
@@ -30,7 +28,7 @@ End-to-end flow when testing FCM registration and wakeup **delivery** on a physi
│ └──────────┬───────────┘
│ │
│ FCM data message (WAKEUP_PING) │ daily-notification-plugin
▼ ▼ (Daily Reminder / dual / fetcher)
▼ ▼ (local schedule replace)
┌─────────────────────┐ ┌──────────────────────┐
│ Firebase Cloud │ ──FCM────────► │ Android device │
│ Messaging │ direct │ app.timesafari.app │
@@ -43,16 +41,18 @@ Unlike iOS, Android does **not** use APNs. FCM delivers directly to the app via
| Repo | Role |
|------|------|
| **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()` |
| **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 |
### Android wakeup flow (current)
### Android wakeup flow (production path)
1. **notification-wakeup-service** `/debug/send-wakeup` sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
1. **notification-wakeup-service** (or `/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 logs `[Notifications] push handler ignored type=WAKEUP_PING`. It does **not** POST `/notifications/refresh` or schedule `api_*` notifications.
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.
Console and debug panel lines are prefixed with **`[Notifications]`** (see `NotificationDebugEvents.ts`).
@@ -290,11 +290,12 @@ 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 / send-wakeup JSON bodies (default on when unset in storage) |
| **Test Mode** | Sends `testMode: true/false` in register/refresh 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"` |
| **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) |
| **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) |
| **Event Log** | Shared `[Notifications]` panel log (100 entries) |
Persistence: `localStorage` keys `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, and `notificationDebug.bypassAuth` (`NotificationDebugConfig.ts`).
@@ -313,13 +314,19 @@ 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 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.
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.
### WAKEUP_PING debug controls
**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.
Three panel actions exercise different segments of the wakeup pipeline. Use them to bisect failures (see [Troubleshooting §14](#fcm-message-not-received)).
Use **Send Real WAKEUP_PING** to confirm backend → FCM → Capacitor listener delivery. Do not expect `/notifications/refresh` or `api_*` rescheduling.
| 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.
### Send Real WAKEUP_PING
@@ -330,9 +337,9 @@ Use **Send Real WAKEUP_PING** to confirm backend → FCM → Capacitor listener
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()`, which logs `push handler ignored type=WAKEUP_PING`. There is no `refreshNotificationsWithDiagnostics` and no `applyNotificationRefreshPayload`.
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**).
That exercises **backend → FCM → Capacitor push listener** on Android without manual `curl` on the Mac. It does **not** reschedule local notifications.
That exercises the full **backend → FCM → Capacitor push listener → refresh request → notification rescheduling** path on Android without manual `curl` on the Mac.
**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)).
@@ -341,24 +348,27 @@ That exercises **backend → FCM → Capacitor push listener** on Android withou
Filter logcat (prefix is always `[Notifications]`):
```bash
adb logcat | grep -E '\[Notifications\].*(Real WAKEUP_PING|push handler ignored)'
adb logcat | grep -E '\[Notifications\].*(Real WAKEUP_PING|pushNotificationReceived|WAKEUP_PING|Refresh started|Refresh completed)'
```
On a **successful FCM delivery** run (HTTP success from the panel, then FCM delivery within ~30–120s), expect these key lines in order:
On a **successful end-to-end** 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] push handler ignored type=WAKEUP_PING
[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)
```
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.
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.
#### Send Real WAKEUP_PING vs delivery success
#### Send Real WAKEUP_PING vs end-to-end 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.
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.
**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.
**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.
### Programmatic override (optional)
@@ -445,11 +455,15 @@ On **API 33+**, `POST_NOTIFICATIONS` is a runtime permission ([section 7](#7-and
### Network connectivity
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.
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.
### 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 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/refresh smoke tests.
### Logcat
@@ -457,7 +471,7 @@ Doze, App Standby, and OEM battery menus are weak or absent on many emulators. U
adb logcat | grep -E '\[Notifications\]|\[FirebaseMessaging\]|\[NativeNotificationService\]'
```
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`.
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`.
---
@@ -485,7 +499,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.
OEMs add layers on top of AOSP. Aggressive battery management can delay **WAKEUP_PING** delivery or prevent the refresh `fetch` from running promptly.
| Vendor | Where to look (names vary by OS version) |
|--------|------------------------------------------|
@@ -501,8 +515,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 → `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)).
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)).
---
@@ -514,10 +528,12 @@ 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. 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.
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.
For a formal pass/fail sequence, use the [Verification Checklist](#11-verification-checklist) below.
@@ -533,12 +549,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 | Optional backend `/notifications/refresh` curl | §5 (backend only; app does not call this) |
| 6 | Locals scheduled | §6 (Daily Reminder / dual / fetcher — not refresh) |
| 5 | Refresh returns schedule data | §5 |
| 6 | Locals scheduled | §6 |
| 7 | Manual wakeup sends FCM | §7 |
| 8 | WAKEUP_PING delivered | §8 |
| 9 | _(retired)_ Replace after refresh | skipped |
| 10 | _(retired)_ Test mode frequent refreshes | skipped |
| 8 | WAKEUP_PING → refresh | §8 |
| 9 | Replace after refresh | §9 |
| 10 | Test mode frequent refreshes | §10 |
### 1. Backend reachable through ngrok
@@ -562,7 +578,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 `/debug/send-wakeup`.
**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`.
---
@@ -595,11 +611,12 @@ curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
---
### 5. Refresh endpoint (backend only; app does not call this)
### 5. Refresh endpoint returns schedule data
**Actions:**
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.
1. Tap **Refresh Notifications** in the panel (or curl below).
2. Inspect ngrok response body.
```bash
curl -sS -X POST "$BASE/notifications/refresh" \
@@ -607,18 +624,18 @@ curl -sS -X POST "$BASE/notifications/refresh" \
-d '{"platform":"android","testMode":true}'
```
**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.
**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.
---
### 6. Local notifications are scheduled (not from refresh)
### 6. Local notifications are scheduled
**Actions:**
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).
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))`.
**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.
**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.
---
@@ -639,11 +656,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 delivery success](#send-real-wakeup_ping-vs-delivery-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 end-to-end success](#send-real-wakeup_ping-vs-end-to-end-success)).
---
### 8. WAKEUP_PING is delivered (no refresh)
### 8. WAKEUP_PING triggers refreshNotifications()
**Actions:**
@@ -652,28 +669,42 @@ curl -sS -X POST "$BASE/debug/send-wakeup" \
3. Filter logcat:
```bash
adb logcat | grep -E 'WAKEUP_PING|push handler ignored'
adb logcat | grep -E 'WAKEUP_PING|pushNotificationReceived|Refresh completed'
```
**Expected outcome (within ~30–120s, longer under Doze/OEM):**
1. `Real WAKEUP_PING success` (panel path)
2. `push handler ignored type=WAKEUP_PING`
3. ngrok: **no** app-initiated `POST /notifications/refresh`
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)`
Full expected logcat for **Send Real WAKEUP_PING**: [§6](#expected-logcat-output).
**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).
---
### 9. Existing notifications are replaced after refresh
**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()`.
**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.
---
### 10. Test mode produces frequent notification refreshes
**Retired.** `testMode` still goes on register and send-wakeup bodies. It does not drive app-side `api_*` refresh cadences.
**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.
---
@@ -708,23 +739,31 @@ npm run dev
8. Paste `BASE` → **Save Backend URL**; enable **Test Mode**.
### Phase C — Register
### Phase C — Register and schedule
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).
### Phase D — FCM wakeup delivery
11. **Refresh Notifications** → Event Log `scheduled N`, N ≥ 1; ngrok refresh **200** with `nextNotifications`.
11. Background app (Home).
12. **Pending Notification Inspector** → **Refresh** → future alarm(s) listed.
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.
### Phase D — FCM wakeup path
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`.
13. Background app (Home).
### Phase E — Optional local notification proof
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.
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.
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).
### End-to-end pass criteria
@@ -732,9 +771,9 @@ npm run dev
|-------|------|
| A | Health OK local + ngrok |
| B | Override URL + testMode active |
| C | Register succeeded |
| D | Wakeup curl/panel OK → logcat ignored-type line |
| E | Optional visible Daily Reminder / New Activity notification |
| C | Register + refresh + pending list populated |
| D | Wakeup curl OK → logcat refresh chain → pending updated |
| E | Optional visible notification at scheduled time |
---
@@ -765,7 +804,7 @@ curl -sS -X POST "$BASE/notifications/register" \
}'
```
### Refresh (backend only; app does not consume)
### Refresh (mirror app payload)
```bash
curl -sS -X POST "$BASE/notifications/refresh" \
@@ -776,7 +815,19 @@ curl -sS -X POST "$BASE/notifications/refresh" \
}'
```
This exercises **notification-wakeup-service** only. The app does **not** call `applyNotificationRefreshPayload` or schedule `api_*` from the response.
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`).
### Send wakeup push (debug)
@@ -791,7 +842,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 may still send FCM data including `type: "WAKEUP_PING"`; the app logs it as ignored and does not refresh.
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`.
---
@@ -848,28 +899,35 @@ Compare with panel **Backend Status** and Event Log error text.
### Refresh endpoint failures
**Symptoms:** Mac `curl` of `/notifications/refresh` fails; ngrok missing that path. This is a **backend** issue. The app does not POST refresh.
**Symptoms:** **Refresh Notifications** fails; Event Log HTTP error; no `scheduled X` line; ngrok missing `POST /notifications/refresh`.
**Verification:** `curl` health and refresh from the Mac; confirm the service still ships that route.
**Likely causes:** Stale ngrok URL; backend down; 404/wrong path; JWT/native fetcher not configured; refresh auth failure.
**Fixes:** Fix backend URL and health. App scheduling uses Daily Reminder / dual / native fetcher, not this payload.
**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.
---
### FCM message not received
**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.
**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.
**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 the ignored-type log indicates a **delivery** failure, not a failed wakeup API call ([§6](#send-real-wakeup_ping-vs-delivery-success)).
**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)).
**Verification:**
1. App **backgrounded** (Home), not force-stopped.
2. Panel FCM token matches token used by server/register.
3. Wait 30–120s (longer on Doze/OEM).
4. Battery **Unrestricted** and OEM autostart enabled for test device.
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.
**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.
@@ -889,28 +947,30 @@ Compare with panel **Backend Status** and Event Log error text.
### Notifications not appearing
**Symptoms:** Daily Reminder or New Activity configured but no visible notification at fire time; Pending Inspector empty or stale.
**Symptoms:** Refresh succeeds (`scheduled X` in log) 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 for Daily Reminder / dual identifiers (not `api_*` after Phase 4 cleanup).
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).
**Fixes:** Grant `POST_NOTIFICATIONS`; check `SCHEDULE_EXACT_ALARM` / alarm permission per plugin docs. Do not expect wakeup refresh payloads to populate the inspector.
**Fixes:** Grant `POST_NOTIFICATIONS`; check `SCHEDULE_EXACT_ALARM` / alarm permission per plugin docs; fix refresh payload; test with nearer timestamps via backend `testMode`.
---
### Duplicate notifications
**Symptoms:** Multiple identical local notifications.
**Symptoms:** Multiple identical local notifications; Event Log shows repeated refresh lines.
**Likely causes:** Separate Daily Reminder vs New Activity schedules; duplicate dual config; leftover `api_*` before Phase 4 cleanup.
**Likely causes:** Multiple `WAKEUP_PING` deliveries; repeated manual **Refresh**; flood test; separate Daily Reminder vs New Activity schedules.
**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.
**Verification:** Event Log count of refresh completions; ngrok inspect for duplicate `POST /notifications/refresh`.
**Fixes:** Confirm Daily Reminder vs dual settings. WAKEUP_PING no longer stacks `api_*` refreshes.
**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.
---
@@ -946,7 +1006,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` | Push delivery hook (logs ignored types; no refresh) |
| `src/services/notifications/NativeNotificationService.ts` | `refreshNotifications`, `WAKEUP_PING`, `applyNotificationRefreshPayload` |
| `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 |
+51 -32
View File
@@ -1,16 +1,14 @@
# Local iOS Testing with ngrok (notification-wakeup-service)
**Last updated:** 2026-09-24 (retired WAKEUP_PING → refresh → `api_*` consumption)
**Last updated:** 2026-05-18
**Audience:** Developers on **crowd-funder-for-time-pwa**, **daily-notification-plugin**, and **notification-wakeup-service**
**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.
**Goal:** Exercise silent push wake (`WAKEUP_PING`), FCM token registration, and notification refresh against a Mac-hosted backend reachable from a physical iPhone.
---
## Architecture overview
End-to-end flow when testing FCM registration and wakeup **delivery** on a physical iPhone:
End-to-end flow when testing New Activity / silent wake on a physical iPhone:
```text
┌─────────────────────┐ HTTPS ┌──────────────────────┐
@@ -19,7 +17,7 @@ End-to-end flow when testing FCM registration and wakeup **delivery** on a physi
│ wakeup-service │ └──────────┬───────────┘
└──────────┬──────────┘ │
│ │ fetch
│ │ POST /notifications/register
│ POST /notifications/refresh │ POST /notifications/register
│ ▼
│ ┌──────────────────────┐
│ │ crowd-funder-for- │
@@ -28,7 +26,7 @@ End-to-end flow when testing FCM registration and wakeup **delivery** on a physi
│ └──────────┬───────────┘
│ │
│ FCM data message (WAKEUP_PING) │ daily-notification-plugin
▼ ▼ (Daily Reminder / dual / fetcher)
▼ ▼ (local schedule replace)
┌─────────────────────┐ ┌──────────────────────┐
│ Firebase Cloud │ ──APNs──────► │ iPhone (physical) │
│ Messaging │ silent push │ app.timesafari │
@@ -39,16 +37,18 @@ End-to-end flow when testing FCM registration and wakeup **delivery** on a physi
| Repo | Role |
|------|------|
| **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()` |
| **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 |
### Silent wake sequence (current)
### Silent wake sequence (production path)
1. Backend `/debug/send-wakeup` sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
1. Backend (or `/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. Handler logs `[Notifications] push handler ignored type=WAKEUP_PING`. It does **not** POST `/notifications/refresh` or schedule `api_*` notifications.
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.
Console and debug panel lines are prefixed with **`[Notifications]`** (see `NotificationDebugEvents.ts`).
@@ -297,11 +297,12 @@ 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 / send-wakeup JSON bodies (default on when unset in storage) |
| **Test Mode** | Sends `testMode: true/false` in register/refresh 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 |
| **Upload AlertSearch Authorization** | Uploads AlertSearch delegated JWTs |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; FCM/APNs delivery diagnostic only |
| **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) |
| **Event Log** | Shared `[Notifications]` panel log (100 entries) |
Persistence: `localStorage` keys `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, and `notificationDebug.bypassAuth` (`NotificationDebugConfig.ts`).
@@ -351,7 +352,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. The app logs `WAKEUP_PING` as ignored and does not refresh.
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"`).
---
@@ -377,9 +378,14 @@ 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).
### Send Real WAKEUP_PING (FCM/APNs diagnostic)
### Two “Simulate WAKEUP_PING” buttons
**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.
| 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.
---
@@ -389,10 +395,11 @@ Silent/data pushes used for wake typically use a **content-available** style pay
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. 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.
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.
---
@@ -423,7 +430,7 @@ curl -sS -X POST "$BASE/notifications/register" \
}'
```
### Refresh (backend only; app does not consume)
### Refresh (mirror app payload)
```bash
curl -sS -X POST "$BASE/notifications/refresh" \
@@ -434,7 +441,19 @@ curl -sS -X POST "$BASE/notifications/refresh" \
}'
```
This exercises **notification-wakeup-service** only. The app does **not** call `applyNotificationRefreshPayload` or schedule `api_*` from the response.
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`).
### Send wakeup push (debug)
@@ -459,8 +478,8 @@ Confirm parameters (token vs deviceId, auth headers) in that repo’s README or
| Symptom | Checks |
|---------|--------|
| 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) |
| Network error in Event Log | ngrok running? URL saved without typo/trailing slash? |
| HTTP 404 | Tunnel port matches backend `PORT`; path is `/notifications/refresh` |
| 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 |
@@ -476,15 +495,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"` (logged as ignored in `NativeNotificationService.ts`)
- FCM message includes `data.type = "WAKEUP_PING"` (see `NativeNotificationService.ts`)
- Server actually sent to the **same** FCM token shown in the debug panel
- Wait 30–120s — delivery is not instant
- Confirm Xcode shows `push handler ignored type=WAKEUP_PING` (there is no Simulate WAKEUP_PING / refresh API in the app)
- Try **Simulate WAKEUP_PING** (refresh API) to isolate app/plugin from FCM/APNs
### Notifications duplicating
- 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()`)
- 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`
### Stale ngrok URL
@@ -506,7 +525,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` | Push delivery hook (logs ignored types; no refresh) |
| `src/services/notifications/NativeNotificationService.ts` | Refresh, `WAKEUP_PING`, schedule replace |
| `src/services/notifications/firebaseMessagingClient.ts` | Capacitor push listeners |
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
| `src/main.capacitor.ts` | Native push init at startup |
+19 -14
View File
@@ -1,16 +1,16 @@
# Notification Debug Panel
**Created:** 2026-07-07
**Updated:** 2026-09-24
**Audience:** Developers testing notification registration, AlertSearch authorization upload, and FCM delivery diagnostics on native (iOS/Android) dev builds.
**Updated:** 2026-07-22
**Audience:** Developers testing notification registration, refresh, and WAKEUP_PING flows on native (iOS/Android) dev builds.
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).
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.
---
## Notification API base URL
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`.
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`.
### 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/alert-authorization`, `/debug/send-wakeup`, etc.) obtain headers through `getNotificationApiHeaders()` in `notificationApiAuth.ts`.
All notification API requests (`/notifications/register`, `/notifications/refresh`, `/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 send-wakeup requests include `"testMode": true` in the JSON body. The backend can use this to route test traffic separately from production.
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.
Test Mode is **independent of authentication**. It does not control whether `Authorization` headers are sent.
@@ -124,8 +124,9 @@ 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). |
| **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. |
| **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. |
**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).
@@ -135,8 +136,12 @@ Example: `https://abc123.ngrok-free.app` or `http://127.0.0.1:3000`
| Section | Purpose |
|---------|---------|
| **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. |
| **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. |
---
@@ -193,11 +198,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 show delivery
### Register succeeds but Send Real WAKEUP_PING does not trigger refresh
**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).
**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.
**Checks:** App backgrounded (not force-stopped); FCM token matches registration; Firebase / Play services available.
**Checks:** App backgrounded (not force-stopped); FCM token matches registration; **Simulate WAKEUP_PING (Local)** works (isolates FCM from refresh API).
See platform-specific guides for extended ngrok and FCM workflows:
@@ -217,7 +222,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` | Push delivery hook (logs ignored types; no refresh) |
| `src/services/notifications/NativeNotificationService.ts` | `POST /notifications/refresh`, WAKEUP_PING handler |
---
+27 -16
View File
@@ -72,7 +72,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
C86585E32ED456DE00824752 /* Exceptions for "TimeSafariShareExtension" folder in "TimeSafariShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
@@ -82,7 +82,18 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
C86585E32ED456DE00824752 /* Exceptions for "TimeSafariShareExtension" folder in "TimeSafariShareExtension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = TimeSafariShareExtension;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -177,7 +188,7 @@
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
2B3F98670AF3508A35AC3248 /* [CP] Embed Pods Frameworks */,
04A558C9E2DEC92B5B2AFBE4 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -294,7 +305,7 @@
shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" \n";
showEnvVarsInLog = 0;
};
2B3F98670AF3508A35AC3248 /* [CP] Embed Pods Frameworks */ = {
04A558C9E2DEC92B5B2AFBE4 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -455,7 +466,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -512,7 +523,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -530,7 +541,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 70;
CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -541,7 +552,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.4;
MARKETING_VERSION = 1.4.6;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -560,7 +571,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 70;
CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -571,7 +582,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.4;
MARKETING_VERSION = 1.4.6;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -589,21 +600,21 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 70;
CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.4.4;
MARKETING_VERSION = 1.4.6;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
@@ -627,21 +638,21 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 70;
CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.4.4;
MARKETING_VERSION = 1.4.6;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
-4
View File
@@ -82,10 +82,6 @@ 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: 5736811d271d5309d3e2de8f3eefdbb6632086a3
PODFILE CHECKSUM: abe640043e6b8adea745693d980ead03251912f9
COCOAPODS: 1.16.2
+1462 -1522
View File
File diff suppressed because it is too large Load Diff
+118 -117
View File
@@ -1,138 +1,139 @@
{
"name": "giftopia",
"version": "1.4.4",
"version": "1.4.7-beta",
"description": "Giftopia App",
"author": {
"name": "Gift Economies Team"
},
"scripts": {
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"type-check": "tsc --noEmit",
"type-check:vue": "vue-tsc --noEmit",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
"test:prerequisites": "node scripts/check-prerequisites.js",
"test:unit": "jest",
"check:dependencies": "./scripts/check-dependencies.sh",
"deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
"test:all": "npm run lint && npm run type-check && npm run type-check:vue && npm run test:unit && npm run test:web && npm run test:mobile && echo '\n\n\nGotta add the performance tests'",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on",
"test:mobile": "./scripts/test-mobile.sh",
"test:android": "node scripts/test-android.js",
"test:ios": "node scripts/test-ios.js",
"check:android-device": "adb devices | grep -w 'device' || (echo 'No Android device connected' && exit 1)",
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
"auto-run": "./scripts/auto-run.sh",
"auto-run:test": "./scripts/auto-run.sh --test",
"auto-run:prod": "./scripts/auto-run.sh --prod",
"auto-run:ios": "./scripts/auto-run.sh --platform=ios",
"auto-run:android": "./scripts/auto-run.sh --platform=android",
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && bash -c 'source scripts/common.sh && ensure_ios_capacitor_asset_directories' && npx capacitor-assets generate",
"assets:clean": "rimraf android/app/src/main/res/mipmap-* ios/App/App/Assets.xcassets/**/AppIcon*.png ios/App/App/Assets.xcassets/**/Splash*.png || true",
"assets:config": "npx tsx scripts/assets-config.ts",
"assets:validate": "npx tsx scripts/assets-validator.ts",
"assets:validate:android": "./scripts/build-android.sh --assets-only",
"assets:clean": "rimraf android/app/src/main/res/mipmap-* ios/App/App/Assets.xcassets/**/AppIcon*.png ios/App/App/Assets.xcassets/**/Splash*.png || true",
"build:ios": "./scripts/build-ios.sh",
"build:ios:dev": "./scripts/build-ios.sh --dev",
"build:ios:test": "./scripts/build-ios.sh --test",
"build:ios:test:run": "./scripts/build-ios.sh --test --auto-run",
"build:ios:prod": "./scripts/build-ios.sh --prod",
"build:ios:prod:run": "./scripts/build-ios.sh --prod --auto-run",
"build:ios:debug": "./scripts/build-ios.sh --debug",
"build:ios:debug:run": "./scripts/build-ios.sh --debug --auto-run",
"build:ios:release": "./scripts/build-ios.sh --release",
"build:ios:release:run": "./scripts/build-ios.sh --release --auto-run",
"build:ios:studio": "./scripts/build-ios.sh --studio",
"build:ios:ipa": "./scripts/build-ios.sh --ipa",
"build:ios:clean": "./scripts/build-ios.sh --clean",
"build:ios:sync": "./scripts/build-ios.sh --sync",
"build:ios:assets": "./scripts/build-ios.sh --assets",
"build:ios:deploy": "./scripts/build-ios.sh --deploy",
"build:ios:dev:custom": "./scripts/build-ios.sh --dev --api-ip",
"build:ios:test:custom": "./scripts/build-ios.sh --test --api-ip",
"build:ios:dev:run:custom": "./scripts/build-ios.sh --dev --api-ip --auto-run",
"build:ios:test:run:custom": "./scripts/build-ios.sh --test --api-ip --auto-run",
"build:web": "./scripts/build-web.sh",
"build:web:dev": "./scripts/build-web.sh --dev",
"build:web:test": "./scripts/build-web.sh --test",
"build:web:prod": "./scripts/build-web.sh --prod",
"build:web:docker": "./scripts/build-web.sh --docker",
"build:web:docker:test": "./scripts/build-web.sh --docker:test",
"build:web:docker:prod": "./scripts/build-web.sh --docker:prod",
"build:web:serve": "./scripts/build-web.sh --serve",
"build:web:serve:test": "./scripts/build-web.sh --serve --test",
"build:web:serve:prod": "./scripts/build-web.sh --serve --prod",
"docker:up": "docker-compose up",
"docker:up:test": "npm run build:web:build -- --mode test && docker-compose up test",
"docker:up:prod": "npm run build:web:build -- --mode production && docker-compose up production",
"docker:down": "docker-compose down",
"docker:logs": "docker-compose logs -f",
"build:electron": "./scripts/build-electron.sh",
"build:electron:dev": "./scripts/build-electron.sh --dev",
"build:electron:dev:run": "./scripts/build-electron.sh --dev --auto-run",
"build:electron:test": "./scripts/build-electron.sh --test",
"build:electron:test:run": "./scripts/build-electron.sh --test --auto-run",
"build:electron:prod": "./scripts/build-electron.sh --prod",
"build:electron:prod:run": "./scripts/build-electron.sh --prod --auto-run",
"build:electron:windows": "./scripts/build-electron.sh --prod --windows",
"build:electron:windows:dev": "./scripts/build-electron.sh --dev --windows",
"build:electron:windows:test": "./scripts/build-electron.sh --test --windows",
"build:electron:windows:prod": "./scripts/build-electron.sh --prod --windows",
"build:electron:mac": "./scripts/build-electron.sh --prod --mac",
"build:electron:mac:dev": "./scripts/build-electron.sh --dev --mac",
"build:electron:mac:test": "./scripts/build-electron.sh --test --mac",
"build:electron:mac:prod": "./scripts/build-electron.sh --prod --mac",
"build:electron:linux": "./scripts/build-electron.sh --prod --linux",
"build:electron:linux:dev": "./scripts/build-electron.sh --dev --linux",
"build:electron:linux:test": "./scripts/build-electron.sh --test --linux",
"build:electron:linux:prod": "./scripts/build-electron.sh --prod --linux",
"build:electron:appimage": "./scripts/build-electron.sh --prod --appimage",
"build:electron:appimage:dev": "./scripts/build-electron.sh --dev --appimage",
"build:electron:appimage:test": "./scripts/build-electron.sh --test --appimage",
"build:electron:appimage:prod": "./scripts/build-electron.sh --prod --appimage",
"build:electron:deb": "./scripts/build-electron.sh --prod --deb",
"build:electron:deb:dev": "./scripts/build-electron.sh --dev --deb",
"build:electron:deb:test": "./scripts/build-electron.sh --test --deb",
"build:electron:deb:prod": "./scripts/build-electron.sh --prod --deb",
"build:electron:dmg": "./scripts/build-electron.sh --prod --dmg",
"build:electron:dmg:dev": "./scripts/build-electron.sh --dev --dmg",
"build:electron:dmg:test": "./scripts/build-electron.sh --test --dmg",
"build:electron:dmg:prod": "./scripts/build-electron.sh --prod --dmg",
"markdown:fix": "markdownlint-cli2 --fix",
"markdown:check": "markdownlint-cli2",
"markdown:setup": "./scripts/setup-markdown-hooks.sh",
"prepare": "husky",
"guard": "bash ./scripts/build-arch-guard.sh",
"guard:test": "bash ./scripts/build-arch-guard.sh --staged",
"guard:setup": "npm run prepare && echo '✅ Build Architecture Guard is now active!'",
"clean:android": "./scripts/uninstall-android.sh",
"clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true",
"clean:electron": "./scripts/build-electron.sh --clean",
"clean:all": "npm run clean:ios && npm run clean:android && npm run clean:electron",
"auto-run": "./scripts/auto-run.sh",
"auto-run:android": "./scripts/auto-run.sh --platform=android",
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
"auto-run:ios": "./scripts/auto-run.sh --platform=ios",
"auto-run:prod": "./scripts/auto-run.sh --prod",
"auto-run:test": "./scripts/auto-run.sh --test",
"build:android": "./scripts/build-android.sh",
"build:android:dev": "./scripts/build-android.sh --dev",
"build:android:test": "./scripts/build-android.sh --test",
"build:android:test:run": "./scripts/build-android.sh --test --auto-run",
"build:android:prod": "./scripts/build-android.sh --prod",
"build:android:prod:run": "./scripts/build-android.sh --prod --auto-run",
"build:android:aab": "./scripts/build-android.sh --aab",
"build:android:apk": "./scripts/build-android.sh --apk",
"build:android:assets": "./scripts/build-android.sh --assets",
"build:android:clean": "./scripts/build-android.sh --clean",
"build:android:debug": "./scripts/build-android.sh --debug",
"build:android:debug:run": "./scripts/build-android.sh --debug --auto-run",
"build:android:deploy": "./scripts/build-android.sh --deploy",
"build:android:dev": "./scripts/build-android.sh --dev",
"build:android:dev:custom": "./scripts/build-android.sh --dev --api-ip",
"build:android:dev:run:custom": "./scripts/build-android.sh --dev --api-ip --auto-run",
"build:android:prod": "./scripts/build-android.sh --prod",
"build:android:prod:run": "./scripts/build-android.sh --prod --auto-run",
"build:android:release": "./scripts/build-android.sh --release",
"build:android:release:run": "./scripts/build-android.sh --release --auto-run",
"build:android:studio": "./scripts/build-android.sh --studio",
"build:android:apk": "./scripts/build-android.sh --apk",
"build:android:aab": "./scripts/build-android.sh --aab",
"build:android:clean": "./scripts/build-android.sh --clean",
"build:android:sync": "./scripts/build-android.sh --sync",
"build:android:assets": "./scripts/build-android.sh --assets",
"build:android:deploy": "./scripts/build-android.sh --deploy",
"build:android:dev:custom": "./scripts/build-android.sh --dev --api-ip",
"build:android:test": "./scripts/build-android.sh --test",
"build:android:test:custom": "./scripts/build-android.sh --test --api-ip",
"build:android:dev:run:custom": "./scripts/build-android.sh --dev --api-ip --auto-run",
"build:android:test:run:custom": "./scripts/build-android.sh --test --api-ip --auto-run"
"build:android:test:run": "./scripts/build-android.sh --test --auto-run",
"build:android:test:run:custom": "./scripts/build-android.sh --test --api-ip --auto-run",
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
"build:electron": "./scripts/build-electron.sh",
"build:electron:appimage": "./scripts/build-electron.sh --prod --appimage",
"build:electron:appimage:dev": "./scripts/build-electron.sh --dev --appimage",
"build:electron:appimage:prod": "./scripts/build-electron.sh --prod --appimage",
"build:electron:appimage:test": "./scripts/build-electron.sh --test --appimage",
"build:electron:deb": "./scripts/build-electron.sh --prod --deb",
"build:electron:deb:dev": "./scripts/build-electron.sh --dev --deb",
"build:electron:deb:prod": "./scripts/build-electron.sh --prod --deb",
"build:electron:deb:test": "./scripts/build-electron.sh --test --deb",
"build:electron:dev": "./scripts/build-electron.sh --dev",
"build:electron:dev:run": "./scripts/build-electron.sh --dev --auto-run",
"build:electron:dmg": "./scripts/build-electron.sh --prod --dmg",
"build:electron:dmg:dev": "./scripts/build-electron.sh --dev --dmg",
"build:electron:dmg:prod": "./scripts/build-electron.sh --prod --dmg",
"build:electron:dmg:test": "./scripts/build-electron.sh --test --dmg",
"build:electron:linux": "./scripts/build-electron.sh --prod --linux",
"build:electron:linux:dev": "./scripts/build-electron.sh --dev --linux",
"build:electron:linux:prod": "./scripts/build-electron.sh --prod --linux",
"build:electron:linux:test": "./scripts/build-electron.sh --test --linux",
"build:electron:mac": "./scripts/build-electron.sh --prod --mac",
"build:electron:mac:dev": "./scripts/build-electron.sh --dev --mac",
"build:electron:mac:prod": "./scripts/build-electron.sh --prod --mac",
"build:electron:mac:test": "./scripts/build-electron.sh --test --mac",
"build:electron:prod": "./scripts/build-electron.sh --prod",
"build:electron:prod:run": "./scripts/build-electron.sh --prod --auto-run",
"build:electron:test": "./scripts/build-electron.sh --test",
"build:electron:test:run": "./scripts/build-electron.sh --test --auto-run",
"build:electron:windows": "./scripts/build-electron.sh --prod --windows",
"build:electron:windows:dev": "./scripts/build-electron.sh --dev --windows",
"build:electron:windows:prod": "./scripts/build-electron.sh --prod --windows",
"build:electron:windows:test": "./scripts/build-electron.sh --test --windows",
"build:ios": "./scripts/build-ios.sh",
"build:ios:assets": "./scripts/build-ios.sh --assets",
"build:ios:clean": "./scripts/build-ios.sh --clean",
"build:ios:debug": "./scripts/build-ios.sh --debug",
"build:ios:debug:run": "./scripts/build-ios.sh --debug --auto-run",
"build:ios:deploy": "./scripts/build-ios.sh --deploy",
"build:ios:dev": "./scripts/build-ios.sh --dev",
"build:ios:dev:custom": "./scripts/build-ios.sh --dev --api-ip",
"build:ios:dev:run:custom": "./scripts/build-ios.sh --dev --api-ip --auto-run",
"build:ios:ipa": "./scripts/build-ios.sh --ipa",
"build:ios:prod": "./scripts/build-ios.sh --prod",
"build:ios:prod:run": "./scripts/build-ios.sh --prod --auto-run",
"build:ios:release": "./scripts/build-ios.sh --release",
"build:ios:release:run": "./scripts/build-ios.sh --release --auto-run",
"build:ios:studio": "./scripts/build-ios.sh --studio",
"build:ios:sync": "./scripts/build-ios.sh --sync",
"build:ios:test": "./scripts/build-ios.sh --test",
"build:ios:test:custom": "./scripts/build-ios.sh --test --api-ip",
"build:ios:test:run": "./scripts/build-ios.sh --test --auto-run",
"build:ios:test:run:custom": "./scripts/build-ios.sh --test --api-ip --auto-run",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && bash -c 'source scripts/common.sh && ensure_ios_capacitor_asset_directories' && npx capacitor-assets generate --assetPath resources",
"build:web": "./scripts/build-web.sh",
"build:web:dev": "./scripts/build-web.sh --dev",
"build:web:docker": "./scripts/build-web.sh --docker",
"build:web:docker:prod": "./scripts/build-web.sh --docker:prod",
"build:web:docker:test": "./scripts/build-web.sh --docker:test",
"build:web:prod": "./scripts/build-web.sh --prod",
"build:web:serve": "./scripts/build-web.sh --serve",
"build:web:serve:prod": "./scripts/build-web.sh --serve --prod",
"build:web:serve:test": "./scripts/build-web.sh --serve --test",
"build:web:test": "./scripts/build-web.sh --test",
"check:android-device": "adb devices | grep -w 'device' || (echo 'No Android device connected' && exit 1)",
"check:dependencies": "./scripts/check-dependencies.sh",
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
"check:prod-bundle": "./scripts/check-prod-bundle.sh",
"clean:all": "npm run clean:ios && npm run clean:android && npm run clean:electron",
"clean:android": "./scripts/uninstall-android.sh",
"clean:electron": "./scripts/build-electron.sh --clean",
"clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true",
"deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
"docker:down": "docker-compose down",
"docker:logs": "docker-compose logs -f",
"docker:up": "docker-compose up",
"docker:up:prod": "npm run build:web:build -- --mode production && docker-compose up production",
"docker:up:test": "npm run build:web:build -- --mode test && docker-compose up test",
"guard": "bash ./scripts/build-arch-guard.sh",
"guard:setup": "npm run prepare && echo 'Build Architecture Guard is now active!'",
"guard:test": "bash ./scripts/build-arch-guard.sh --staged",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"markdown:check": "markdownlint-cli2",
"markdown:fix": "markdownlint-cli2 --fix",
"markdown:setup": "./scripts/setup-markdown-hooks.sh",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
"prepare": "husky",
"test:all": "npm run lint && npm run type-check && npm run type-check:vue && npm run test:unit && npm run test:web && npm run test:mobile && echo '\n\n\nGotta add the performance tests'",
"test:android": "node scripts/test-android.js",
"test:ios": "node scripts/test-ios.js",
"test:mobile": "./scripts/test-mobile.sh",
"test:prerequisites": "node scripts/check-prerequisites.js",
"test:unit": "jest",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on",
"type-check": "tsc --noEmit",
"type-check:vue": "vue-tsc --noEmit"
},
"lint-staged": {
"*.{js,ts,vue,css,json,yml,yaml}": "eslint --fix || true",
+7 -8
View File
@@ -46,20 +46,19 @@ Assets are automatically generated during the build process using `capacitor-ass
```bash
# Generate assets for all platforms
npx capacitor-assets generate
npx capacitor-assets generate --assetPath resources
# Generate assets for specific platform
npx capacitor-assets generate --ios
npx capacitor-assets generate --android
npx capacitor-assets generate --web
npx capacitor-assets generate --ios --assetPath resources
npx capacitor-assets generate --android --assetPath resources
npx capacitor-assets generate --web --assetPath resources
```
## Configuration
`resources/` is this project's canonical asset source. `@capacitor/assets`
prioritizes a top-level `assets/` directory over `resources/`, so a legacy
`assets/` directory can prevent these assets from being discovered. Remove that
directory when it is empty or obsolete.
`resources/` is this project's canonical asset source. Pass it explicitly with
`--assetPath resources`; `@capacitor/assets` otherwise prioritizes a top-level
`assets/` directory when one exists, even when that directory is empty.
## Version Control
+33 -13
View File
@@ -545,7 +545,7 @@ fi
# Handle assets-only mode
if [ "$ASSETS_ONLY" = true ]; then
log_info "Assets-only mode: generating assets"
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
safe_execute "Generating assets" "npx capacitor-assets generate --android --assetPath resources" || exit 7
log_success "Assets generation completed successfully!"
exit 0
fi
@@ -612,25 +612,31 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi
# Step 6: Clean Gradle build
# Step 6: Sync with Capacitor (before any Gradle assemble, which packages
# whatever web bundle and resources are in android/app/src/main at that moment)
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
# Step 6.5: Restore local plugins (capacitor.plugins.json gets overwritten by cap sync)
safe_execute "Restoring local plugins" "node scripts/restore-local-plugins.js" || exit 7
# Step 6.6: Verify the synced web bundle has production server defaults
if [ "$BUILD_MODE" = "production" ]; then
safe_execute "Checking production bundle" "./scripts/check-prod-bundle.sh android/app/src/main/assets/public" || exit 10
fi
# Step 7: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --android --assetPath resources" || exit 7
# Step 8: Clean Gradle build
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
# Step 7: Build based on type
# Step 9: Build based on type
if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
elif [ "$BUILD_TYPE" = "release" ]; then
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
fi
# Step 8: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
# Step 8.5: Restore local plugins (capacitor.plugins.json gets overwritten by cap sync)
safe_execute "Restoring local plugins" "node scripts/restore-local-plugins.js" || exit 7
# Step 9: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
# Step 10: Build APK/AAB if requested
if [ "$BUILD_APK" = true ]; then
if [ "$BUILD_TYPE" = "debug" ]; then
@@ -642,6 +648,9 @@ fi
if [ "$BUILD_AAB" = true ]; then
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
if [ "$BUILD_MODE" = "production" ]; then
safe_execute "Checking production AAB" "./scripts/check-prod-bundle.sh android/app/build/outputs/bundle/release/app-release.aab" || exit 10
fi
fi
# Step 11: Auto-run app if requested
@@ -649,7 +658,18 @@ fi
# we already synced and ran restore-local-plugins.js above, so skip sync here.
if [ "$AUTO_RUN" = true ]; then
log_step "Auto-running Android app..."
safe_execute "Launching app" "npx cap run android --no-sync" || {
# With exactly one connected device, target it directly. Otherwise cap run
# shows an interactive picker, which without a terminal exits 0 having
# launched nothing, so fail instead of reporting a false success.
run_devices=$(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }')
run_target=""
if [ "$(printf '%s\n' "$run_devices" | grep -c .)" = "1" ]; then
run_target="--target $run_devices"
elif [ ! -t 0 ]; then
log_error "Auto-run needs exactly one connected device when not run from a terminal (found: ${run_devices:-none})"
exit 9
fi
safe_execute "Launching app" "npx cap run android --no-sync $run_target" || {
log_error "Failed to launch Android app"
log_info "You can manually run with: npx cap run android --no-sync"
exit 9
+6 -6
View File
@@ -295,12 +295,7 @@ apply_ios_app_icon_appearances() {
# Generate iOS assets (capacitor-assets), then apply optional appearance variants.
generate_ios_assets() {
ensure_ios_capacitor_asset_directories
if [ -d "assets" ]; then
log_warn "@capacitor/assets prioritizes the top-level assets/ directory over resources/."
log_warn "This project intentionally uses resources/ as the canonical asset source."
log_warn "Remove the legacy assets/ directory if it is empty or obsolete."
fi
npx capacitor-assets generate --ios
npx capacitor-assets generate --ios --assetPath resources
apply_ios_app_icon_appearances
}
@@ -685,6 +680,11 @@ safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaroun
# Step 6.6: Sync with Capacitor (uses run_cap_sync_with_workaround defined above for Xcode 26)
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
# Step 6.7: Verify the synced web bundle has production server defaults
if [ "$BUILD_MODE" = "production" ]; then
safe_execute "Checking production bundle" "./scripts/check-prod-bundle.sh ios/App/App/public" || exit 10
fi
# Step 7: Generate assets
safe_execute "Generating assets" "generate_ios_assets" || exit 7
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
# check-prod-bundle.sh
# Fails if a built web bundle has test/dev server URLs baked in as defaults.
#
# Native packaging (gradlew bundleRelease, Xcode archive) ships whatever web
# bundle was last synced into the native project, whether it came from a
# --test, --dev, or --prod build. Run this on the bundle before uploading.
#
# Usage:
# ./scripts/check-prod-bundle.sh [PATH...]
#
# Each PATH is a directory (e.g. android/app/src/main/assets/public,
# ios/App/App/public, an .xcarchive) or an .aab, .apk, or .ipa file.
# With no PATH, checks the synced Android and iOS web assets plus the
# release AAB if it exists.
#
# Detection: the canary URLs below appear in src/ only as AppString.TEST_*
# enum values, which minify to `x.TEST_..._SERVER="<url>"`. Any other quoted
# occurrence is a VITE_* default compiled in from .env.test or
# .env.development (.env.development also uses the test image server).
#
# Exit codes: 0 = clean, 1 = test defaults found, 2 = bad input
source "$(dirname "$0")/common.sh"
CANARY_REGEX='"https://test(-image-api|-notify-api)?\.timesafari\.app"'
ENUM_REGEX='TEST[0-9]*_[A-Z_]+="https'
# Prints offending snippets from the JS files under a directory; returns 1 if any.
check_js_dir() {
local dir="$1"
local js_count
js_count=$(find "$dir" -name "*.js" -path "*assets*" | wc -l | tr -d ' ')
if [ "$js_count" = "0" ]; then
log_error "No JS assets found under $dir"
return 2
fi
local hits
hits=$(find "$dir" -name "*.js" -path "*assets*" -print0 \
| xargs -0 grep -ohE ".{0,40}${CANARY_REGEX}" \
| grep -vE "$ENUM_REGEX")
if [ -n "$hits" ]; then
log_error "Test/dev server defaults found in bundle:"
echo "$hits" | sed 's/^/ /' >&2
return 1
fi
return 0
}
check_path() {
local path="$1"
local result
if [ -d "$path" ]; then
check_js_dir "$path"
result=$?
elif [ -f "$path" ]; then
case "$path" in
*.aab|*.apk|*.ipa) ;;
*)
log_error "Unsupported file type: $path"
return 2
;;
esac
local tmp
tmp=$(mktemp -d)
unzip -q "$path" '*public/assets/*.js' -d "$tmp" 2>/dev/null
check_js_dir "$tmp"
result=$?
rm -rf "$tmp"
else
log_error "Not found: $path"
return 2
fi
if [ "$result" = "0" ]; then
log_success "Production defaults OK: $path"
else
log_error "Not a production bundle: $path"
fi
return $result
}
paths=("$@")
if [ ${#paths[@]} -eq 0 ]; then
for p in android/app/src/main/assets/public \
ios/App/App/public \
android/app/build/outputs/bundle/release/app-release.aab; do
[ -e "$p" ] && paths+=("$p")
done
fi
status=0
for p in "${paths[@]}"; do
check_path "$p"
rc=$?
[ $rc -gt $status ] && status=$rc
done
exit $status
+5 -1
View File
@@ -2,7 +2,7 @@
/**
* Restore Local Capacitor Plugins
*
* This script ensures that local custom plugins (SafeArea and SharedImage)
* This script ensures that local custom plugins (SafeArea, SharedImage, NotificationInspector)
* are present in capacitor.plugins.json after `npx cap sync` runs.
*
* The capacitor.plugins.json file is auto-generated by Capacitor and gets
@@ -28,6 +28,10 @@ const LOCAL_PLUGINS = [
{
pkg: 'SharedImage',
classpath: 'app.timesafari.sharedimage.SharedImagePlugin'
},
{
pkg: 'NotificationInspector',
classpath: 'app.timesafari.notifications.NotificationInspectorPlugin'
}
];
+133 -2
View File
@@ -88,6 +88,25 @@
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"
@@ -109,8 +128,8 @@
{{ realWakeupStatus.message }}
</p>
<p v-else class="text-xs text-slate-500">
FCM delivery diagnostic only — backend `/debug/send-wakeup`. The app
no longer schedules api_* notifications from WAKEUP_PING.
Full pipeline — backend `/debug/send-wakeup` → FCM → WAKEUP_PING
handler.
</p>
</div>
@@ -158,6 +177,70 @@
</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">
@@ -275,6 +358,14 @@ 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);
@@ -304,6 +395,11 @@ 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 "";
@@ -327,6 +423,27 @@ 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();
@@ -397,6 +514,20 @@ 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
@@ -0,0 +1,134 @@
/* 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,
};
}
+1 -1
View File
@@ -225,7 +225,7 @@ const MIGRATIONS = [
},
{
name: "008_remove_iViewContent_from_contacts",
// Recreate contacts without iViewContent: backup, drop, recreate, restore
// Recreate contacts without iViewContent (since we use hideTheirContent instead): backup, drop, recreate, restore
sql: `
PRAGMA foreign_keys = OFF;
+1 -4
View File
@@ -78,10 +78,7 @@ export interface NotificationRegisterRequest extends NotifyApiTestModeFlag {
}
/**
* 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
* Body of `POST /notifications/refresh`. 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 "../utils/notificationUtils";
import { NOTIFICATION_TIMEOUTS } from "../composables/useNotifications";
import { createNotifyHelpers } from "../utils/notify";
import { NOTIFY_PERSONAL_DATA_ERROR } from "../constants/notifications";
import { Contact } from "../db/tables/contacts";
-3
View File
@@ -48,7 +48,6 @@ import {
configureNativeFetcherIfReady,
initializeNativePushAndFirebaseMessaging,
onNotificationAuthMayBeReady,
runLegacyApiNotificationsCleanupOnce,
} from "@/services/notifications";
logger.log("[Capacitor] 🚀 Starting initialization");
@@ -362,8 +361,6 @@ 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)
@@ -11,6 +11,8 @@ export type PendingNotificationInfo = {
export interface NotificationInspectorPlugin {
getPendingNotifications(): Promise<{ pending: PendingNotificationInfo[] }>;
/** Android only: whether the build has a Firebase config (-PfirebaseEnabled). */
isFirebaseConfigured(): Promise<{ configured: boolean }>;
}
export const NotificationInspector =
@@ -13,8 +13,30 @@
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";
/**
@@ -541,14 +563,200 @@ export class NativeNotificationService implements NotificationServiceInterface {
}
}
export type RefreshNotificationsResult = {
ok: boolean;
scheduledCount: number;
status?: number;
errorMessage?: string;
};
/**
* 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.
* 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.
*/
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,12 +1,14 @@
/**
* DEV-only notification testing utilities.
*
* 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.
* 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.
*/
import { Capacitor } from "@capacitor/core";
import type { PushNotificationSchema } from "@capacitor/push-notifications";
import type {
DebugSendWakeupRequest,
DebugSendWakeupResponse,
@@ -36,6 +38,12 @@ 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";
@@ -171,10 +179,19 @@ export const NotificationDebugService = {
return result;
},
/**
* 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 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. */
async sendRealWakeupPing(): Promise<SendRealWakeupPingResult> {
logNotification("Real WAKEUP_PING requested");
@@ -247,6 +264,62 @@ 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)");
@@ -21,6 +21,7 @@ import {
onMessage,
} from "firebase/messaging";
import { logger } from "@/utils/logger";
import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin";
import { handleCapacitorPushNotificationReceived } from "./NativeNotificationService";
import { getNotificationApiHeaders } from "./notificationApiAuth";
import { deferFcmRegistration } from "./notificationAuthLifecycle";
@@ -67,6 +68,24 @@ async function registerRetrievedToken(
lastRegisteredFcmToken = trimmed;
}
/**
* Whether PushNotifications.register() is safe to call. Android builds without
* -PfirebaseEnabled (FOSS/F-Droid/APK) have no Firebase config, and register()
* there throws natively, crashing the app before any JS catch can run.
*/
async function canRegisterNativePush(): Promise<boolean> {
if (Capacitor.getPlatform() !== "android") {
return true;
}
try {
const { configured } = await NotificationInspector.isFirebaseConfigured();
return configured;
} catch (err) {
logger.warn(`${LOG} Could not check Firebase config; skipping push`, err);
return false;
}
}
/** Most recent FCM token from native/web push registration (for debug UI). */
export function getLastKnownFcmToken(): string | null {
return lastSeenFcmToken ?? lastRegisteredFcmToken;
@@ -80,6 +99,12 @@ export async function reregisterFcmTokenNow(): Promise<string> {
throw new Error("FCM registration is only available on native platforms");
}
if (!(await canRegisterNativePush())) {
throw new Error(
"Push is unavailable: this Android build has no Firebase config (built without -PfirebaseEnabled)",
);
}
lastRegisteredFcmToken = null;
const cached = lastSeenFcmToken?.trim();
@@ -290,6 +315,13 @@ async function initializeNativePushAndFirebaseMessagingImpl(): Promise<void> {
return;
}
if (!(await canRegisterNativePush())) {
logger.info(
`${LOG} No Firebase config in this Android build; skipping push registration`,
);
return;
}
await PushNotifications.register();
if (app) {
-1
View File
@@ -42,7 +42,6 @@ export { uploadAlertSearchAuthorization } from "./alertAuthorization";
export type { AlertAuthorizationUploadResult } from "./alertAuthorization";
export { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
export { runLegacyApiNotificationsCleanupOnce } from "./legacyApiNotificationsCleanup";
export {
deferFcmRegistration,
flushDeferredFcmRegistration,
@@ -1,101 +0,0 @@
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;
});
});
@@ -1,85 +0,0 @@
/**
* 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";
export type NotificationRequestKind = "register" | "refresh";
export type NotificationApiHeadersResult =
| {
@@ -133,17 +133,32 @@ 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 {
logNotification(`Register auth unavailable: ${message}`);
const verb = kind === "register" ? "Register" : "Refresh";
logNotification(`${verb} 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 FCM token registration until app auth (active DID + Bearer) is available.
* Defers notification register/refresh until app auth (active DID + Bearer) is available.
* Bounded retries avoid racing startup and prevent infinite loops.
*/
@@ -24,6 +24,9 @@ export function logPushNotificationReceived(notification: {
title: notification.title,
dataType: type,
});
if (type === "WAKEUP_PING") {
logNotification("WAKEUP_PING received — will trigger refresh");
}
}
export function logPushNotificationActionPerformed(action: {
@@ -71,6 +74,44 @@ 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)`);
}