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. `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 ## 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 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 #### Automated Build Script
The recommended way to build for iOS is using the 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) ##### 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: - ... 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: ##### 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 ```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. # Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5 #xcrun agvtool new-marketing-version 0.4.5
``` ```
@@ -1162,14 +1167,20 @@ npm run build:ios:prod
3.2. Use Xcode to release. 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 -> Destination -> Any iOS Device
- Choose Product -> Archive - Choose Product -> Archive
- This will trigger a build and take time, needing user's "login" keychain - This will trigger a build and take time, needing user's "login" keychain
password (user's login password), repeatedly. 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 - 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 (.../.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`). 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 - Click Distribute -> App Store Connect
- In AppStoreConnect, add the build to the distribution. You may have to remove - 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 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 # Auto-run builds
npm run build:android:test:run # Test build with auto-run (builds then runs on emulator) 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) 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 # Debug and release builds
npm run build:android:debug # Debug build (builds debug APK) 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: ##### 1. Bump the version in package.json, then update these versions & run:
```bash ```bash
perl -p -i -e 's/versionCode .*/versionCode 70/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.4"/g' android/app/build.gradle perl -p -i -e 's/versionName .*/versionName "1.4.6"/g' android/app/build.gradle
``` ```
##### 2. Build ##### 2. Build
@@ -1442,35 +1454,38 @@ npx cap open android
##### 4. Use Android Studio to build and run on emulator or device ##### 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`
cd android
./gradlew clean
./gradlew build -Dlint.baselines.continue=true
cd -
```
... 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 ```bash
./gradlew bundleDebug -Dlint.baselines.continue=true ./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 - 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 of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
- Then `bundleRelease`: - Then `bundleRelease` or `bundleDebug`:
```bash ```bash
cd android # if you use pkgx: pkgx +openjdk.org /bin/zsh
./gradlew bundleRelease -Dlint.baselines.continue=true -PfirebaseEnabled ./gradlew bundleRelease -Dlint.baselines.continue=true -PfirebaseEnabled
cd -
``` ```
... and find your `aab` file at app/build/outputs/bundle/release ... 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`. * Note that F-Droid builds should omit `-PfirebaseEnabled`.
At play.google.com/console: 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 4. **Native Build**: Platform-specific compilation
5. **Package Creation**: APK/IPA generation 5. **Package Creation**: APK/IPA generation
`resources/` is the canonical source for app icons and splash screens. `resources/` is the canonical source for app icons and splash screens. Build
Do not keep a legacy top-level `assets/` directory unless it is intentionally scripts select it explicitly with `--assetPath resources`. Use the same option
used: `@capacitor/assets` prioritizes `assets/` over `resources/`, which can when invoking the generator directly:
prevent the canonical assets from being discovered.
```bash
npx capacitor-assets generate --assetPath resources
```
## Architecture Environment Configuration ## Architecture Environment Configuration
@@ -1822,7 +1840,7 @@ npm run build:android:assets
- Configure signing keys - Configure signing keys
- Check device/emulator setup - Check device/emulator setup
## Additional Resources ## Additional Documentation
- [Electron Build Patterns](doc/electron-build-patterns.md) - [Electron Build Patterns](doc/electron-build-patterns.md)
- [iOS Build Scripts](doc/ios-build-scripts.md) - [iOS Build Scripts](doc/ios-build-scripts.md)
@@ -2212,6 +2230,8 @@ command chaining, following DRY principles.
- `7` - Asset generation failed - `7` - Asset generation failed
- `8` - Android Studio launch failed - `8` - Android Studio launch failed
- `9` - Resource check 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 ### A.4 build-ios.sh
@@ -2272,6 +2292,8 @@ command chaining, following DRY principles.
- **Clean Build**: Removes Xcode build artifacts and DerivedData - **Clean Build**: Removes Xcode build artifacts and DerivedData
- **Asset Generation**: Creates platform-specific assets - **Asset Generation**: Creates platform-specific assets
- **Simulator Support**: Launches iOS Simulator for testing - **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 ### 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). 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 ### Added
- Full flow for setting up SMS notifications: phone registration and code - Full flow for setting up SMS notifications: phone registration and code
verification, delegated alertSearch JWT batches with a send hour, and verification, delegated alertSearch JWT batches with a send hour, and
revocation to stop the texts revocation to stop the texts
- Notification debug panel action to mint and upload a push-channel alertSearch - 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 ### Fixed
- Native build scripts set NODE_ENV, so `import.meta.env.DEV` matches the build - Native build scripts set NODE_ENV, so `import.meta.env.DEV` matches the build
mode instead of always reporting a production 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) ## 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**. **Access:** **Account** → enable **Show All General Advanced Functions** → **Notification Debug Panel**.
+2 -2
View File
@@ -37,8 +37,8 @@ android {
applicationId "app.timesafari.app" applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 70 versionCode 72
versionName "1.4.4" versionName "1.4.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -54,5 +54,9 @@
{ {
"pkg": "SharedImage", "pkg": "SharedImage",
"classpath": "app.timesafari.sharedimage.SharedImagePlugin" "classpath": "app.timesafari.sharedimage.SharedImagePlugin"
},
{
"pkg": "NotificationInspector",
"classpath": "app.timesafari.notifications.NotificationInspectorPlugin"
} }
] ]
@@ -1,5 +1,6 @@
package app.timesafari.notifications; package app.timesafari.notifications;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin; import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall; import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod; import com.getcapacitor.PluginMethod;
@@ -12,5 +13,20 @@ public class NotificationInspectorPlugin extends Plugin {
call.unimplemented( call.unimplemented(
"Pending notification inspection is currently implemented on iOS only"); "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. **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 ## The `google-services.json` file
- Gitignored (`android/.gitignore` line 80) — never commit it - 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. 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`. **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 **Created:** 2026-06-02
**Source document:** [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md) **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. **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 ## Executive summary
+148 -88
View File
@@ -1,10 +1,8 @@
# Local Android Testing with ngrok (notification-wakeup-service) # 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** **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. **Goal:** Exercise FCM wakeup (`WAKEUP_PING`), FCM token registration, and notification refresh against a Mac-hosted backend reachable from a physical Android device.
> **Retired (do not expect this in the app):** `WAKEUP_PING` → `POST /notifications/refresh` → `applyNotificationRefreshPayload()` → `api_*` local schedules. The app logs ignored push types and does not refresh or schedule from wakeup. `/debug/send-wakeup` remains an FCM diagnostic. Mac `curl` of `/notifications/refresh` only tests the backend. Local schedules come from Static Daily Reminder, New Activity / dual, and the native fetcher.
**See also:** [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md) for the iOS workflow (APNs, Xcode). [android-physical-device-guide.md](./android-physical-device-guide.md) for USB debugging and build/run commands. **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 ## 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 ```text
┌─────────────────────┐ HTTPS ┌──────────────────────┐ ┌─────────────────────┐ HTTPS ┌──────────────────────┐
@@ -21,7 +19,7 @@ End-to-end flow when testing FCM registration and wakeup **delivery** on a physi
│ wakeup-service │ └──────────┬───────────┘ │ wakeup-service │ └──────────┬───────────┘
└──────────┬──────────┘ │ └──────────┬──────────┘ │
│ │ fetch │ │ fetch
│ │ POST /notifications/register │ POST /notifications/refresh │ POST /notifications/register
│ ▼ │ ▼
│ ┌──────────────────────┐ │ ┌──────────────────────┐
│ │ crowd-funder-for- │ │ │ 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 │ FCM data message (WAKEUP_PING) │ daily-notification-plugin
▼ ▼ (Daily Reminder / dual / fetcher) ▼ ▼ (local schedule replace)
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Firebase Cloud │ ──FCM────────► │ Android device │ │ Firebase Cloud │ ──FCM────────► │ Android device │
│ Messaging │ direct │ app.timesafari.app │ │ 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 | | Repo | Role |
|------|------| |------|------|
| **notification-wakeup-service** | HTTP API: device registration, health, debug wakeup send; may still expose `/notifications/refresh` for the backend | | **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`; logs `WAKEUP_PING` without refresh/`api_*` scheduling | | **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register` & `/refresh`, handles `WAKEUP_PING` push |
| **daily-notification-plugin** | Native Android: Daily Reminder, New Activity / dual, native fetcher; Phase 4 `clearApiNotifications()` | | **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)). 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`. 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`). 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 | | Control | Purpose |
|---------|---------| |---------|---------|
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) | | **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**) | | **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"` | | **Register Token Now** | `POST /notifications/register` with current FCM token and `platform: "android"` |
| **Upload AlertSearch Authorization** | Uploads AlertSearch delegated JWTs (`/notifications/alert-authorization`) | | **Refresh Notifications** | `POST /notifications/refresh` (same as post-wakeup flow) |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; FCM delivery diagnostic only (no app-side refresh/`api_*` scheduling) | | **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) | | **Event Log** | Shared `[Notifications]` panel log (100 entries) |
Persistence: `localStorage` keys `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, and `notificationDebug.bypassAuth` (`NotificationDebugConfig.ts`). 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 ### 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 ### 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 ### 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. 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()`). 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. 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)). **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]`): Filter logcat (prefix is always `[Notifications]`):
```bash ```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 requested
[Notifications] Real WAKEUP_PING success [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) ### Programmatic override (optional)
@@ -445,11 +455,15 @@ On **API 33+**, `POST_NOTIFICATIONS` is a runtime permission ([section 7](#7-and
### Network connectivity ### 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 ### 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 ### 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\]' 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 ### 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) | | 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. 1. Server accepts `/debug/send-wakeup` → FCM enqueue succeeds.
2. Device may **hold** the message until Doze maintenance or OEM policy allows delivery. 2. Device may **hold** the message until Doze maintenance or OEM policy allows delivery.
3. `pushNotificationReceived` runs → `push handler ignored type=WAKEUP_PING`. 3. `pushNotificationReceived` runs → `refreshNotificationsWithDiagnostics()` → ngrok `POST /notifications/refresh`.
4. Any step can lag under battery savers; use **Send Real WAKEUP_PING** for the FCM path ([§6](#send-real-wakeup_ping)). 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). 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. 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. 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**. 7. Tap **Refresh Notifications** → confirm `POST /notifications/refresh` and `Refresh completed in Nms (scheduled X)` in Event Log.
8. Watch logcat for `push handler ignored type=WAKEUP_PING` ([expected output](#expected-logcat-output)). 8. Optional: tap **Simulate WAKEUP_PING (Local)** to verify ngrok + refresh without FCM.
9. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate HTTP traffic (register and send-wakeup; not app-initiated refresh). 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. Use **Pending Notification Inspector** for Daily Reminder / New Activity / dual schedules — not for retired `api_*` refresh. 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. 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 | | 2 | Device → backend override | §2 |
| 3 | FCM token registered | §3 | | 3 | FCM token registered | §3 |
| 4 | Device record in wakeup service | §4 | | 4 | Device record in wakeup service | §4 |
| 5 | Optional backend `/notifications/refresh` curl | §5 (backend only; app does not call this) | | 5 | Refresh returns schedule data | §5 |
| 6 | Locals scheduled | §6 (Daily Reminder / dual / fetcher — not refresh) | | 6 | Locals scheduled | §6 |
| 7 | Manual wakeup sends FCM | §7 | | 7 | Manual wakeup sends FCM | §7 |
| 8 | WAKEUP_PING delivered | §8 | | 8 | WAKEUP_PING → refresh | §8 |
| 9 | _(retired)_ Replace after refresh | skipped | | 9 | Replace after refresh | §9 |
| 10 | _(retired)_ Test mode frequent refreshes | skipped | | 10 | Test mode frequent refreshes | §10 |
### 1. Backend reachable through ngrok ### 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. 3. Confirm **Backend Status → URL** matches the saved ngrok host.
4. Enable **Test Mode** if using dev backend behavior. 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:** **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 ```bash
curl -sS -X POST "$BASE/notifications/refresh" \ curl -sS -X POST "$BASE/notifications/refresh" \
@@ -607,18 +624,18 @@ curl -sS -X POST "$BASE/notifications/refresh" \
-d '{"platform":"android","testMode":true}' -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:** **Actions:**
1. Configure Static Daily Reminder and/or New Activity (dual) in the app, or rely on the native fetcher. 1. After a successful refresh (step 5), open **Pending Notification Inspector** → **Refresh** (list button).
2. 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). 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:** **Actions:**
@@ -652,28 +669,42 @@ curl -sS -X POST "$BASE/debug/send-wakeup" \
3. Filter logcat: 3. Filter logcat:
```bash ```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):** **Expected outcome (within ~30–120s, longer under Doze/OEM):**
1. `Real WAKEUP_PING success` (panel path) 1. `pushNotificationReceived type=WAKEUP_PING` / `WAKEUP_PING received`
2. `push handler ignored type=WAKEUP_PING` 2. `WAKEUP_PING handler — invoking refresh`
3. ngrok: **no** app-initiated `POST /notifications/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 ### 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 ### 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**. 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. 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). 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 ### End-to-end pass criteria
@@ -732,9 +771,9 @@ npm run dev
|-------|------| |-------|------|
| A | Health OK local + ngrok | | A | Health OK local + ngrok |
| B | Override URL + testMode active | | B | Override URL + testMode active |
| C | Register succeeded | | C | Register + refresh + pending list populated |
| D | Wakeup curl/panel OK → logcat ignored-type line | | D | Wakeup curl OK → logcat refresh chain → pending updated |
| E | Optional visible Daily Reminder / New Activity notification | | 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 ```bash
curl -sS -X POST "$BASE/notifications/refresh" \ 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) ### 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 ### 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 ### 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. **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:** **Verification:**
1. App **backgrounded** (Home), not force-stopped. 1. App **backgrounded** (Home), not force-stopped.
2. Panel FCM token matches token used by server/register. 2. Panel FCM token matches token used by server/register.
3. Wait 30–120s (longer on Doze/OEM). 3. **Simulate WAKEUP_PING (Local)** works → isolates FCM path from refresh/API.
4. Battery **Unrestricted** and OEM autostart enabled for test device. 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. **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 ### 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. **Likely causes:** Permission denied (display blocked); exact alarm permission on Android 12+; timestamps in past; plugin schedule error; DND/channel settings.
**Verification:** **Verification:**
1. Permission granted ([section 7](#7-android-notification-permissions)). 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 ### 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/NotificationDebugEvents.ts` | Panel event log + `logNotification()` |
| `src/services/notifications/notificationLog.ts` | Structured log helpers | | `src/services/notifications/notificationLog.ts` | Structured log helpers |
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` | | `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/services/notifications/firebaseMessagingClient.ts` | Capacitor push listeners, permission, token registration |
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI | | `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
| `src/main.capacitor.ts` | Native push init at startup | | `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) # 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** **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. **Goal:** Exercise silent push wake (`WAKEUP_PING`), FCM token registration, and notification refresh against a Mac-hosted backend reachable from a physical iPhone.
> **Retired (do not expect this in the app):** `WAKEUP_PING` → `POST /notifications/refresh` → `applyNotificationRefreshPayload()` → `api_*` local schedules. The app logs ignored push types and does not refresh or schedule from wakeup. `/debug/send-wakeup` remains an FCM/APNs diagnostic. Mac `curl` of `/notifications/refresh` only tests the backend.
--- ---
## Architecture overview ## 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 ```text
┌─────────────────────┐ HTTPS ┌──────────────────────┐ ┌─────────────────────┐ HTTPS ┌──────────────────────┐
@@ -19,7 +17,7 @@ End-to-end flow when testing FCM registration and wakeup **delivery** on a physi
│ wakeup-service │ └──────────┬───────────┘ │ wakeup-service │ └──────────┬───────────┘
└──────────┬──────────┘ │ └──────────┬──────────┘ │
│ │ fetch │ │ fetch
│ │ POST /notifications/register │ POST /notifications/refresh │ POST /notifications/register
│ ▼ │ ▼
│ ┌──────────────────────┐ │ ┌──────────────────────┐
│ │ crowd-funder-for- │ │ │ 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 │ FCM data message (WAKEUP_PING) │ daily-notification-plugin
▼ ▼ (Daily Reminder / dual / fetcher) ▼ ▼ (local schedule replace)
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Firebase Cloud │ ──APNs──────► │ iPhone (physical) │ │ Firebase Cloud │ ──APNs──────► │ iPhone (physical) │
│ Messaging │ silent push │ app.timesafari │ │ 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 | | Repo | Role |
|------|------| |------|------|
| **notification-wakeup-service** | HTTP API: device registration, health, debug wakeup send; may still expose `/notifications/refresh` | | **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`; logs `WAKEUP_PING` without refresh/`api_*` scheduling | | **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register` & `/refresh`, handles `WAKEUP_PING` push |
| **daily-notification-plugin** | Native iOS/Android: Daily Reminder, New Activity / dual, native fetcher; Phase 4 `clearApiNotifications()` | | **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). 2. APNs delivers to the device (best-effort; see iOS caveats below).
3. Capacitor `pushNotificationReceived` fires → `handleCapacitorPushNotificationReceived()`. 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`). 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 | | Control | Purpose |
|---------|---------| |---------|---------|
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) | | **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**) | | **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 | | **Register Token Now** | `POST /notifications/register` with current FCM token |
| **Upload AlertSearch Authorization** | Uploads AlertSearch delegated JWTs | | **Refresh Notifications** | `POST /notifications/refresh` (same as post-wakeup flow) |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; FCM/APNs delivery diagnostic only | | **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) | | **Event Log** | Shared `[Notifications]` panel log (100 entries) |
Persistence: `localStorage` keys `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, and `notificationDebug.bypassAuth` (`NotificationDebugConfig.ts`). 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 | | **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` | | **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. - **Low Power Mode** can reduce background execution.
- **Focus / Do Not Disturb** may affect notification presentation (separate from silent data wake, but confusing during tests). - **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. 2. Start **ngrok** and copy the HTTPS URL.
3. Set URL + **Test Mode** in the Notification Debug Panel; confirm **Backend Status**. 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`. 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**. 5. Tap **Refresh Notifications** → confirm `Refresh completed in Nms (scheduled X)` in Event Log and ngrok `POST /notifications/refresh`.
6. Watch **Xcode console** for `[Notifications] push handler ignored type=WAKEUP_PING`. 6. From the backend, call **`/debug/send-wakeup`** (see curl below) with the registered `deviceId` / FCM token as required by that service.
7. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate requests (register and send-wakeup; not app-initiated refresh). 7. Watch **Xcode console** for `[Notifications] pushNotificationReceived type=WAKEUP_PING` and refresh timing lines.
8. Use **Pending Notification Inspector** for Daily Reminder / dual / fetcher schedules. 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 ```bash
curl -sS -X POST "$BASE/notifications/refresh" \ 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) ### Send wakeup push (debug)
@@ -459,8 +478,8 @@ Confirm parameters (token vs deviceId, auth headers) in that repo’s README or
| Symptom | Checks | | Symptom | Checks |
|---------|--------| |---------|--------|
| Mac `curl` of `/notifications/refresh` fails | ngrok running? URL saved without typo/trailing slash? | | Network error in Event Log | 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) | | 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 | | 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 | | 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 - App **backgrounded**, not force-quit
- Physical device, correct provisioning profile - Physical device, correct provisioning profile
- APNs key uploaded to Firebase; bundle ID matches - 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 - Server actually sent to the **same** FCM token shown in the debug panel
- Wait 30–120s — delivery is not instant - 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 ### Notifications duplicating
- Daily Reminder vs New Activity both scheduling — see `doc/notification-new-activity-lay-of-the-land.md` - Multiple refresh calls (flood test, repeated wakeups) each **replace** schedule via clear + schedule — check Event Log for repeated refreshes
- Leftover `api_*` before Phase 4 cleanup (startup `clearApiNotifications()`) - Separate issue: Daily Reminder vs New Activity both scheduling — see `doc/notification-new-activity-lay-of-the-land.md`
### Stale ngrok URL ### 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/NotificationDebugEvents.ts` | Panel event log + `logNotification()` |
| `src/services/notifications/notificationLog.ts` | Structured log helpers | | `src/services/notifications/notificationLog.ts` | Structured log helpers |
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` | | `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/services/notifications/firebaseMessagingClient.ts` | Capacitor push listeners |
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI | | `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
| `src/main.capacitor.ts` | Native push init at startup | | `src/main.capacitor.ts` | Native push init at startup |
+19 -14
View File
@@ -1,16 +1,16 @@
# Notification Debug Panel # Notification Debug Panel
**Created:** 2026-07-07 **Created:** 2026-07-07
**Updated:** 2026-09-24 **Updated:** 2026-07-22
**Audience:** Developers testing notification registration, AlertSearch authorization upload, and FCM delivery diagnostics on native (iOS/Android) dev builds. **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 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 ### 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.testMode` | `true` | Sent in JSON request bodies (`testMode: true/false`) |
| `notificationDebug.bypassAuth` | `false` | When `true`, omit JWT `Authorization` headers on notification API calls | | `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 ### Notification Backend URL
@@ -75,7 +75,7 @@ Leave empty to use the configured build default (`DEFAULT_NOTIFY_API_SERVER`, fr
### Test Mode ### 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. 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 | | 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). | | **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. | | **Refresh Notifications** | `POST {backend}/notifications/refresh` — same path used after a real WAKEUP_PING. Applies returned schedule to the native plugin. |
| **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. | | **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). **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 | | Section | Purpose |
|---------|---------| |---------|---------|
| **Pending Notification Inspector** | Lists locally scheduled notifications (Daily Reminder, New Activity / dual, and any leftover `api_*` until Phase 4 cleanup). | | **Mock Timing Presets** | Interval for mock refresh timestamps (30 sec – 10 min). |
| **Clear Notifications** | Clears/cancels plugin-scheduled notifications on native. Does not replace the one-time `api_*` startup cleanup. | | **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. 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: 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/notificationApiDebugMode.ts` | Auth bypass gate |
| `src/services/notifications/NotificationDebugService.ts` | Panel action handlers | | `src/services/notifications/NotificationDebugService.ts` | Panel action handlers |
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` | | `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 */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { C86585E32ED456DE00824752 /* Exceptions for "TimeSafariShareExtension" folder in "TimeSafariShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Info.plist, Info.plist,
@@ -82,7 +82,18 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup 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 */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -177,7 +188,7 @@
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */, 012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */, 96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
C86585E02ED456DE00824752 /* Embed Foundation Extensions */, C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
2B3F98670AF3508A35AC3248 /* [CP] Embed Pods Frameworks */, 04A558C9E2DEC92B5B2AFBE4 /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
@@ -294,7 +305,7 @@
shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" \n"; shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" \n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
2B3F98670AF3508A35AC3248 /* [CP] Embed Pods Frameworks */ = { 04A558C9E2DEC92B5B2AFBE4 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -455,7 +466,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.5; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -512,7 +523,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.5; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -530,7 +541,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements; CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 70; CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -541,7 +552,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.4.4; MARKETING_VERSION = 1.4.6;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -560,7 +571,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 70; CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -571,7 +582,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.4.4; MARKETING_VERSION = 1.4.6;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -589,21 +600,21 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 70; CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist; INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia; INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.5; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.4.4; MARKETING_VERSION = 1.4.6;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
@@ -627,21 +638,21 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 70; CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist; INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia; INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.5; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.4.4; MARKETING_VERSION = 1.4.6;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
-4
View File
@@ -82,10 +82,6 @@ post_install do |installer|
assertDeploymentTarget(installer) assertDeploymentTarget(installer)
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
target.build_configurations.each do |config| 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' config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
merge_sqlite_omit_load_extension_definition(config) merge_sqlite_omit_load_extension_definition(config)
strip_system_sqlite_from_pod_config(config) strip_system_sqlite_from_pod_config(config)
+1 -1
View File
@@ -177,6 +177,6 @@ SPEC CHECKSUMS:
TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 5736811d271d5309d3e2de8f3eefdbb6632086a3 PODFILE CHECKSUM: abe640043e6b8adea745693d980ead03251912f9
COCOAPODS: 1.16.2 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", "name": "giftopia",
"version": "1.4.4", "version": "1.4.7-beta",
"description": "Giftopia App", "description": "Giftopia App",
"author": { "author": {
"name": "Gift Economies Team" "name": "Gift Economies Team"
}, },
"scripts": { "scripts": {
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src", "assets:clean": "rimraf android/app/src/main/res/mipmap-* ios/App/App/Assets.xcassets/**/AppIcon*.png ios/App/App/Assets.xcassets/**/Splash*.png || true",
"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:config": "npx tsx scripts/assets-config.ts", "assets:config": "npx tsx scripts/assets-config.ts",
"assets:validate": "npx tsx scripts/assets-validator.ts", "assets:validate": "npx tsx scripts/assets-validator.ts",
"assets:validate:android": "./scripts/build-android.sh --assets-only", "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", "auto-run": "./scripts/auto-run.sh",
"build:ios": "./scripts/build-ios.sh", "auto-run:android": "./scripts/auto-run.sh --platform=android",
"build:ios:dev": "./scripts/build-ios.sh --dev", "auto-run:electron": "./scripts/auto-run.sh --platform=electron",
"build:ios:test": "./scripts/build-ios.sh --test", "auto-run:ios": "./scripts/auto-run.sh --platform=ios",
"build:ios:test:run": "./scripts/build-ios.sh --test --auto-run", "auto-run:prod": "./scripts/auto-run.sh --prod",
"build:ios:prod": "./scripts/build-ios.sh --prod", "auto-run:test": "./scripts/auto-run.sh --test",
"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",
"build:android": "./scripts/build-android.sh", "build:android": "./scripts/build-android.sh",
"build:android:dev": "./scripts/build-android.sh --dev", "build:android:aab": "./scripts/build-android.sh --aab",
"build:android:test": "./scripts/build-android.sh --test", "build:android:apk": "./scripts/build-android.sh --apk",
"build:android:test:run": "./scripts/build-android.sh --test --auto-run", "build:android:assets": "./scripts/build-android.sh --assets",
"build:android:prod": "./scripts/build-android.sh --prod", "build:android:clean": "./scripts/build-android.sh --clean",
"build:android:prod:run": "./scripts/build-android.sh --prod --auto-run",
"build:android:debug": "./scripts/build-android.sh --debug", "build:android:debug": "./scripts/build-android.sh --debug",
"build:android:debug:run": "./scripts/build-android.sh --debug --auto-run", "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": "./scripts/build-android.sh --release",
"build:android:release:run": "./scripts/build-android.sh --release --auto-run", "build:android:release:run": "./scripts/build-android.sh --release --auto-run",
"build:android:studio": "./scripts/build-android.sh --studio", "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:sync": "./scripts/build-android.sh --sync",
"build:android:assets": "./scripts/build-android.sh --assets", "build:android:test": "./scripts/build-android.sh --test",
"build:android:deploy": "./scripts/build-android.sh --deploy",
"build:android:dev:custom": "./scripts/build-android.sh --dev --api-ip",
"build:android:test:custom": "./scripts/build-android.sh --test --api-ip", "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": "./scripts/build-android.sh --test --auto-run",
"build:android:test:run:custom": "./scripts/build-android.sh --test --api-ip --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": { "lint-staged": {
"*.{js,ts,vue,css,json,yml,yaml}": "eslint --fix || true", "*.{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 ```bash
# Generate assets for all platforms # Generate assets for all platforms
npx capacitor-assets generate npx capacitor-assets generate --assetPath resources
# Generate assets for specific platform # Generate assets for specific platform
npx capacitor-assets generate --ios npx capacitor-assets generate --ios --assetPath resources
npx capacitor-assets generate --android npx capacitor-assets generate --android --assetPath resources
npx capacitor-assets generate --web npx capacitor-assets generate --web --assetPath resources
``` ```
## Configuration ## Configuration
`resources/` is this project's canonical asset source. `@capacitor/assets` `resources/` is this project's canonical asset source. Pass it explicitly with
prioritizes a top-level `assets/` directory over `resources/`, so a legacy `--assetPath resources`; `@capacitor/assets` otherwise prioritizes a top-level
`assets/` directory can prevent these assets from being discovered. Remove that `assets/` directory when one exists, even when that directory is empty.
directory when it is empty or obsolete.
## Version Control ## Version Control
+33 -13
View File
@@ -545,7 +545,7 @@ fi
# Handle assets-only mode # Handle assets-only mode
if [ "$ASSETS_ONLY" = true ]; then if [ "$ASSETS_ONLY" = true ]; then
log_info "Assets-only mode: generating assets" 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!" log_success "Assets generation completed successfully!"
exit 0 exit 0
fi fi
@@ -612,25 +612,31 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3 safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi 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 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 if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5 safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
elif [ "$BUILD_TYPE" = "release" ]; then elif [ "$BUILD_TYPE" = "release" ]; then
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5 safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
fi 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 # Step 10: Build APK/AAB if requested
if [ "$BUILD_APK" = true ]; then if [ "$BUILD_APK" = true ]; then
if [ "$BUILD_TYPE" = "debug" ]; then if [ "$BUILD_TYPE" = "debug" ]; then
@@ -642,6 +648,9 @@ fi
if [ "$BUILD_AAB" = true ]; then if [ "$BUILD_AAB" = true ]; then
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5 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 fi
# Step 11: Auto-run app if requested # 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. # we already synced and ran restore-local-plugins.js above, so skip sync here.
if [ "$AUTO_RUN" = true ]; then if [ "$AUTO_RUN" = true ]; then
log_step "Auto-running Android app..." 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_error "Failed to launch Android app"
log_info "You can manually run with: npx cap run android --no-sync" log_info "You can manually run with: npx cap run android --no-sync"
exit 9 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 (capacitor-assets), then apply optional appearance variants.
generate_ios_assets() { generate_ios_assets() {
ensure_ios_capacitor_asset_directories ensure_ios_capacitor_asset_directories
if [ -d "assets" ]; then npx capacitor-assets generate --ios --assetPath resources
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
apply_ios_app_icon_appearances 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) # 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 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 # Step 7: Generate assets
safe_execute "Generating assets" "generate_ios_assets" || exit 7 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 * 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. * are present in capacitor.plugins.json after `npx cap sync` runs.
* *
* The capacitor.plugins.json file is auto-generated by Capacitor and gets * The capacitor.plugins.json file is auto-generated by Capacitor and gets
@@ -28,6 +28,10 @@ const LOCAL_PLUGINS = [
{ {
pkg: 'SharedImage', pkg: 'SharedImage',
classpath: 'app.timesafari.sharedimage.SharedImagePlugin' 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 Manually mints and uploads 100 delegated day JWTs. Requires an active
did:ethr identity and JWT authentication; Test Mode is not used. did:ethr identity and JWT authentication; Test Mode is not used.
</p> </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 <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" 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" :disabled="busy"
@@ -109,8 +128,8 @@
{{ realWakeupStatus.message }} {{ realWakeupStatus.message }}
</p> </p>
<p v-else class="text-xs text-slate-500"> <p v-else class="text-xs text-slate-500">
FCM delivery diagnostic only — backend `/debug/send-wakeup`. The app Full pipeline — backend `/debug/send-wakeup` → FCM → WAKEUP_PING
no longer schedules api_* notifications from WAKEUP_PING. handler.
</p> </p>
</div> </div>
@@ -158,6 +177,70 @@
</div> </div>
</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 --> <!-- SECTION D: Pending Notification Inspector -->
<div class="mb-6"> <div class="mb-6">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
@@ -275,6 +358,14 @@ type PendingInfo = {
wallClockSource?: string | null; 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 busy = ref(false);
const pending = ref<PendingInfo[]>([]); const pending = ref<PendingInfo[]>([]);
const pendingInspectorMessage = ref<string | null>(null); const pendingInspectorMessage = ref<string | null>(null);
@@ -304,6 +395,11 @@ const truncatedFcmToken = computed(() => {
const eventLog = ref<string[]>([]); const eventLog = ref<string[]>([]);
let unsubscribeEventLog: (() => void) | undefined; 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 { function formatIsoMs(ms: number | null | undefined): string {
if (ms == null || !Number.isFinite(ms)) { if (ms == null || !Number.isFinite(ms)) {
return ""; return "";
@@ -327,6 +423,27 @@ async function refreshPending(): Promise<void> {
pendingInspectorMessage.value = result.inspectorUnavailableMessage ?? null; 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> { async function onClearNotifications(): Promise<void> {
await withBusy(async () => { await withBusy(async () => {
await NotificationDebugService.clearNotifications(); 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( function formatRealWakeupStatusMessage(
result: Awaited< result: Awaited<
ReturnType<typeof NotificationDebugService.sendRealWakeupPing> 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", 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: ` sql: `
PRAGMA foreign_keys = OFF; 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 * Body of `POST /notifications/refresh`. The service finds the caller's device
* route (WAKEUP_PING → refresh → `api_*` scheduling was retired). These types
* remain because notification-wakeup-service still exposes the endpoint.
* The service finds the caller's device
* by `deviceId`, or by `fcmToken` when no `deviceId` is sent, and answers 400 * 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 * when neither is present and non-empty. When both are sent they must name the
* same device, or the answer is 404. * 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 * as R from "ramda";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app"; 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 { createNotifyHelpers } from "../utils/notify";
import { NOTIFY_PERSONAL_DATA_ERROR } from "../constants/notifications"; import { NOTIFY_PERSONAL_DATA_ERROR } from "../constants/notifications";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
-3
View File
@@ -48,7 +48,6 @@ import {
configureNativeFetcherIfReady, configureNativeFetcherIfReady,
initializeNativePushAndFirebaseMessaging, initializeNativePushAndFirebaseMessaging,
onNotificationAuthMayBeReady, onNotificationAuthMayBeReady,
runLegacyApiNotificationsCleanupOnce,
} from "@/services/notifications"; } from "@/services/notifications";
logger.log("[Capacitor] 🚀 Starting initialization"); logger.log("[Capacitor] 🚀 Starting initialization");
@@ -362,8 +361,6 @@ setTimeout(async () => {
); );
await registerDeepLinkListener(); await registerDeepLinkListener();
logger.info(`[Main] 🎉 Deep link system fully initialized!`); 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) // Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token, delivery listeners)
await initializeNativePushAndFirebaseMessaging(); await initializeNativePushAndFirebaseMessaging();
// Configure native fetcher for API-driven daily notifications (activeDid + JWT) // Configure native fetcher for API-driven daily notifications (activeDid + JWT)
@@ -11,6 +11,8 @@ export type PendingNotificationInfo = {
export interface NotificationInspectorPlugin { export interface NotificationInspectorPlugin {
getPendingNotifications(): Promise<{ pending: PendingNotificationInfo[] }>; getPendingNotifications(): Promise<{ pending: PendingNotificationInfo[] }>;
/** Android only: whether the build has a Firebase config (-PfirebaseEnabled). */
isFirebaseConfigured(): Promise<{ configured: boolean }>;
} }
export const NotificationInspector = export const NotificationInspector =
@@ -13,8 +13,30 @@
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import type { PushNotificationSchema } from "@capacitor/push-notifications"; import type { PushNotificationSchema } from "@capacitor/push-notifications";
import type {
NotificationRefreshRequest,
NotificationRefreshResponse,
} from "@/interfaces/notifyApi";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { getOrCreateDeviceId } from "./deviceId";
import { REMINDER_ID_DAILY_REMINDER } from "./reminderIds"; 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"; 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 * Re-applies native API fetcher credentials (JWT pool, active DID) so background
* consumption was retired; AlertSearch uses visible FCM notifications and is * notification workers can run. No UI; safe from push handlers while backgrounded.
* not handled here. Keep logging for diagnostics without scheduling api_* work. */
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( export async function handleCapacitorPushNotificationReceived(
notification: PushNotificationSchema, notification: PushNotificationSchema,
): Promise<void> { ): Promise<void> {
if (notification.data?.type === "WAKEUP_PING") {
logNotification("WAKEUP_PING handler — invoking refresh");
await refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING" });
return;
}
const type = const type =
typeof notification.data?.type === "string" typeof notification.data?.type === "string"
? notification.data.type ? notification.data.type
@@ -1,12 +1,14 @@
/** /**
* DEV-only notification testing utilities. * DEV-only notification testing utilities.
* *
* Legacy WAKEUP_PING → /notifications/refresh → api_* tooling was removed in * IMPORTANT:
* the Phase 2 retirement. Remaining helpers cover FCM registration, AlertSearch * This service intentionally routes through the same production notification
* authorization upload, backend URL overrides, and pending-notification inspection. * orchestration paths used by refresh flows, wakeup pushes, and replacement.
* Avoid adding duplicate scheduling logic here.
*/ */
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import type { PushNotificationSchema } from "@capacitor/push-notifications";
import type { import type {
DebugSendWakeupRequest, DebugSendWakeupRequest,
DebugSendWakeupResponse, DebugSendWakeupResponse,
@@ -36,6 +38,12 @@ import {
notificationApiFailureMessage, notificationApiFailureMessage,
readNotificationApiBody, readNotificationApiBody,
} from "./notificationApiAuth"; } from "./notificationApiAuth";
import {
applyNotificationRefreshPayload,
handleCapacitorPushNotificationReceived,
refreshNotificationsWithDiagnostics,
type NotificationRefreshPayload,
} from "./NativeNotificationService";
import { truncateFcmTokenForLog } from "./notificationLog"; import { truncateFcmTokenForLog } from "./notificationLog";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin"; import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin";
@@ -171,10 +179,19 @@ export const NotificationDebugService = {
return result; return result;
}, },
/** async triggerBackendRefresh(): Promise<void> {
* Backend `/debug/send-wakeup` → FCM only. App no longer consumes WAKEUP_PING await refreshNotificationsWithDiagnostics({ source: "debug panel" });
* for api_* scheduling; kept for FCM delivery diagnostics until backend Phase 3. },
*/
/** 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> { async sendRealWakeupPing(): Promise<SendRealWakeupPingResult> {
logNotification("Real WAKEUP_PING requested"); 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> { async clearNotifications(): Promise<void> {
logNotification("Clear notifications (debug panel)"); logNotification("Clear notifications (debug panel)");
@@ -21,6 +21,7 @@ import {
onMessage, onMessage,
} from "firebase/messaging"; } from "firebase/messaging";
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin";
import { handleCapacitorPushNotificationReceived } from "./NativeNotificationService"; import { handleCapacitorPushNotificationReceived } from "./NativeNotificationService";
import { getNotificationApiHeaders } from "./notificationApiAuth"; import { getNotificationApiHeaders } from "./notificationApiAuth";
import { deferFcmRegistration } from "./notificationAuthLifecycle"; import { deferFcmRegistration } from "./notificationAuthLifecycle";
@@ -67,6 +68,24 @@ async function registerRetrievedToken(
lastRegisteredFcmToken = trimmed; 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). */ /** Most recent FCM token from native/web push registration (for debug UI). */
export function getLastKnownFcmToken(): string | null { export function getLastKnownFcmToken(): string | null {
return lastSeenFcmToken ?? lastRegisteredFcmToken; return lastSeenFcmToken ?? lastRegisteredFcmToken;
@@ -80,6 +99,12 @@ export async function reregisterFcmTokenNow(): Promise<string> {
throw new Error("FCM registration is only available on native platforms"); 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; lastRegisteredFcmToken = null;
const cached = lastSeenFcmToken?.trim(); const cached = lastSeenFcmToken?.trim();
@@ -290,6 +315,13 @@ async function initializeNativePushAndFirebaseMessagingImpl(): Promise<void> {
return; return;
} }
if (!(await canRegisterNativePush())) {
logger.info(
`${LOG} No Firebase config in this Android build; skipping push registration`,
);
return;
}
await PushNotifications.register(); await PushNotifications.register();
if (app) { if (app) {
-1
View File
@@ -42,7 +42,6 @@ export { uploadAlertSearchAuthorization } from "./alertAuthorization";
export type { AlertAuthorizationUploadResult } from "./alertAuthorization"; export type { AlertAuthorizationUploadResult } from "./alertAuthorization";
export { configureNativeFetcherIfReady } from "./nativeFetcherConfig"; export { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
export { runLegacyApiNotificationsCleanupOnce } from "./legacyApiNotificationsCleanup";
export { export {
deferFcmRegistration, deferFcmRegistration,
flushDeferredFcmRegistration, 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 { shouldBypassNotificationAuth } from "./notificationApiDebugMode";
import { logNotification } from "./NotificationDebugEvents"; import { logNotification } from "./NotificationDebugEvents";
export type NotificationRequestKind = "register"; export type NotificationRequestKind = "register" | "refresh";
export type NotificationApiHeadersResult = 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( export function logNotificationAuthFailure(
_kind: NotificationRequestKind, kind: NotificationRequestKind,
message: string, message: string,
): void { ): void {
logNotification(`Register auth unavailable: ${message}`); const verb = kind === "register" ? "Register" : "Refresh";
logNotification(`${verb} auth unavailable: ${message}`);
} }
export function logWaitingForAuthBeforeRegistration(): void { export function logWaitingForAuthBeforeRegistration(): void {
logNotification("Waiting for auth before registration"); logNotification("Waiting for auth before registration");
} }
export function logSkippingRefreshDueToMissingAuth(): void {
logNotification("Skipping refresh due to missing auth");
}
export function httpAuthErrorMessage(status: number): string { export function httpAuthErrorMessage(status: number): string {
if (status === 401) { if (status === 401) {
return "unauthorized (expired or invalid auth)"; 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. * Bounded retries avoid racing startup and prevent infinite loops.
*/ */
@@ -24,6 +24,9 @@ export function logPushNotificationReceived(notification: {
title: notification.title, title: notification.title,
dataType: type, dataType: type,
}); });
if (type === "WAKEUP_PING") {
logNotification("WAKEUP_PING received — will trigger refresh");
}
} }
export function logPushNotificationActionPerformed(action: { 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 { export function logNotificationClearing(method: string): void {
logNotification(`Clearing notifications via ${method}`); logNotification(`Clearing notifications via ${method}`);
} }
export function logScheduleReplacement(count: number): void {
logNotification(`Schedule replacement: ${count} notification(s)`);
}