Compare commits
62 Commits
notify-api
...
notify-api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fcc9a40d0 | ||
|
|
2afe748292 | ||
|
|
34a8c51d2f | ||
|
|
d5bcdbae3f | ||
|
|
46a2e0aaf5 | ||
|
|
d5c357b291 | ||
|
|
86611fe50d | ||
|
|
4152012838 | ||
|
|
25110e3eea | ||
|
|
0ecd4c6dd7 | ||
| cdf5c721a5 | |||
| 974002fa90 | |||
|
|
f23fe65078 | ||
|
|
82380b3d35 | ||
|
|
6a7f341990 | ||
|
|
693bfacc1e | ||
|
|
3d6ac2ab53 | ||
|
|
cd32895281 | ||
|
|
a1f94300ad | ||
|
|
58b61471b5 | ||
|
|
55ef36be06 | ||
|
|
00abd5277f | ||
|
|
227ae85bb7 | ||
|
|
e0a3f7094f | ||
|
|
2dd76878ba | ||
|
|
4fb8f048cd | ||
|
|
c97defef11 | ||
|
|
2c0992ba8b | ||
|
|
964cdb4509 | ||
|
|
656de5eba3 | ||
|
|
0d7586865c | ||
|
|
5bc030125a | ||
|
|
8cd8727a84 | ||
|
|
8864a2049b | ||
|
|
63f5c4ecc7 | ||
|
|
a4453c0b1b | ||
|
|
794b48f0d7 | ||
|
|
4c97c578bb | ||
|
|
6a9f34a516 | ||
|
|
5a40075ab1 | ||
|
|
48637ae9a8 | ||
|
|
a55dce6f3d | ||
|
|
d7d5e401b8 | ||
|
|
19427c2817 | ||
|
|
d4ac0acd01 | ||
|
|
1ef3f32b9e | ||
|
|
fd0b8ce6d0 | ||
|
|
320e55912b | ||
|
|
6bbade2a29 | ||
|
|
1cd329c720 | ||
|
|
7c8ef284c2 | ||
|
|
35a1b92559 | ||
|
|
c523c14d96 | ||
|
|
162158066f | ||
|
|
1643bab18b | ||
|
|
ce078862e7 | ||
|
|
954500cf9d | ||
|
|
73d595046a | ||
|
|
cf9d207895 | ||
|
|
7d87a746f9 | ||
|
|
90e6603d52 | ||
|
|
8290943b53 |
@@ -18,4 +18,6 @@ VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
|
||||
#VITE_DEFAULT_PUSH_SERVER... can't be set up with localhost domain
|
||||
# Using shared test notify API (no local notify server by default).
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_PASSKEYS_ENABLED=true
|
||||
|
||||
@@ -11,3 +11,4 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
|
||||
VITE_DEFAULT_PUSH_SERVER=https://timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
|
||||
|
||||
@@ -15,4 +15,5 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch
|
||||
VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_PASSKEYS_ENABLED=true
|
||||
|
||||
10
BUILDING.md
10
BUILDING.md
@@ -164,6 +164,7 @@ cp .env.example .env.development
|
||||
# - VITE_DEFAULT_ENDORSER_API_SERVER
|
||||
# - VITE_DEFAULT_PARTNER_API_SERVER
|
||||
# - VITE_DEFAULT_IMAGE_API_SERVER
|
||||
# - VITE_DEFAULT_NOTIFY_API_SERVER
|
||||
```
|
||||
|
||||
#### Platform-Specific Development
|
||||
@@ -1126,7 +1127,7 @@ If you need to build manually or want to understand the individual steps:
|
||||
|
||||
##### 0. First time (or if dependencies change)
|
||||
|
||||
- `pkgx +rubygems.org zsh`
|
||||
- `pkgx +rubygems.org +pod zsh`
|
||||
|
||||
- ... and you may have to fix these, especially with pkgx:
|
||||
|
||||
@@ -1153,6 +1154,8 @@ Here's prod. Also available: test, dev
|
||||
npm run build:ios:prod
|
||||
```
|
||||
|
||||
- The first time, it may complain about a bundler install for "missing gems", and you'll want to run the "install" command it gives you.
|
||||
|
||||
3.1. Use Xcode to build and run on simulator or device.
|
||||
|
||||
- Select Product -> Destination with some Simulator version. Then click the run arrow.
|
||||
@@ -1648,6 +1651,7 @@ The build system supports multiple environment file patterns for different scena
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
|
||||
|
||||
# Platform Configuration
|
||||
VITE_PLATFORM=web|electron|capacitor
|
||||
@@ -1667,6 +1671,7 @@ VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZY
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_APP_SERVER=http://localhost:8080
|
||||
```
|
||||
|
||||
@@ -1677,6 +1682,7 @@ VITE_APP_SERVER=http://localhost:8080
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_APP_SERVER=https://test.timesafari.app
|
||||
```
|
||||
|
||||
@@ -1687,6 +1693,7 @@ VITE_APP_SERVER=https://test.timesafari.app
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
|
||||
VITE_APP_SERVER=https://timesafari.app
|
||||
```
|
||||
|
||||
@@ -1944,6 +1951,7 @@ The build system supports multiple environment file patterns:
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
|
||||
|
||||
# Platform Configuration
|
||||
VITE_PLATFORM=web|electron|capacitor
|
||||
|
||||
44
README.md
44
README.md
@@ -15,10 +15,31 @@ Quick start:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Web
|
||||
|
||||
```bash
|
||||
npm run build:web:dev
|
||||
```
|
||||
|
||||
To be able to take action on the platform: go to [the test page](http://localhost:8080/test) and click "Become User 0".
|
||||
Then go to [the test page](http://localhost:8080/test) and click "Become User 0" to take action on the platform.
|
||||
|
||||
### Android
|
||||
|
||||
```bash
|
||||
npm run build:android:test:run
|
||||
```
|
||||
|
||||
Assumes ADB is installed; see [Android Build](BUILDING.md#android-build) for SDK, emulator, and `PATH` setup.
|
||||
|
||||
### iOS
|
||||
|
||||
```bash
|
||||
npm run build:ios:studio
|
||||
```
|
||||
|
||||
Assumes Xcode and Xcode Command Line Tools are installed.
|
||||
|
||||
See [BUILDING.md](BUILDING.md) for comprehensive build instructions for all platforms (Web, Electron, iOS, Android, Docker).
|
||||
|
||||
@@ -89,6 +110,27 @@ VITE_LOG_LEVEL=debug npm run build:web:dev
|
||||
|
||||
See [Logging Configuration Guide](doc/logging-configuration.md) for complete details.
|
||||
|
||||
## Notification Debug Panel (dev builds)
|
||||
|
||||
In non-production bundles (for example `vite dev` or a Vite build whose mode is not `production`), the **Notification Debug Panel** at `/dev/notifications` helps you test notification registration, backend refresh, WAKEUP_PING handling, and local schedule inspection on native builds.
|
||||
|
||||
**Access:** **Account** → enable **Show All General Advanced Functions** → **Notification Debug Panel**.
|
||||
|
||||
Key configuration (independent settings):
|
||||
|
||||
- **Notification Backend URL** — which notification server receives API calls
|
||||
- **Test Mode** — `testMode` sent in JSON request bodies (default on)
|
||||
- **Skip JWT Authentication (Local Development Only)** — omit JWT headers for local unauthenticated backends (default off)
|
||||
|
||||
See [doc/notification-debug-panel.md](doc/notification-debug-panel.md) for controls, recommended settings (hosted test server vs local ngrok), and troubleshooting.
|
||||
|
||||
Platform-specific end-to-end guides:
|
||||
|
||||
- [doc/local-android-testing-ngrok.md](doc/local-android-testing-ngrok.md)
|
||||
- [doc/local-ios-testing-ngrok.md](doc/local-ios-testing-ngrok.md)
|
||||
|
||||
## Database Clearing (development)
|
||||
|
||||
### Quick Usage
|
||||
```bash
|
||||
# Run the database clearing script
|
||||
|
||||
@@ -15,6 +15,8 @@ dependencies {
|
||||
implementation project(':capacitor-camera')
|
||||
implementation project(':capacitor-clipboard')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capawesome-capacitor-file-picker')
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "123456789000",
|
||||
"project_id": "timesafari-app",
|
||||
"storage_bucket": "timesafari-app.appspot.com"
|
||||
"project_number": "1094643115061",
|
||||
"project_id": "pc-api-7249509642322112640-286",
|
||||
"storage_bucket": "pc-api-7249509642322112640-286.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:123456789000:android:1234567890abcdef",
|
||||
"mobilesdk_app_id": "1:1094643115061:android:f11bd26f6bd2fcdc887d7c",
|
||||
"android_client_info": {
|
||||
"package_name": "app.timesafari.app"
|
||||
}
|
||||
@@ -15,7 +15,45 @@
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDummyKeyForBuildPurposesOnly12345"
|
||||
"current_key": "AIzaSyCFLYeLfGQqh7ErvzXgy74H0Gx3yQAMEw8"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1094643115061:android:354e70007466b006887d7c",
|
||||
"android_client_info": {
|
||||
"package_name": "ch.endorser.mobile"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyCFLYeLfGQqh7ErvzXgy74H0Gx3yQAMEw8"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1094643115061:android:40b63cb5851f34ac887d7c",
|
||||
"android_client_info": {
|
||||
"package_name": "com.veramo_react_native"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyCFLYeLfGQqh7ErvzXgy74H0Gx3yQAMEw8"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
@@ -24,5 +62,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -16,6 +16,13 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"PushNotifications": {
|
||||
"presentationOptions": [
|
||||
"badge",
|
||||
"sound",
|
||||
"alert"
|
||||
]
|
||||
},
|
||||
"SplashScreen": {
|
||||
"launchShowDuration": 3000,
|
||||
"launchAutoHide": true,
|
||||
|
||||
@@ -23,6 +23,14 @@
|
||||
"pkg": "@capacitor/filesystem",
|
||||
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/preferences",
|
||||
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/push-notifications",
|
||||
"classpath": "com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/share",
|
||||
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
|
||||
|
||||
@@ -16,6 +16,7 @@ import android.webkit.WebViewClient;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import app.timesafari.safearea.SafeAreaPlugin;
|
||||
import app.timesafari.sharedimage.SharedImagePlugin;
|
||||
import app.timesafari.notifications.NotificationInspectorPlugin;
|
||||
//import com.getcapacitor.community.sqlite.SQLite;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
@@ -66,6 +67,9 @@ public class MainActivity extends BridgeActivity {
|
||||
|
||||
// Register SharedImage plugin
|
||||
registerPlugin(SharedImagePlugin.class);
|
||||
|
||||
// Register NotificationInspector plugin (dev tooling; safe no-op on Android)
|
||||
registerPlugin(NotificationInspectorPlugin.class);
|
||||
|
||||
// Register DailyNotification plugin
|
||||
// Plugin is written in Kotlin but compiles to Java-compatible bytecode
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package app.timesafari.notifications;
|
||||
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
@CapacitorPlugin(name = "NotificationInspector")
|
||||
public class NotificationInspectorPlugin extends Plugin {
|
||||
@PluginMethod
|
||||
public void getPendingNotifications(PluginCall call) {
|
||||
call.unimplemented(
|
||||
"Pending notification inspection is currently implemented on iOS only");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacito
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-preferences'
|
||||
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
||||
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ const config: CapacitorConfig = {
|
||||
]
|
||||
}
|
||||
},
|
||||
PushNotifications: {
|
||||
presentationOptions: ['badge', 'sound', 'alert']
|
||||
},
|
||||
SplashScreen: {
|
||||
launchShowDuration: 3000,
|
||||
launchAutoHide: true,
|
||||
|
||||
@@ -61,16 +61,14 @@ The app depends on:
|
||||
"@timesafari/daily-notification-plugin": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master"
|
||||
```
|
||||
|
||||
If the fixes were only made in a **different** clone (e.g. `daily-notification-plugin_test`) and never pushed to that gitea `master`, then:
|
||||
If the fixes were only made in a **local clone** and never pushed to **gitea** `master`, then:
|
||||
|
||||
- `npm install` / `npm update` in the app would not pull the fixes.
|
||||
- The app’s `node_modules` would only have the fixes if they were copied/linked from the fixed repo.
|
||||
|
||||
**Do this:**
|
||||
|
||||
- If the fixes live in another clone: either **push** the fixed plugin to gitea `master` and run `npm update @timesafari/daily-notification-plugin` (then `npx cap sync android`, then clean build), **or** point the app at the fixed plugin locally, e.g. in **app** `package.json`:
|
||||
- `"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin"`
|
||||
(adjust path to your fixed plugin repo), then `npm install`, `npx cap sync android`, clean build and reinstall.
|
||||
- **Push** the fixed plugin to the official gitea repo (`trent_larson/daily-notification-plugin`), then in this app run `npm update @timesafari/daily-notification-plugin` (or set `package.json` to the branch/tag/commit you need), `npm install`, `npx cap sync android`, clean build and reinstall. The app should always depend on the published git remote, not a local `file:` path.
|
||||
|
||||
### 3. Fallback text from native fetcher (Bug 2 only)
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ Create or edit `.env.development` with your computer's IP:
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=http://192.168.1.100:3000
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=http://192.168.1.100:3000
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_APP_SERVER=http://192.168.1.100:8080
|
||||
```
|
||||
|
||||
|
||||
80
doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md
Normal file
80
doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Consuming app handoff: iOS native fetcher + chained dual (mirror)
|
||||
|
||||
**Canonical source:** `daily-notification-plugin` repo, `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` (same content as below for offline use).
|
||||
|
||||
---
|
||||
|
||||
## Implemented in this app
|
||||
|
||||
- **`ios/App/App/TimeSafariNativeFetcher.swift`** — Swift `NativeNotificationContentFetcher` mirroring `TimeSafariNativeFetcher.java` (`POST …/plansLastUpdatedBetween`, starred IDs from `daily_notification_timesafari.starredPlanIds`, JWT pool selection, pagination key `daily_notification_timesafari.last_acked_jwt_id`, aggregated copy).
|
||||
- **`AppDelegate.swift`** — `DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)` at launch **before** any JS `configureNativeFetcher`; foreground handler reads `scheduled_time` as `Int64`, `NSNumber`, or `Int` for `DailyNotificationDelivered`.
|
||||
|
||||
## Dependency
|
||||
|
||||
- **`@timesafari/daily-notification-plugin`** must be **≥ 3.0.0** (register native fetcher, chained dual, iOS `updateStarredPlans`). Declare it in `package.json` from the official remote (`git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git`, branch or tag as needed), then `npm install` so `package-lock.json` resolves the published tree.
|
||||
|
||||
## Bump / sync (after plugin version is resolved)
|
||||
|
||||
1. `npm install`
|
||||
2. `npx cap sync ios && npx cap sync android`
|
||||
3. `cd ios/App && pod install`
|
||||
4. Clean build in Xcode / Android Studio
|
||||
|
||||
## QA focus
|
||||
|
||||
- iOS: Fetcher registered before `configureNativeFetcher`; `updateStarredPlans` not `UNIMPLEMENTED`.
|
||||
- Both: New Activity fires **after** prefetch for that cycle where the plugin implements chaining.
|
||||
- Android: Existing `MainActivity.setNativeFetcher` unchanged; regression-test `cancelDualSchedule` vs Daily Reminder.
|
||||
|
||||
---
|
||||
|
||||
## Original handoff text (from plugin)
|
||||
|
||||
This document is for the **host app** repository (e.g. crowd-funder-for-time-pwa) after bumping `@timesafari/daily-notification-plugin` to a version that includes:
|
||||
|
||||
- **iOS** `NativeNotificationContentFetcher`–style registration (`DailyNotificationPlugin.registerNativeFetcher`)
|
||||
- **iOS** `updateStarredPlans` / `getStarredPlans` (parity with Android `daily_notification_timesafari` / `starredPlanIds` semantics)
|
||||
- **iOS** chained dual flow: user notification is **armed only after** prefetch completes (delay if fetch is late; max slip 15 minutes before fallback copy)
|
||||
- **Android** chained dual flow: exact **notify** alarm is scheduled **after** dual prefetch completes (no longer scheduled at initial `scheduleDualNotification` before fetch)
|
||||
|
||||
Material from `doc/new-activity-notifications-ios-android-parity.md` still applies; the plugin doc adds **app-side** steps not spelled out there.
|
||||
|
||||
### 1. iOS — register native fetcher before `configureNativeFetcher`
|
||||
|
||||
The plugin **rejects** `configureNativeFetcher` if no fetcher is registered (aligned with Android).
|
||||
|
||||
**In `AppDelegate` (or earliest app startup before Capacitor calls into the plugin):**
|
||||
|
||||
```swift
|
||||
import TimesafariDailyNotificationPlugin
|
||||
|
||||
DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)
|
||||
```
|
||||
|
||||
Implement **`TimeSafariNativeFetcher`** as a Swift type that:
|
||||
|
||||
- Conforms to `NativeNotificationContentFetcher`
|
||||
- Implements `fetchContent(context: FetchContext) async throws -> [NotificationContent]` with the same **Endorser** behavior as `TimeSafariNativeFetcher.java`
|
||||
- Implements `configure(apiBaseUrl:activeDid:jwtToken:jwtTokenPool:)` if the fetcher needs credentials pushed from TypeScript
|
||||
|
||||
**Starred plan IDs for the fetcher:** Read JSON array string from UserDefaults key **`daily_notification_timesafari.starredPlanIds`** (written by `updateStarredPlans` from JS).
|
||||
|
||||
### 2. iOS — `UNUserNotificationCenterDelegate` / rollover
|
||||
|
||||
Chained dual notifications set:
|
||||
|
||||
- `notification_id` = `org.timesafari.dailynotification.dual`
|
||||
- `scheduled_time` = `NSNumber` (fire time in ms)
|
||||
|
||||
Ensure **`DailyNotificationDelivered`** forwards **`notification_id`** and **`scheduled_time`** from **notification content `userInfo`**.
|
||||
|
||||
### 3. Android — no API change for `setNativeFetcher`
|
||||
|
||||
Host apps that already call `DailyNotificationPlugin.setNativeFetcher(TimeSafariNativeFetcher(...))` keep that flow.
|
||||
|
||||
**Behavior change:** the dual **notify** alarm is scheduled when **dual prefetch work finishes**, not at the initial `scheduleDualNotification` only.
|
||||
|
||||
### 4. Assumptions
|
||||
|
||||
- Swift host implements `TimeSafariNativeFetcher`; the plugin does **not** embed `plansLastUpdatedBetween` on iOS when a host fetcher is registered (mirrors Android).
|
||||
- Module import: `TimesafariDailyNotificationPlugin` (Pod `TimesafariDailyNotificationPlugin`).
|
||||
@@ -6,8 +6,7 @@
|
||||
2. **Notifications show when the app is in the foreground** (not only background/closed).
|
||||
3. **Plugin loads at app launch** so recovery runs after reboot without the user opening notification UI.
|
||||
|
||||
**Reference:** Test app at
|
||||
`/Users/aardimus/Sites/trentlarson/daily-notification-plugin_test/daily-notification-plugin/test-apps/daily-notification-test`
|
||||
**Reference:** In the **daily-notification-plugin** repository, the test app lives at `test-apps/daily-notification-test` (same repo as `https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
401
doc/local-android-testing-analysis.md
Normal file
401
doc/local-android-testing-analysis.md
Normal file
@@ -0,0 +1,401 @@
|
||||
# Android Local Notification Testing — Planning Analysis
|
||||
|
||||
**Created:** 2026-06-02
|
||||
**Source document:** [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md)
|
||||
**Purpose:** Plan a future **Android** counterpart guide by mapping what can be reused from the iOS ngrok workflow and what must be written for Android-specific push, permissions, and OS behavior.
|
||||
|
||||
**Status:** Planning only — does not replace or modify the iOS guide.
|
||||
|
||||
---
|
||||
|
||||
## Executive summary
|
||||
|
||||
The iOS guide’s **backend + ngrok + in-app debug panel** path is platform-agnostic. Most of sections **1–3**, **6**, **9** (with log tooling swapped), **10** (with `platform: "android"`), **12**, and parts of **11** can be copied or lightly edited.
|
||||
|
||||
Everything involving **APNs, Xcode, Apple Developer, iOS capabilities, and iOS background/silent-push caveats** must be replaced. Android adds **direct FCM delivery** (no APNs hop), **`google-services.json`**, **runtime notification permissions (API 33+)**, **Doze / battery optimization / OEM restrictions**, and different **force-stop / background** semantics.
|
||||
|
||||
Existing related docs to cross-link (not duplicate):
|
||||
|
||||
- [android-physical-device-guide.md](./android-physical-device-guide.md) — USB, `adb`, build/run commands
|
||||
- [notification-system-overview.md](./notification-system-overview.md)
|
||||
- [notification-from-api-call.md](./notification-from-api-call.md)
|
||||
- [notification-permissions-and-rollovers.md](./notification-permissions-and-rollovers.md)
|
||||
|
||||
---
|
||||
|
||||
## iOS guide structure (reference map)
|
||||
|
||||
| § | iOS doc heading | Reuse for Android |
|
||||
|---|-----------------|-------------------|
|
||||
| Intro | Architecture overview | **Adapt** — swap APNs leg for FCM→device |
|
||||
| — | Prerequisites | **Partial** — drop Xcode/APNs; add Android SDK/device |
|
||||
| 1 | Install and configure ngrok | **Reuse unchanged** |
|
||||
| 2 | Start the backend locally | **Reuse unchanged** |
|
||||
| 3 | Obtain and use ngrok HTTPS URL | **Reuse** — wording: “device” not “iPhone” |
|
||||
| 4 | Generate and open iOS workspace | **Rewrite** — Android Studio / Capacitor sync |
|
||||
| 5 | Firebase + APNs setup | **Rewrite** — Firebase Android only; no APNs |
|
||||
| 6 | Notification Debug Panel override | **Reuse unchanged** |
|
||||
| 7 | Firebase and Xcode checklist | **Rewrite** — Android manifest / Gradle checklist |
|
||||
| 8 | iOS-specific testing notes | **Rewrite** — Android delivery caveats |
|
||||
| 9 | Recommended debug workflow | **Reuse** — replace Xcode console with logcat |
|
||||
| 10 | Sample curl commands | **Reuse** — change `platform` to `android` |
|
||||
| 11 | Troubleshooting | **Partial** — keep ngrok/API rows; replace push rows |
|
||||
| 12 | Key source files | **Reuse unchanged** |
|
||||
| 13 | Related docs | **Extend** — link Android build/device guides |
|
||||
|
||||
---
|
||||
|
||||
## Sections reusable unchanged (or near-unchanged)
|
||||
|
||||
These blocks can be carried into `doc/local-android-testing-ngrok.md` (proposed name) with at most global find-replace (“iPhone” → “Android device”, “Mac” tunnel audience unchanged).
|
||||
|
||||
### notification-wakeup-service startup (iOS §1 Terminal A, §2)
|
||||
|
||||
- Clone **notification-wakeup-service**, `npm install`, `.env` from `.env.example`
|
||||
- `export PORT=3000` (or port from that repo’s README)
|
||||
- `npm run dev`
|
||||
- Local verify: `curl -sS http://localhost:3000/health`
|
||||
- Firebase **Admin** service account for the backend (`GOOGLE_APPLICATION_CREDENTIALS`) — same project can serve iOS and Android apps
|
||||
|
||||
### ngrok setup (iOS §1)
|
||||
|
||||
- `brew install ngrok/ngrok/ngrok` (or download)
|
||||
- `ngrok http 3000` in a second terminal
|
||||
- Use **HTTPS** forwarding URL; free tier URL rotation note
|
||||
- ngrok inspect UI at `http://127.0.0.1:4040`
|
||||
|
||||
### ngrok account creation (iOS §1 “Account and auth token”)
|
||||
|
||||
- Sign up at dashboard.ngrok.com
|
||||
- `ngrok config add-authtoken YOUR_AUTHTOKEN_HERE`
|
||||
|
||||
### Obtaining HTTPS URL (iOS §3)
|
||||
|
||||
- Copy `https://….ngrok-free.app` from Forwarding line
|
||||
- No trailing slash in debug panel
|
||||
- Mac-side tunnel test: `export NGROK_URL=…` and `curl "$NGROK_URL/health"`
|
||||
|
||||
### Backend override configuration (iOS §6)
|
||||
|
||||
- Non-production build required for Notification Debug Panel
|
||||
- Account → **Show All General Advanced Functions** → `/dev/notifications`
|
||||
- **Notification Backend URL**, **Save Backend URL**
|
||||
- `localStorage`: `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, `notificationDebug.bypassAuth`
|
||||
- Optional programmatic override via `@/services/notifications` (`setBackendBaseUrl`, `setTestMode`, `setBypassAuth`, `getNotificationApiBaseUrl`)
|
||||
|
||||
### Debug panel usage (iOS §6 table, §8 “Two Simulate WAKEUP_PING buttons”)
|
||||
|
||||
| Control | Android relevance |
|
||||
|---------|-------------------|
|
||||
| Notification Backend URL | Same |
|
||||
| Test Mode | Same (`testMode` in JSON body) |
|
||||
| Skip JWT Authentication | Same — explicit opt-in for unauthenticated local backends (default off) |
|
||||
| Register Token Now | Same (`POST /notifications/register`) |
|
||||
| Refresh Notifications | Same |
|
||||
| Simulate WAKEUP_PING (backend) | Same — isolates ngrok + refresh without FCM |
|
||||
| Wakeup Ping Simulator | Same — exercises `handleCapacitorPushNotificationReceived` path |
|
||||
| Event Log `[Notifications]` | Same |
|
||||
| Pending Notification Inspector | Same concept; confirm Android plugin inspector behavior in **daily-notification-plugin** |
|
||||
|
||||
### testMode usage (iOS §6, §10)
|
||||
|
||||
- Default-on when unset in storage (`NotificationDebugConfig.ts`)
|
||||
- Sent on register and refresh payloads
|
||||
- Backend/debug endpoints accept `testMode: true` for dev traffic
|
||||
|
||||
### Refresh endpoint testing (iOS §9 steps 5, §11 “Refresh endpoint unreachable”)
|
||||
|
||||
- Panel **Refresh Notifications** → expect Event Log + ngrok `POST /notifications/refresh`
|
||||
- **Simulate WAKEUP_PING** (backend button) for API-only path
|
||||
- Troubleshooting table for network error, 404, wrong port, stale URL
|
||||
|
||||
### curl examples (iOS §10)
|
||||
|
||||
Reuse structure; **only payload deltas** for Android doc:
|
||||
|
||||
```bash
|
||||
export BASE="https://abc123.ngrok-free.app"
|
||||
```
|
||||
|
||||
- `$BASE/health` — unchanged
|
||||
- `$BASE/notifications/register` — set `"platform": "android"`
|
||||
- `$BASE/notifications/refresh` — set `"platform": "android"`
|
||||
- `$BASE/debug/send-wakeup` — unchanged shape; confirm deviceId/token contract in **notification-wakeup-service** README
|
||||
|
||||
App still uses `Capacitor.getPlatform()` for `platform` in `NotificationService.ts` (`ios` | `android`).
|
||||
|
||||
### Shared architecture concepts (intro + silent wake sequence)
|
||||
|
||||
Reusable narrative (edit diagram only):
|
||||
|
||||
1. FCM **data** message with `data.type = "WAKEUP_PING"`
|
||||
2. Capacitor `pushNotificationReceived` → `handleCapacitorPushNotificationReceived()`
|
||||
3. `POST {backend}/notifications/refresh` with `testMode`
|
||||
4. `nextNotifications` → `applyNotificationRefreshPayload()` → **daily-notification-plugin** clear + schedule
|
||||
|
||||
Repos table (notification-wakeup-service, crowd-funder-for-time-pwa, daily-notification-plugin) — unchanged.
|
||||
|
||||
### Key source files (iOS §12)
|
||||
|
||||
Same files apply on Android Capacitor builds:
|
||||
|
||||
- `NotificationDebugConfig.ts`, `NotificationDebugEvents.ts`, `notificationLog.ts`
|
||||
- `NotificationService.ts`, `NativeNotificationService.ts`
|
||||
- `firebaseMessagingClient.ts`, `NotificationDebugPanel.vue`, `main.capacitor.ts`
|
||||
|
||||
### Recommended debug workflow (iOS §9) — reuse with tooling swap
|
||||
|
||||
Steps 1–5, 8–9 unchanged. Replace step 7:
|
||||
|
||||
- **iOS:** Xcode console → `[Notifications] pushNotificationReceived type=WAKEUP_PING`
|
||||
- **Android:** `adb logcat` filtered on app tag / `[Notifications]` (document exact filter in Android guide)
|
||||
|
||||
---
|
||||
|
||||
## iOS-specific sections — must rewrite for Android
|
||||
|
||||
### Architecture diagram (intro)
|
||||
|
||||
**iOS today:** Mac → ngrok → app; FCM → **APNs** → iPhone.
|
||||
|
||||
**Android doc:** FCM → **device directly** (no APNs). Update ASCII diagram and caption (“silent push” on Android is still FCM data; delivery rules differ).
|
||||
|
||||
### Prerequisites (intro list)
|
||||
|
||||
| iOS prerequisite | Android replacement |
|
||||
|------------------|---------------------|
|
||||
| Mac with **Xcode** | **Android Studio**, JDK 17+, `ANDROID_HOME`, `adb` — see [android-physical-device-guide.md](./android-physical-device-guide.md) |
|
||||
| Physical **iPhone** | Physical **Android** device (emulator possible for some steps but **not** representative for Doze/OEM/battery) |
|
||||
| Firebase with **APNs** for bundle ID | Firebase with **Android app** (`app.timesafari` package name) |
|
||||
| Non-production build | Same — e.g. `build:android:dev` / `build:android:test` |
|
||||
|
||||
Remove: “simulator is not sufficient for reliable silent push / **APNs**”.
|
||||
|
||||
Add: emulator vs physical device guidance for FCM and background limits.
|
||||
|
||||
### §4 — Generate and open the iOS workspace
|
||||
|
||||
**Replace entirely** with Android equivalent:
|
||||
|
||||
- `npm install`
|
||||
- `npm run build:android:dev` or `build:android:test` (non-production for debug panel)
|
||||
- `npx cap sync android` if needed
|
||||
- Open `android/` in Android Studio
|
||||
- Run on physical device (USB debugging)
|
||||
- `VITE_FIREBASE_*` in Capacitor web build
|
||||
- `initializeNativePushAndFirebaseMessaging()` in `main.capacitor.ts` — same entry point
|
||||
|
||||
Do **not** reference `.xcworkspace`, signing in Xcode, or `build:ios:*` except as cross-link to iOS doc.
|
||||
|
||||
### §5 — Firebase + APNs setup (first-time setup)
|
||||
|
||||
**Keep (Android-relevant portions only):**
|
||||
|
||||
- Firebase account / Spark plan sufficient for FCM
|
||||
- Create Firebase project
|
||||
- **Register Android app** in Firebase (package name `app.timesafari` from `capacitor.config.ts`)
|
||||
- Download **`google-services.json`** → `android/app/` (project may gitignore this file — document secure handling)
|
||||
- Firebase Admin service account for **notification-wakeup-service** — same as iOS §5 tail
|
||||
|
||||
**Remove entirely:**
|
||||
|
||||
- Register **iOS** app in Firebase (or move to “shared project” sidebar: one Firebase project, two apps)
|
||||
- **GoogleService-Info.plist** / Xcode drag-and-drop
|
||||
- **Create APNs Authentication Key** (.p8)
|
||||
- **Upload APNs key to Firebase**
|
||||
- **Enable iOS capabilities** (Push Notifications, Background Modes → Remote notifications)
|
||||
|
||||
**Add in Android guide (see next major section):**
|
||||
|
||||
- Gradle plugin / `google-services` classpath if not already in repo
|
||||
- `POST_NOTIFICATIONS` permission (API 33+)
|
||||
- Default notification channel / Capacitor Push Notifications Android setup
|
||||
- SHA-1/SHA-256 only if using Firebase features that require it (note whether wakeup testing needs Play App Signing keys)
|
||||
|
||||
### §5 verify checklist — iOS-only bullets
|
||||
|
||||
Replace:
|
||||
|
||||
- “Xcode without Firebase/plist errors” → Android Studio build; `google-services.json` present
|
||||
- “iOS push permission prompt” → Android 13+ notification permission + older grant model
|
||||
- “content-available style payload” → Android **high-priority data message** / FCM options as implemented by **notification-wakeup-service** (document actual payload; no APNs `content-available`)
|
||||
|
||||
### §7 — Firebase and Xcode checklist (iOS)
|
||||
|
||||
**Replace** with Android checklist, e.g.:
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| **Application ID** | `app.timesafari` in `capacitor.config.ts`, `android/app/build.gradle`, Firebase Android app |
|
||||
| **google-services.json** | In `android/app/`; not committed if gitignored — local copy per developer |
|
||||
| **Gradle** | Google services plugin applied (verify repo’s current `build.gradle`) |
|
||||
| **Permissions** | `POST_NOTIFICATIONS` (API 33+); manifest entries for FCM |
|
||||
| **FCM token** | Debug panel **Register Token Now** + ngrok `POST /notifications/register` |
|
||||
| **No APNs** | N/A on Android |
|
||||
|
||||
### §8 — iOS-specific testing notes
|
||||
|
||||
**Replace** with Android-specific sections (draft topics below). Do not port:
|
||||
|
||||
- APNs silent delivery / Simulator unreliability (iOS framing)
|
||||
- **Force-quit** via app switcher (iOS-specific policy)
|
||||
- **Low Power Mode** (iOS) — Android has different battery saver APIs
|
||||
- **Focus / Do Not Disturb** (iOS naming)
|
||||
|
||||
Port with Android wording:
|
||||
|
||||
- Two **Simulate WAKEUP_PING** buttons table — unchanged behavior
|
||||
|
||||
### §11 — Troubleshooting (partial)
|
||||
|
||||
**Reuse as-is:**
|
||||
|
||||
- Refresh endpoint unreachable (ngrok, URL, 404, CORS note)
|
||||
- Stale ngrok URL
|
||||
- Plugin / JWT errors after refresh
|
||||
|
||||
**Rewrite:**
|
||||
|
||||
| iOS troubleshooting | Android replacement |
|
||||
|----------------------|---------------------|
|
||||
| Push permission + `VITE_FIREBASE_*` + **Xcode** log | Permission (runtime POST_NOTIFICATIONS), logcat, Firebase Android config |
|
||||
| Silent push not waking — **backgrounded not force-quit**, **APNs key**, wait 30–120s | FCM high-priority data, **force-stop** (`STOP` from settings), **Doze**, battery optimization, OEM autostart, token mismatch |
|
||||
| Physical device + provisioning profile | USB debugging, correct build variant, Play vs debug signing if relevant |
|
||||
|
||||
### §13 — Related docs
|
||||
|
||||
Keep iOS-centric links as “see also”; add:
|
||||
|
||||
- [android-physical-device-guide.md](./android-physical-device-guide.md)
|
||||
- `BUILDING.md` — Android build commands (`build:android:*`)
|
||||
- **daily-notification-plugin** Android docs (exact alarm, pending inspector on Android)
|
||||
|
||||
---
|
||||
|
||||
## Android-Specific Topics Required
|
||||
|
||||
These sections do not exist in the iOS guide (or exist only by analogy) and must be written for the Android notification testing doc.
|
||||
|
||||
### Firebase project setup
|
||||
|
||||
- Use the **same** Firebase project as iOS when testing the same backend, or document a dedicated `timesafari-dev` project.
|
||||
- Add an **Android** app with package name **`app.timesafari`**.
|
||||
- Enable **Cloud Messaging** (default on new projects).
|
||||
- Download **`google-services.json`** and install under `android/app/`.
|
||||
- Note: `android/.gitignore` may exclude `google-services.json` — developers copy locally; never commit secrets.
|
||||
|
||||
### google-services.json
|
||||
|
||||
- Placement: `android/app/google-services.json`
|
||||
- Sync after add: `npx cap sync android`, rebuild in Android Studio
|
||||
- Verify build merges Firebase config (no “missing google-services” Gradle errors)
|
||||
- Relationship to `VITE_FIREBASE_*` for the web layer / Capacitor JS Firebase initialization
|
||||
|
||||
### Android notification permissions
|
||||
|
||||
- **Android 13+ (API 33):** `POST_NOTIFICATIONS` runtime permission — required for notification **display**; document interaction with **data-only** FCM wake (may still deliver to app code when permission denied — verify against current app behavior and document accurately).
|
||||
- **Android 12 and below:** install-time grant model; fewer runtime prompts.
|
||||
- App Settings → Notifications — manual enable path for testers.
|
||||
- Link [notification-permissions-and-rollovers.md](./notification-permissions-and-rollovers.md) for product-level permission UX.
|
||||
|
||||
### FCM token handling
|
||||
|
||||
- Token obtained via Capacitor Push Notifications + `firebaseMessagingClient.ts` (same JS path as iOS).
|
||||
- **Register Token Now** in debug panel → `POST /notifications/register` with `platform: "android"`.
|
||||
- Token rotation: when to re-register; duplicate skip behavior in panel.
|
||||
- Ensure **notification-wakeup-service** stores/sends to the token shown in the panel for `/debug/send-wakeup`.
|
||||
- Optional: `adb` cannot easily read FCM token — panel is source of truth (same as iOS).
|
||||
|
||||
### Android background delivery behavior
|
||||
|
||||
- FCM **data** messages handled in foreground/background per Capacitor plugin and `NativeNotificationService.ts`.
|
||||
- No APNs intermediary — document expected latency vs iOS.
|
||||
- **High-priority** FCM for wakeup testing (align with backend message options).
|
||||
- App in **background** vs **foreground** vs **killed** — different from iOS “swipe away” story:
|
||||
- **Force stop** (Settings → Force stop): delivery often blocked until user launches app again (stricter than iOS “backgrounded”).
|
||||
- **Recent apps swipe**: behavior varies by OEM/Android version — document “test with Home button background, not force stop.”
|
||||
- `pushNotificationReceived` / listener registration at startup (`main.capacitor.ts`).
|
||||
|
||||
### Doze Mode
|
||||
|
||||
- Device idle → deferred network and job execution.
|
||||
- Testing: use `adb shell dumpsys deviceidle` (document safe dev-only commands) or unplugged idle wait.
|
||||
- Explain why `/debug/send-wakeup` may succeed on server but device wakes late.
|
||||
- Whitelisting app for tests (developer settings) — use cautiously; note production users won’t do this.
|
||||
|
||||
### Battery optimization
|
||||
|
||||
- Settings → Apps → TimeSafari → Battery → **Unrestricted** vs **Optimized**.
|
||||
- Manufacturer “battery saver” modes that restrict background network.
|
||||
- Recommend **Unrestricted** (or equivalent) for local wakeup validation; warn that production users may remain optimized.
|
||||
|
||||
### OEM restrictions (Samsung, Xiaomi, Oppo, etc.)
|
||||
|
||||
- **Autostart** / **Background activity** / **Battery** menus on Samsung, Xiaomi (MIUI), Oppo/ColorOS, Huawei, OnePlus, etc.
|
||||
- Symptom: FCM works on Pixel but not on OEM device until autostart enabled.
|
||||
- Provide a short “if wake fails on OEM, check…” checklist without exhaustive per-OEM screenshots (link community docs if needed).
|
||||
- Physical device testing should include at least one **stock-ish** device (Pixel) and one **OEM** device when possible.
|
||||
|
||||
---
|
||||
|
||||
## Proposed outline for `doc/local-android-testing-ngrok.md`
|
||||
|
||||
Suggested section order mirroring iOS doc for easy maintenance:
|
||||
|
||||
1. Title, audience, goal (Android physical device + ngrok + wakeup service)
|
||||
2. Architecture overview (FCM direct to Android)
|
||||
3. Prerequisites (Android Studio, device, Firebase Android app, non-prod build)
|
||||
4. ngrok install, account, tunnel (**reuse iOS §1**)
|
||||
5. Start notification-wakeup-service (**reuse iOS §2**)
|
||||
6. ngrok HTTPS URL (**reuse iOS §3**)
|
||||
7. Build and open Android project (**new**, replaces iOS §4)
|
||||
8. Firebase setup for Android (**new**, replaces iOS §5 — no APNs)
|
||||
9. Notification Debug Panel (**reuse iOS §6**)
|
||||
10. Android configuration checklist (**new**, replaces iOS §7)
|
||||
11. Android-specific testing notes (**new**, replaces iOS §8)
|
||||
12. Recommended debug workflow (**reuse iOS §9** + logcat)
|
||||
13. Sample curl commands (**reuse iOS §10** + `platform: "android"`)
|
||||
14. Troubleshooting (**merge reusable + Android push rows**)
|
||||
15. Key source files (**reuse iOS §12**)
|
||||
16. Related docs (**iOS doc + Android device guide + BUILDING**)
|
||||
|
||||
---
|
||||
|
||||
## Wording and terminology substitutions
|
||||
|
||||
When adapting reused sections:
|
||||
|
||||
| iOS doc term | Android doc term |
|
||||
|--------------|------------------|
|
||||
| iPhone | Android phone / device |
|
||||
| Xcode console | logcat / Android Studio Logcat |
|
||||
| `build:ios:dev` / `test` | `build:android:dev` / `test` |
|
||||
| `GoogleService-Info.plist` | `google-services.json` |
|
||||
| APNs / silent push | FCM data message / high-priority data |
|
||||
| Bundle ID | Application ID / package name (`app.timesafari`) |
|
||||
| Physical iPhone required for APNs | Physical device strongly recommended for Doze/OEM/FCM realism |
|
||||
| `platform: "ios"` in curl | `platform: "android"` |
|
||||
|
||||
---
|
||||
|
||||
## Gaps to resolve while writing the Android guide
|
||||
|
||||
Research during authoring (code + **notification-wakeup-service** + **daily-notification-plugin**):
|
||||
|
||||
1. Exact FCM Android message priority and payload fields for `WAKEUP_PING` (parity with iOS data message).
|
||||
2. Whether `POST_NOTIFICATIONS` denial blocks data message delivery to JS listeners on API 33+.
|
||||
3. Gradle/Firebase plugin versions already in `android/` — document exact files to touch.
|
||||
4. Android **Pending Notification Inspector** parity with iOS panel section.
|
||||
5. Whether emulator with Google Play image is acceptable for minimal FCM smoke tests vs mandatory physical device for wakeup SLA testing.
|
||||
|
||||
---
|
||||
|
||||
## Document maintenance
|
||||
|
||||
| Document | Role |
|
||||
|----------|------|
|
||||
| [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md) | Canonical iOS + ngrok workflow (unchanged by this analysis) |
|
||||
| **This file** | Reuse vs rewrite matrix and Android topic backlog |
|
||||
| *Future* `local-android-testing-ngrok.md` | Operator guide for Android testers |
|
||||
|
||||
When backend or debug panel behavior changes, update **both** platform guides’ shared sections in lockstep (or extract shared “ngrok + debug panel” snippet later — out of scope unless requested).
|
||||
1030
doc/local-android-testing-ngrok.md
Normal file
1030
doc/local-android-testing-ngrok.md
Normal file
File diff suppressed because it is too large
Load Diff
543
doc/local-ios-testing-ngrok.md
Normal file
543
doc/local-ios-testing-ngrok.md
Normal file
@@ -0,0 +1,543 @@
|
||||
# Local iOS Testing with ngrok (notification-wakeup-service)
|
||||
|
||||
**Last updated:** 2026-05-18
|
||||
**Audience:** Developers on **crowd-funder-for-time-pwa**, **daily-notification-plugin**, and **notification-wakeup-service**
|
||||
**Goal:** Exercise silent push wake (`WAKEUP_PING`), FCM token registration, and notification refresh against a Mac-hosted backend reachable from a physical iPhone.
|
||||
|
||||
---
|
||||
|
||||
## Architecture overview
|
||||
|
||||
End-to-end flow when testing New Activity / silent wake on a physical iPhone:
|
||||
|
||||
```text
|
||||
┌─────────────────────┐ HTTPS ┌──────────────────────┐
|
||||
│ Mac (localhost) │ ◄───────────── │ ngrok edge │
|
||||
│ notification- │ tunnel │ (public HTTPS URL) │
|
||||
│ wakeup-service │ └──────────┬───────────┘
|
||||
└──────────┬──────────┘ │
|
||||
│ │ fetch
|
||||
│ POST /notifications/refresh │ POST /notifications/register
|
||||
│ ▼
|
||||
│ ┌──────────────────────┐
|
||||
│ │ crowd-funder-for- │
|
||||
│ │ time-pwa (Capacitor │
|
||||
│ │ iOS on iPhone) │
|
||||
│ └──────────┬───────────┘
|
||||
│ │
|
||||
│ FCM data message (WAKEUP_PING) │ daily-notification-plugin
|
||||
▼ ▼ (local schedule replace)
|
||||
┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ Firebase Cloud │ ──APNs──────► │ iPhone (physical) │
|
||||
│ Messaging │ silent push │ app.timesafari │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
### Repos and responsibilities
|
||||
|
||||
| Repo | Role |
|
||||
|------|------|
|
||||
| **notification-wakeup-service** | HTTP API: device registration, refresh payload (`nextNotifications`), health, debug wakeup send |
|
||||
| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register` & `/refresh`, handles `WAKEUP_PING` push |
|
||||
| **daily-notification-plugin** | Native iOS/Android: clear + reschedule local notifications from refresh timestamps |
|
||||
|
||||
### Silent wake sequence (production path)
|
||||
|
||||
1. Backend (or `/debug/send-wakeup`) sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
|
||||
2. APNs delivers to the device (best-effort; see iOS caveats below).
|
||||
3. Capacitor `pushNotificationReceived` fires → `handleCapacitorPushNotificationReceived()`.
|
||||
4. 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`).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Mac with Xcode, Node.js 18+, and the **notification-wakeup-service** repo cloned and runnable
|
||||
- Physical iPhone (USB or wireless debugging) — **simulator is not sufficient** for reliable silent push / APNs behavior
|
||||
- ngrok account (free tier is enough for dev)
|
||||
- Firebase project with APNs configured for the iOS app bundle ID
|
||||
- Non-production app build (Notification Debug Panel is dev-only)
|
||||
|
||||
---
|
||||
|
||||
## 1. Install and configure ngrok (macOS)
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
# Homebrew
|
||||
brew install ngrok/ngrok/ngrok
|
||||
```
|
||||
|
||||
Or download from [https://ngrok.com/download](https://ngrok.com/download).
|
||||
|
||||
### Account and auth token
|
||||
|
||||
1. Sign up at [https://dashboard.ngrok.com/signup](https://dashboard.ngrok.com/signup).
|
||||
2. Copy your authtoken from **Your Authtoken** in the dashboard.
|
||||
3. Configure the CLI:
|
||||
|
||||
```bash
|
||||
ngrok config add-authtoken YOUR_AUTHTOKEN_HERE
|
||||
```
|
||||
|
||||
### Start a tunnel to the wakeup service
|
||||
|
||||
Assume the service listens on port **3000** (confirm in **notification-wakeup-service** `README` or `.env`).
|
||||
|
||||
If the service already defaults to port 3000 internally, you may not need to export PORT manually.
|
||||
|
||||
```bash
|
||||
# Terminal A — backend
|
||||
cd /path/to/notification-wakeup-service
|
||||
|
||||
npm install
|
||||
|
||||
# one-time setup if needed
|
||||
cp .env.example .env
|
||||
|
||||
# configure Firebase/service account/etc as required
|
||||
export PORT=3000
|
||||
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```bash
|
||||
# Terminal B — ngrok
|
||||
ngrok http 3000
|
||||
```
|
||||
|
||||
The backend only needs to be started once. The dedicated backend section below exists for verification and troubleshooting details, not as a second startup step.
|
||||
|
||||
ngrok prints a forwarding URL, for example:
|
||||
|
||||
```text
|
||||
Forwarding https://abc123.ngrok-free.app -> http://localhost:3000
|
||||
```
|
||||
|
||||
Use the **HTTPS** URL (not `http://127.0.0.1:3000`). The iPhone cannot reach your Mac’s localhost without the tunnel.
|
||||
|
||||
> **Note:** Free ngrok URLs change every time you restart ngrok unless you use a reserved domain (paid). Update the app debug override whenever the URL changes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Start the backend locally
|
||||
|
||||
Example (adjust to match **notification-wakeup-service**). On first setup, copy `.env.example` to `.env` and set Firebase service account, `PORT`, and other variables per that repo's docs.
|
||||
|
||||
If the backend is not already running from section 1:
|
||||
|
||||
```bash
|
||||
# If not already running from the previous step:
|
||||
cd /path/to/notification-wakeup-service
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Verify locally before ngrok:
|
||||
|
||||
```bash
|
||||
curl -sS http://localhost:3000/health
|
||||
```
|
||||
|
||||
Expected: HTTP 200 and a JSON body indicating the service is up (exact shape depends on that repo).
|
||||
|
||||
---
|
||||
|
||||
## 3. Obtain and use the ngrok HTTPS URL
|
||||
|
||||
1. Run `ngrok http <PORT>`.
|
||||
2. Copy the `https://….ngrok-free.app` host from the **Forwarding** line.
|
||||
3. Do **not** add a trailing slash when saving in the app (the debug config trims it).
|
||||
4. Optional: open `http://127.0.0.1:4040` (ngrok web UI) to inspect requests and responses while testing.
|
||||
|
||||
Test through the tunnel from your Mac:
|
||||
|
||||
```bash
|
||||
export NGROK_URL="https://abc123.ngrok-free.app"
|
||||
curl -sS "$NGROK_URL/health"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Generate and open the iOS workspace
|
||||
|
||||
From **crowd-funder-for-time-pwa**, generate the Capacitor iOS project and open it in Xcode. **[Section 5](#5-firebase--apns-setup-first-time-setup) (Firebase + APNs)** needs this workspace—for example to add `GoogleService-Info.plist` and enable Push Notifications in the app target. The app does not need Firebase or push fully configured yet; the goal here is a buildable Xcode project on your Mac.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build:ios:dev # or build:ios:test — non-production for debug panel
|
||||
```
|
||||
|
||||
Open the generated Xcode workspace (for example `ios/App/App.xcworkspace`), select your **physical iPhone**, enable signing, and Run when you are ready to verify the app launches.
|
||||
|
||||
Ensure `VITE_FIREBASE_*` variables are set for the Capacitor build you use (see `.env` / build docs). Native push registration runs at startup via `initializeNativePushAndFirebaseMessaging()` in `main.capacitor.ts` once Firebase is configured in the next section.
|
||||
|
||||
---
|
||||
|
||||
## 5. Firebase + APNs setup (first-time setup)
|
||||
|
||||
Complete this section once before your first physical-device push test. If Firebase and APNs are already configured for this app, skip to [section 6](#6-configure-the-notification-debug-panel-backend-override).
|
||||
|
||||
### Create or access a Firebase account
|
||||
|
||||
1. Sign in with a Google account at [https://console.firebase.google.com/](https://console.firebase.google.com/).
|
||||
|
||||
2. If this is your first time using Firebase:
|
||||
- Accept the Firebase terms.
|
||||
- Create a new Firebase account/workspace when prompted.
|
||||
|
||||
3. No paid Firebase plan is required for local iOS notification testing. The free **Spark** plan is sufficient for:
|
||||
- Firebase Cloud Messaging (FCM)
|
||||
- APNs silent push testing
|
||||
- local ngrok-based development
|
||||
|
||||
### Create a Firebase project
|
||||
|
||||
1. In the [Firebase Console](https://console.firebase.google.com/), click **Add project** (or **Create a project**).
|
||||
2. Enter a project name (for example, `timesafari-dev`) and continue through the wizard.
|
||||
3. **Google Analytics** is optional for this workflow; you can disable it for a simpler dev project.
|
||||
4. When the project is created, open it. **Cloud Messaging** is available on all projects — you do not need a separate enable step for FCM.
|
||||
|
||||
### Register the iOS app in Firebase
|
||||
|
||||
1. In the project overview, click the **iOS** icon (**Add app** → iOS).
|
||||
2. Enter the **Apple bundle ID**. It must **exactly** match the Capacitor / Xcode app ID:
|
||||
- **`app.timesafari`** (see `appId` in `capacitor.config.ts` and the Xcode target **Bundle Identifier**).
|
||||
3. App nickname and App Store ID are optional for local testing; continue.
|
||||
4. Download **`GoogleService-Info.plist`** when prompted and keep it handy for the next step.
|
||||
|
||||
### Add GoogleService-Info.plist to Xcode
|
||||
|
||||
1. Open the iOS workspace you generated in [section 4](#4-generate-and-open-the-ios-workspace) (for example `ios/App/App.xcworkspace`).
|
||||
2. In the Project Navigator, drag **`GoogleService-Info.plist`** into the **App** folder (the same one that contains AppDelegate.swift and Info.plist).
|
||||
3. In the dialog that appears:
|
||||
- Check **Copy items if needed** (so the file is copied into the project tree).
|
||||
- Under **Add to targets**, ensure the main app target (not only the share extension) is checked.
|
||||
4. Confirm the file appears under the app target in Xcode and is listed in **Build Phases** → **Copy Bundle Resources** if your project uses that phase for plists.
|
||||
|
||||
### Create an APNs Authentication Key
|
||||
|
||||
Apple uses APNs to deliver pushes to devices; Firebase needs an APNs key to talk to Apple on your behalf.
|
||||
|
||||
1. Sign in to [Apple Developer](https://developer.apple.com/account/) → **Certificates, Identifiers & Profiles**.
|
||||
2. Open **Keys** → **+** (create a new key).
|
||||
3. Name the key (for example, `Timesafari APNs Dev`).
|
||||
4. Enable **Apple Push Notifications service (APNs)** and continue.
|
||||
5. Register the key, then **Download** the `.p8` file. **You can download it only once** — store it securely.
|
||||
6. Note:
|
||||
- **Key ID** (shown on the key detail page)
|
||||
- **Team ID** (top right of the developer portal, or **Membership** details)
|
||||
|
||||
### Upload APNs key to Firebase
|
||||
|
||||
1. Firebase Console → your project → **Project settings** (gear icon).
|
||||
2. Open the **Cloud Messaging** tab.
|
||||
3. Under **Apple app configuration**, select your iOS app (`app.timesafari`) if prompted.
|
||||
4. Under **APNs Authentication Key**, click **Upload**.
|
||||
5. Select the `.p8` file and enter:
|
||||
- **Key ID**
|
||||
- **Team ID**
|
||||
6. Save. Firebase can now send FCM messages through APNs to your iOS app.
|
||||
|
||||
### Enable iOS capabilities in Xcode
|
||||
|
||||
1. Select the **App** target → **Signing & Capabilities**.
|
||||
2. Click **+ Capability** and add **Push Notifications**.
|
||||
3. Click **+ Capability** again and add **Background Modes**.
|
||||
4. Under Background Modes, enable **Remote notifications**.
|
||||
|
||||
These match what silent / data wake flows expect for background delivery.
|
||||
|
||||
### Configure Firebase Admin for the backend
|
||||
|
||||
**notification-wakeup-service** uses the Firebase Admin SDK to send FCM (and thus APNs) messages from your Mac.
|
||||
|
||||
1. Firebase Console → **Project settings** → **Service accounts**.
|
||||
2. Click **Generate new private key** and confirm download of the JSON file.
|
||||
3. Store the JSON outside the repo (do not commit it).
|
||||
4. Point the backend at it, for example:
|
||||
|
||||
```bash
|
||||
export GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/to/service-account.json"
|
||||
```
|
||||
|
||||
The backend uses this credential to authenticate with Firebase when calling endpoints such as `/debug/send-wakeup`. Set the same variable (or the equivalent env var documented in **notification-wakeup-service**) in the shell where you run `npm run dev`, or add it to that repo’s `.env` per its README.
|
||||
|
||||
### Verify Firebase configuration
|
||||
|
||||
Before ngrok end-to-end testing, confirm:
|
||||
|
||||
- [ ] App builds and launches on a **physical** iPhone without Firebase/plist errors in Xcode.
|
||||
- [ ] iOS shows the push **permission** prompt (or Settings → app → Notifications is enabled).
|
||||
- [ ] **Notification Debug Panel** shows an FCM token (after permission).
|
||||
- [ ] **Register Token Now** succeeds and ngrok (or local backend) shows `POST /notifications/register`.
|
||||
- [ ] Backend health and Firebase Admin env are set so `/debug/send-wakeup` can run when you reach that step in the workflow below.
|
||||
|
||||
---
|
||||
|
||||
## 6. Configure the Notification Debug Panel backend override
|
||||
|
||||
The app normally calls `DEFAULT_NOTIFY_API_SERVER` (from `VITE_DEFAULT_NOTIFY_API_SERVER`, falling back to `AppString.PROD_NOTIFY_API_SERVER`). That is independent of `APP_SERVER`. For local wakeup testing, override the notification API base URL in the Debug Panel without rebuilding.
|
||||
|
||||
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
|
||||
|
||||
### Open the panel
|
||||
|
||||
1. Use a **non-production** bundle (e.g. dev/test build).
|
||||
2. **Account** → enable **Show All General Advanced Functions**.
|
||||
3. Open **Notification Debug Panel** (route `/dev/notifications`).
|
||||
|
||||
### Backend Testing section
|
||||
|
||||
| Control | Purpose |
|
||||
|---------|---------|
|
||||
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) |
|
||||
| **Test Mode** | Sends `testMode: true/false` in register/refresh JSON bodies (default on when unset in storage) |
|
||||
| **Skip JWT Authentication (Local Development Only)** | When on, omits `Authorization` headers for local servers that accept unauthenticated requests (default **off**) |
|
||||
| **Register Token Now** | `POST /notifications/register` with current FCM token |
|
||||
| **Refresh Notifications** | `POST /notifications/refresh` (same as post-wakeup flow) |
|
||||
| **Simulate WAKEUP_PING (Local)** | Calls refresh API directly (no FCM) — quick backend test |
|
||||
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; server sends real FCM `WAKEUP_PING` (Android doc has full flow) |
|
||||
| **Event Log** | Shared `[Notifications]` panel log (100 entries) |
|
||||
|
||||
Persistence: `localStorage` keys `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, and `notificationDebug.bypassAuth` (`NotificationDebugConfig.ts`).
|
||||
|
||||
### Authentication vs backend URL
|
||||
|
||||
These settings are **independent**:
|
||||
|
||||
- **Backend URL** — which server receives notification API calls.
|
||||
- **Test Mode** — `testMode` field in JSON request bodies only.
|
||||
- **Skip JWT Authentication** — whether JWT `Authorization` headers are sent.
|
||||
|
||||
For a **hosted shared test server**: set the backend URL, keep **Test Mode** on if required, leave **Skip JWT Authentication** **off**, and ensure an active DID exists.
|
||||
|
||||
For **local ngrok**: set the backend URL; enable **Skip JWT Authentication** only if your local backend accepts unauthenticated requests.
|
||||
|
||||
### Programmatic override (optional)
|
||||
|
||||
From Safari Web Inspector or a dev console attached to the WebView:
|
||||
|
||||
```javascript
|
||||
import {
|
||||
setBackendBaseUrl,
|
||||
setTestMode,
|
||||
setBypassAuth,
|
||||
getNotificationApiBaseUrl,
|
||||
} from "@/services/notifications";
|
||||
|
||||
setBackendBaseUrl("https://abc123.ngrok-free.app");
|
||||
setTestMode(true);
|
||||
setBypassAuth(true); // local dev only — omit for hosted servers that require JWT
|
||||
getNotificationApiBaseUrl(); // → ngrok URL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Firebase and Xcode checklist (iOS)
|
||||
|
||||
This section is a quick verification checklist for the detailed Firebase/APNs setup steps above.
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| **Bundle ID** | Match Capacitor `appId` (`app.timesafari` in `capacitor.config.ts`) to Firebase iOS app and Xcode target |
|
||||
| **APNs auth key** | Firebase Console → Project Settings → Cloud Messaging → upload **APNs Authentication Key** (.p8) or certificates |
|
||||
| **Push Notifications** | Xcode target → **Signing & Capabilities** → **+ Capability** → **Push Notifications** |
|
||||
| **Background Modes** | Enable **Remote notifications** (and any others required by your plugin docs) |
|
||||
| **GoogleService-Info.plist** | Present in the iOS target if using Firebase iOS SDK paths in your build |
|
||||
| **FCM token** | Confirm **Register Token Now** succeeds in the debug panel and ngrok shows `POST /notifications/register` |
|
||||
|
||||
Silent/data pushes used for wake typically use a **content-available** style payload; confirm **notification-wakeup-service** and Firebase message format match what `handleCapacitorPushNotificationReceived` expects (`data.type === "WAKEUP_PING"`).
|
||||
|
||||
---
|
||||
|
||||
## 8. iOS-specific testing notes
|
||||
|
||||
### Physical device required
|
||||
|
||||
- APNs silent delivery and background wake behavior are **not** representative on the iOS Simulator.
|
||||
- Always validate on a plugged-in or trusted wireless device with a development provisioning profile.
|
||||
|
||||
### Silent push is best-effort
|
||||
|
||||
- iOS may **delay or coalesce** background pushes, especially on battery saver or under load.
|
||||
- A successful `/debug/send-wakeup` from the server does not guarantee immediate app wake.
|
||||
|
||||
### Force-quit limitations
|
||||
|
||||
- If the user **swipes the app away** from the app switcher, iOS often **will not** deliver background notifications until the user launches the app again.
|
||||
- Test with the app **backgrounded** (home button / gesture), not force-quit, when validating wake.
|
||||
|
||||
### Low Power Mode and Focus
|
||||
|
||||
- **Low Power Mode** can reduce background execution.
|
||||
- **Focus / Do Not Disturb** may affect notification presentation (separate from silent data wake, but confusing during tests).
|
||||
|
||||
### Two “Simulate WAKEUP_PING” buttons
|
||||
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommended debug workflow
|
||||
|
||||
1. Start **notification-wakeup-service** on the Mac.
|
||||
2. Start **ngrok** and copy the HTTPS URL.
|
||||
3. Set URL + **Test Mode** in the Notification Debug Panel; confirm **Backend Status**.
|
||||
4. Tap **Register Token Now** → confirm ngrok request and `[Notifications] Token registration success`.
|
||||
5. Tap **Refresh Notifications** → confirm `Refresh completed in Nms (scheduled X)` in Event Log and ngrok `POST /notifications/refresh`.
|
||||
6. From the backend, call **`/debug/send-wakeup`** (see curl below) with the registered `deviceId` / FCM token as required by that service.
|
||||
7. Watch **Xcode console** for `[Notifications] pushNotificationReceived type=WAKEUP_PING` and refresh timing lines.
|
||||
8. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate requests.
|
||||
9. Use **Pending Notification Inspector** on the panel to see locally scheduled fires after refresh.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sample curl commands
|
||||
|
||||
Set your tunnel base URL:
|
||||
|
||||
```bash
|
||||
export BASE="https://abc123.ngrok-free.app"
|
||||
```
|
||||
|
||||
### Health
|
||||
|
||||
```bash
|
||||
curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
|
||||
```
|
||||
|
||||
### Register device (mirror app payload)
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/notifications/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"deviceId": "00000000-0000-4000-8000-000000000001",
|
||||
"fcmToken": "YOUR_FCM_TOKEN_FROM_DEBUG_PANEL",
|
||||
"platform": "ios",
|
||||
"testMode": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Refresh (mirror app payload)
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/notifications/refresh" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"platform": "ios",
|
||||
"testMode": true
|
||||
}'
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
Exact path and body depend on **notification-wakeup-service**; typical pattern:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/debug/send-wakeup" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"deviceId": "00000000-0000-4000-8000-000000000001",
|
||||
"testMode": true
|
||||
}'
|
||||
```
|
||||
|
||||
Confirm parameters (token vs deviceId, auth headers) in that repo’s README or OpenAPI spec.
|
||||
|
||||
---
|
||||
|
||||
## 11. Troubleshooting
|
||||
|
||||
### Refresh endpoint unreachable
|
||||
|
||||
| Symptom | Checks |
|
||||
|---------|--------|
|
||||
| Network error in Event Log | ngrok running? URL saved without typo/trailing slash? |
|
||||
| HTTP 404 | Tunnel port matches backend `PORT`; path is `/notifications/refresh` |
|
||||
| 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 |
|
||||
|
||||
### Token registration failures
|
||||
|
||||
- Push permission granted on the device?
|
||||
- Firebase `VITE_FIREBASE_*` env vars baked into the build?
|
||||
- `[Notifications] Token registration failure` in Xcode — read HTTP status in ngrok inspect
|
||||
- Duplicate token skip: panel may show “skipped (duplicate)”; use **Register Token Now** to force re-register
|
||||
|
||||
### Silent push not waking the app
|
||||
|
||||
- App **backgrounded**, not force-quit
|
||||
- Physical device, correct provisioning profile
|
||||
- APNs key uploaded to Firebase; bundle ID matches
|
||||
- FCM message includes `data.type = "WAKEUP_PING"` (see `NativeNotificationService.ts`)
|
||||
- Server actually sent to the **same** FCM token shown in the debug panel
|
||||
- Wait 30–120s — delivery is not instant
|
||||
- Try **Simulate WAKEUP_PING** (refresh API) to isolate app/plugin from FCM/APNs
|
||||
|
||||
### Notifications duplicating
|
||||
|
||||
- Multiple refresh calls (flood test, repeated wakeups) each **replace** schedule via clear + schedule — check Event Log for repeated refreshes
|
||||
- Separate issue: Daily Reminder vs New Activity both scheduling — see `doc/notification-new-activity-lay-of-the-land.md`
|
||||
|
||||
### Stale ngrok URL
|
||||
|
||||
- After restarting ngrok, update **Notification Backend URL** in the panel and tap **Save**
|
||||
- Or clear override (empty field + Save) only if you intend to hit `DEFAULT_NOTIFY_API_SERVER` again
|
||||
|
||||
### Plugin / JWT errors after refresh
|
||||
|
||||
- Refresh calls `configureNativeFetcherIfReady()` before scheduling — ensure an **active DID** and endorser API settings exist in the app DB
|
||||
- See `doc/notification-from-api-call.md` and `nativeFetcherConfig.ts`
|
||||
|
||||
---
|
||||
|
||||
## 12. Key source files (crowd-funder-for-time-pwa)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/services/notifications/NotificationDebugConfig.ts` | Backend URL, testMode, and bypassAuth overrides |
|
||||
| `src/services/notifications/NotificationDebugEvents.ts` | Panel event log + `logNotification()` |
|
||||
| `src/services/notifications/notificationLog.ts` | Structured log helpers |
|
||||
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
|
||||
| `src/services/notifications/NativeNotificationService.ts` | Refresh, `WAKEUP_PING`, schedule replace |
|
||||
| `src/services/notifications/firebaseMessagingClient.ts` | Capacitor push listeners |
|
||||
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
|
||||
| `src/main.capacitor.ts` | Native push init at startup |
|
||||
|
||||
---
|
||||
|
||||
## 13. Related docs
|
||||
|
||||
- [notification-debug-panel.md](./notification-debug-panel.md) — panel controls, authentication, troubleshooting
|
||||
- [Notification Debug Panel (README)](../README.md#notification-debug-panel-dev-builds)
|
||||
- [notification-system-overview.md](./notification-system-overview.md)
|
||||
- [notification-from-api-call.md](./notification-from-api-call.md)
|
||||
- [notification-new-activity-lay-of-the-land.md](./notification-new-activity-lay-of-the-land.md)
|
||||
- [BUILDING.md](../BUILDING.md) — iOS build commands
|
||||
|
||||
For plugin-native behavior (exact alarm, iOS pending inspector), see **daily-notification-plugin** documentation. For FCM payload format and `/debug/send-wakeup` contract, see **notification-wakeup-service**.
|
||||
158
doc/new-activity-notifications-ios-android-parity.md
Normal file
158
doc/new-activity-notifications-ios-android-parity.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# New Activity Notifications: iOS Parity with Android
|
||||
|
||||
**Purpose:** Describe what is required for **iOS** to match **Android** for the daily-notification-plugin **API-driven “New Activity”** flow (`scheduleDualNotification` / `cancelDualSchedule`, with prefetch and Endorser-backed content). The canonical product behavior is documented in `doc/notification-from-api-call.md` and `doc/notification-new-activity-lay-of-the-land.md`.
|
||||
|
||||
**Plugin source of truth:** The Capacitor package is `@timesafari/daily-notification-plugin`, pulled from the official remote in `package.json` (`git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git`). Plugin development happens in that repository; this app bumps the dependency and runs `npm install` / `npx cap sync` after releases.
|
||||
|
||||
---
|
||||
|
||||
## 1. What “parity” means here
|
||||
|
||||
| Concern | Intended behavior |
|
||||
|--------|---------------------|
|
||||
| **Scheduling** | Dual schedule: prefetch job **before** notify time (app uses cron T−5 minutes), then user-visible notification at the chosen time. |
|
||||
| **API content** | Prefetch calls the **same Endorser semantics** as the Android host: **`plansLastUpdatedBetween`** (POST) with **starred plan IDs**, JWT auth, aggregated titles/bodies consistent with `TimeSafariNativeFetcher`. |
|
||||
| **Starred plans** | `updateStarredPlans({ planIds })` from the app must affect what the native prefetch queries. |
|
||||
| **Configure** | `configureNativeFetcher({ apiBaseUrl, activeDid, jwtToken, … })` supplies credentials the native layer uses for prefetch. |
|
||||
| **Lifecycle** | `cancelDualSchedule()` removes the dual prefetch + notify schedule without breaking the separate Daily Reminder. |
|
||||
|
||||
Platform differences (iOS **BGTaskScheduler** is opportunistic; Android **alarms/WorkManager** can be more exact) mean **timing** may never be identical, but **API behavior and user-visible copy** should align.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state: Android (this app)
|
||||
|
||||
- **Host native fetcher:** `android/.../TimeSafariNativeFetcher.java` implements the plugin’s `NativeNotificationContentFetcher` and calls **`POST …/api/v2/report/plansLastUpdatedBetween`** using starred plan IDs (via plugin storage from `updateStarredPlans`).
|
||||
- **Registration:** `MainActivity` calls `DailyNotificationPlugin.setNativeFetcher(new TimeSafariNativeFetcher(this))`.
|
||||
- **Plugin (Android) — older notes:** Prior dual-schedule issues (native fetcher / fetch cron) are addressed in **plugin ≥ 3.0.0** (chained dual: notify after prefetch). Historical analysis: `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Current state: iOS (this app + bundled plugin)
|
||||
|
||||
### 3.1 This repository
|
||||
|
||||
- **iOS native fetcher:** `ios/App/App/TimeSafariNativeFetcher.swift` implements `NativeNotificationContentFetcher` (Endorser `plansLastUpdatedBetween`, same prefs keys as Java). **`AppDelegate`** calls `DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)` at launch **before** any `configureNativeFetcher` from JS (see plugin `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` and **`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`**).
|
||||
- **JS/TS is already shared:** `nativeFetcherConfig.ts`, `dualScheduleConfig.ts`, `syncStarredPlansToNativePlugin.ts`, and `AccountViewView.vue` call the same APIs on both platforms.
|
||||
- **Info.plist** already lists `UIBackgroundModes` (fetch, processing) and `BGTaskSchedulerPermittedIdentifiers` for the plugin’s task IDs. Xcode **Signing & Capabilities** should still enable **Background fetch** and **Background processing** (see `doc/daily-notification-plugin-integration.md`).
|
||||
- **AppDelegate** posts `DailyNotificationDelivered` for foreground presentation—aligned with plugin rollover behavior.
|
||||
|
||||
### 3.2 Bundled plugin (`node_modules/@timesafari/daily-notification-plugin`, iOS)
|
||||
|
||||
Requires **plugin ≥ 3.0.0** (register native fetcher, chained dual, iOS `updateStarredPlans`). Version pinned in `ios/App/Podfile.lock` after `pod install`.
|
||||
|
||||
- **`scheduleDualNotification` / `cancelDualSchedule`** — see plugin release notes; clean sync + `pod install` if you see `UNIMPLEMENTED` (`doc/plugin-feedback-ios-scheduleDualNotification.md`).
|
||||
- **`configureNativeFetcher`** — **requires** `DailyNotificationPlugin.registerNativeFetcher` first; the host Swift fetcher performs **`plansLastUpdatedBetween`** (plugin does not use in-plugin `offers` GET when a fetcher is registered—mirrors Android).
|
||||
- **`updateStarredPlans`** — implemented on iOS in current plugin; persists **`daily_notification_timesafari.starredPlanIds`** for the host fetcher.
|
||||
- **Chained dual** — user notification is armed **after** prefetch for that cycle (plugin); iOS remains subject to BG scheduling limits; see **§3.3**.
|
||||
|
||||
### 3.3 Prefetch before notify (ordering, not cron)
|
||||
|
||||
iOS has no system cron; the app/plugin may still **parse** cron to compute “next run” times. The hard part is **ordering**: if **prefetch** is driven by **`BGTaskScheduler`** (opportunistic) and **notify** by **`UNUserNotificationCenter`** at a fixed time **T**, those are **independent**. The OS can deliver the local notification at **T** while prefetch runs **after** **T** or not at all—so the awkward case (notify first, prefetch later, stale or fallback content) **can** happen. Two peer timers do **not** imply “fetch always completes before **T**.”
|
||||
|
||||
To **enforce** prefetch-before-notify as a rule, use **chaining**, not two unrelated schedules:
|
||||
|
||||
- After prefetch for that cycle **finishes** (success or explicit timeout policy), **then** schedule or **replace** the pending `UNNotificationRequest` for time **T** with the resolved title/body (or fallback). Until then, do not arm a user-visible notification that claims fresh API content.
|
||||
- **Tradeoffs:** If prefetch is late, the notification may be **late**; if prefetch never runs before a deadline, use **fallback** copy at **T** or skip—product choice.
|
||||
- **Parsing cron** remains useful to compute **T** and to decide when to **submit** BG work; **ordering** is a **pipeline** decision (fetch → cache → arm notify), not “BG at T−5 and UN at **T** both scheduled up front.”
|
||||
|
||||
Plugin work item **§4A.3** should reflect this: document the chosen strategy (chained arm vs best-effort dual timer) and how it interacts with `relationship.contentTimeout` / fallback.
|
||||
|
||||
---
|
||||
|
||||
## 4. Work breakdown
|
||||
|
||||
### 4A. Plugin (`daily-notification-plugin`) — status (v3.x)
|
||||
|
||||
Items below were the original gap list; **plugin ≥ 3.0.0** ships **iOS** `updateStarredPlans`, **`registerNativeFetcher`**, **chained dual** on iOS and Android, and Android dual-path fixes. Remaining work is **release coordination** (bump, sync, QA), not greenfield plugin implementation.
|
||||
|
||||
1. **`updateStarredPlans` on iOS** — shipped in current plugin.
|
||||
|
||||
2. **iOS `plansLastUpdatedBetween` / host fetcher** — shipped: host registers **`TimeSafariNativeFetcher`** (Swift); plugin does not duplicate Endorser logic when a fetcher is registered.
|
||||
|
||||
3. **Dual schedule / chaining** — shipped (notify after prefetch; see plugin release notes and **§3.3**).
|
||||
|
||||
4. **Android dual path** — chained dual + native fetcher alignment in current plugin (see `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` for historical context).
|
||||
|
||||
5. **JWT pool / expiry (Phase B)**
|
||||
- **App:** Phase B is already implemented: `configureNativeFetcherIfReady()` passes `jwtTokens` from `mintBackgroundJwtTokenPool` on **both** iOS and Android (`src/services/notifications/nativeFetcherConfig.ts`).
|
||||
- **Android:** `TimeSafariNativeFetcher` selects a bearer from the pool for background requests (`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`).
|
||||
- **iOS:** The bundled plugin’s `configureNativeFetcher` **already accepts and persists** `jwtTokens` / `jwtTokenPoolJson`, and the in-plugin fetch path uses a bearer from the primary token or pool. What is **not** yet at parity with Android is **which API** that token is used for (`offers` GET vs `plansLastUpdatedBetween` + starred plans)—that falls under **§4A.2**, not “waiting for Phase B on iOS.”
|
||||
- **Expiry:** Re-calling `configureNativeFetcherIfReady` on foreground / Account (see `notification-from-api-call.md`) remains relevant on both platforms.
|
||||
|
||||
### 4B. This app (crowd-funder-for-time-pwa) — after or alongside plugin changes
|
||||
|
||||
1. **Bump `@timesafari/daily-notification-plugin`** to **≥ 3.0.0** via the git dependency in `package.json`, run `npm install`, `npx cap sync ios`, `cd ios/App && pod install`, clean build (`doc/plugin-feedback-ios-scheduleDualNotification.md`, **`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`**).
|
||||
2. **iOS native fetcher** — **Done:** `TimeSafariNativeFetcher.swift` + `registerNativeFetcher` in `AppDelegate` (see handoff doc).
|
||||
3. **Re-test** `syncStarredPlansToNativePlugin` on iOS; the helper may still catch `UNIMPLEMENTED` for older plugin binaries.
|
||||
4. **Xcode:** Confirm Background Modes capabilities match `Info.plist`.
|
||||
5. **QA:** Full matrix in `doc/notification-from-api-call.md` (enable/disable, empty starred list, JWT expiry, foreground/background); chained dual timing (notify after prefetch).
|
||||
|
||||
### 4C. Related product bug (both platforms)
|
||||
|
||||
- **`PushNotificationPermission.vue` vs New Activity:** Enabling New Activity can still schedule the **single** daily reminder by mistake; turning New Activity off may not cancel that reminder. See `doc/notification-new-activity-lay-of-the-land.md`. Fixing this is orthogonal to iOS/Android API parity but affects perceived “notifications behavior.”
|
||||
|
||||
---
|
||||
|
||||
## 5. Reference map (this repo)
|
||||
|
||||
| Topic | Document |
|
||||
|-------|-----------|
|
||||
| Plugin post-bump handoff (iOS fetcher + chained dual) | `doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md` |
|
||||
| Feature plan & file list | `doc/notification-from-api-call.md` |
|
||||
| Dual vs Daily Reminder confusion | `doc/notification-new-activity-lay-of-the-land.md` |
|
||||
| iOS `UNIMPLEMENTED` / PluginHeaders | `doc/plugin-feedback-ios-scheduleDualNotification.md` |
|
||||
| Android dual schedule + native fetcher | `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` |
|
||||
| Integration & Xcode | `doc/daily-notification-plugin-integration.md` |
|
||||
| Android host fetcher | `android/.../TimeSafariNativeFetcher.java`, `MainActivity.java` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Handoff to plugin repo (Cursor / isolated workspace)
|
||||
|
||||
Use this section when **daily-notification-plugin** is open **without** the TimeSafari app tree, so implementers do not depend on paths that only exist in crowd-funder-for-time-pwa.
|
||||
|
||||
### 6.1 Bring reference material into scope
|
||||
|
||||
| Source (this app repo) | Why |
|
||||
|------------------------|-----|
|
||||
| `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` | **Canonical Endorser behavior** for New Activity: POST body, pagination, aggregation copy, prefs keys for starred IDs and `last_acked_jwt_id`. Copy or open alongside the plugin when implementing iOS fetch or `setNativeFetcher`. |
|
||||
| `src/services/notifications/dualScheduleConfig.ts` | Shape the app sends to `scheduleDualNotification` (`buildDualScheduleConfig`). |
|
||||
| `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` | Android plugin: dual path must call native fetcher at fetch cron. |
|
||||
| `doc/plugin-feedback-ios-scheduleDualNotification.md` | iOS `UNIMPLEMENTED` / PluginHeaders troubleshooting. |
|
||||
|
||||
In the plugin repo itself, align with **`src/definitions.ts`** (`DualScheduleConfiguration`, `configureNativeFetcher`, `updateStarredPlans`) and **INTEGRATION_GUIDE** if present.
|
||||
|
||||
### 6.2 HTTP / storage contract (match `TimeSafariNativeFetcher`)
|
||||
|
||||
Implementations on **iOS** (in-plugin Swift or host `NativeNotificationContentFetcher`) should match this **unless** product explicitly changes:
|
||||
|
||||
- **Method & path:** `POST` `{apiBaseUrl}/api/v2/report/plansLastUpdatedBetween` (no trailing slash mismatch on `apiBaseUrl`).
|
||||
- **Headers:** `Content-Type: application/json`, `Authorization: Bearer {token}` (token from `jwtToken` or **JWT pool** selection—see Java `selectBearerTokenForRequest`: UTC day mod pool size).
|
||||
- **JSON body:** `planIds` (array of strings, possibly empty), `afterId` (string; use `"0"` if none stored).
|
||||
- **Starred plans:** Android: SharedPreferences **`daily_notification_timesafari`** + key **`starredPlanIds`**. iOS (plugin + host): `UserDefaults.standard` key **`daily_notification_timesafari.starredPlanIds`** (JSON array string).
|
||||
- **Pagination:** After a successful response with non-empty `data`, update **`last_acked_jwt_id`** from the last row’s `jwtId` (item or nested `plan.jwtId`)—see Java `updateLastAckedJwtIdFromResponse`. iOS host (`TimeSafariNativeFetcher.swift`) persists **`daily_notification_timesafari.last_acked_jwt_id`** in `UserDefaults.standard`.
|
||||
- **Empty `data`:** Return **no** notification items (empty list); do not synthesize a “no updates” push from an empty result—Java returns empty `contents` when `data` is absent or empty.
|
||||
- **Non-empty `data`:** One aggregated `NotificationContent`: titles **Starred Project Update** / **Starred Project Updates**, bodies use typographic quotes around first project name and **has been updated.** / **+ N more have been updated.** (see Java `parseApiResponse`).
|
||||
|
||||
### 6.3 Likely plugin touchpoints (maintenance / debugging)
|
||||
|
||||
- **iOS:** `ios/Plugin/DailyNotificationPlugin.swift`, `DailyNotificationScheduleHelper.swift`, native fetcher registry, BG / UN paths.
|
||||
- **Android:** `DailyNotificationPlugin.kt`, fetch workers / `ScheduleHelper`—see dual-schedule feedback doc for history.
|
||||
|
||||
### 6.4 Suggested order (plugin shipped ≥ 3.0.0)
|
||||
|
||||
1. Tag / publish **`@timesafari/daily-notification-plugin`**.
|
||||
2. **Consuming app:** bump, `npm install`, `npx cap sync`, `pod install`, QA (`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Acceptance checklist (iOS vs Android product intent)
|
||||
|
||||
- [ ] Prefetch uses **plansLastUpdatedBetween** (or host fetcher with identical behavior), not only `offers` GET.
|
||||
- [ ] **Starred plan IDs** from settings change what is queried (`updateStarredPlans` works on iOS).
|
||||
- [ ] Notification title/body match the **same rules** as Android for “starred project updates” (including empty updates).
|
||||
- [ ] `configureNativeFetcher` + JWT refresh story documented; re-config on foreground if needed (`notification-from-api-call.md`).
|
||||
- [ ] `cancelDualSchedule` clears dual prefetch/notify without leaving orphan schedules.
|
||||
- [ ] Understand and document **iOS timing** limitations vs Android for support/Help copy.
|
||||
- [ ] **Prefetch vs notify ordering** on iOS: chosen strategy (chained arm vs independent BG + UN) documented; avoids claiming fresh API content when prefetch has not run yet (**§3.3**).
|
||||
234
doc/notification-debug-panel.md
Normal file
234
doc/notification-debug-panel.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Notification Debug Panel
|
||||
|
||||
**Created:** 2026-07-07
|
||||
**Updated:** 2026-07-22
|
||||
**Audience:** Developers testing notification registration, refresh, and WAKEUP_PING flows on native (iOS/Android) dev builds.
|
||||
|
||||
The **Notification Debug Panel** is a dev-only UI for exercising the same notification orchestration paths the production app uses: FCM token registration, backend refresh, wakeup handling, and local schedule inspection. It does not duplicate scheduling logic.
|
||||
|
||||
---
|
||||
|
||||
## Notification API base URL
|
||||
|
||||
Notification HTTP calls (`/notifications/register`, `/notifications/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
|
||||
|
||||
| Symbol | Location | Purpose |
|
||||
|--------|----------|---------|
|
||||
| `VITE_DEFAULT_NOTIFY_API_SERVER` | `.env.development` / `.env.test` / `.env.production` | Build-time default Notification API URL for that Vite mode (same pattern as other `VITE_DEFAULT_*` backends) |
|
||||
| `DEFAULT_NOTIFY_API_SERVER` | `src/constants/app.ts` | Runtime constant: `import.meta.env.VITE_DEFAULT_NOTIFY_API_SERVER \|\| AppString.PROD_NOTIFY_API_SERVER` |
|
||||
| `AppString.PROD_NOTIFY_API_SERVER` | `src/constants/app.ts` | Hardcoded production fallback: `https://notify-api.timesafari.app` |
|
||||
| `AppString.TEST_NOTIFY_API_SERVER` | `src/constants/app.ts` | Hardcoded test host: `https://test-notify-api.timesafari.app` (for explicit UI/debug use; not the automatic fallback) |
|
||||
|
||||
Production, test, and development builds get different Notification API URLs from their respective `.env.*` files. Runtime request code always goes through `DEFAULT_NOTIFY_API_SERVER` (via `getNotificationApiBaseUrl()`), not by reading the env var directly at each call site.
|
||||
|
||||
Typical values today:
|
||||
|
||||
| Build / env file | `VITE_DEFAULT_NOTIFY_API_SERVER` |
|
||||
|------------------|----------------------------------|
|
||||
| `.env.production` | `https://notify-api.timesafari.app` |
|
||||
| `.env.test` | `https://test-notify-api.timesafari.app` |
|
||||
| `.env.development` | `https://test-notify-api.timesafari.app` |
|
||||
|
||||
### URL resolution order
|
||||
|
||||
`getNotificationApiBaseUrl()` selects the base URL in this order:
|
||||
|
||||
1. **Debug Panel backend override** — `localStorage` key `notificationDebug.backendBaseUrl` (set via **Save Backend URL** or `setBackendBaseUrl()`)
|
||||
2. **`VITE_DEFAULT_NOTIFY_API_SERVER`** — baked into the build as part of `DEFAULT_NOTIFY_API_SERVER`
|
||||
3. **`AppString.PROD_NOTIFY_API_SERVER`** — hardcoded fallback when the env var is unset (`https://notify-api.timesafari.app`)
|
||||
|
||||
Clearing the Debug Panel override (empty field + Save) returns the app to step 2 / 3 (`DEFAULT_NOTIFY_API_SERVER`). The override never changes auth behavior by itself.
|
||||
|
||||
`APP_SERVER` / `VITE_APP_SERVER` remain for deep links and the main app web host only — not for notification API traffic.
|
||||
|
||||
---
|
||||
|
||||
## Access
|
||||
|
||||
1. Use a **non-production** build (for example `build:android:dev`, `build:ios:dev`, or `vite dev` with a non-`production` mode).
|
||||
2. Open **Account** → enable **Show All General Advanced Functions**.
|
||||
3. Open **Notification Debug Panel** (route `/dev/notifications`).
|
||||
|
||||
On native platforms, grant notification permission when prompted so FCM token registration and the debug actions work.
|
||||
|
||||
---
|
||||
|
||||
## Configuration (Backend Testing)
|
||||
|
||||
Settings persist in `localStorage` via `NotificationDebugConfig.ts`:
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `notificationDebug.backendBaseUrl` | *(unset — use `DEFAULT_NOTIFY_API_SERVER`)* | Override which notification server receives API calls |
|
||||
| `notificationDebug.testMode` | `true` | Sent in JSON request bodies (`testMode: true/false`) |
|
||||
| `notificationDebug.bypassAuth` | `false` | When `true`, omit JWT `Authorization` headers on notification API calls |
|
||||
|
||||
All notification API requests (`/notifications/register`, `/notifications/refresh`, `/debug/send-wakeup`, etc.) obtain headers through `getNotificationApiHeaders()` in `notificationApiAuth.ts`.
|
||||
|
||||
### Notification Backend URL
|
||||
|
||||
Paste a base URL (no trailing slash) and tap **Save Backend URL**. This changes **only** which server the app calls (`getNotificationApiBaseUrl()`). It does **not** disable JWT authentication.
|
||||
|
||||
Leave empty to use the configured build default (`DEFAULT_NOTIFY_API_SERVER`, from `VITE_DEFAULT_NOTIFY_API_SERVER` or `AppString.PROD_NOTIFY_API_SERVER`). The Debug Panel override still wins whenever a non-empty URL is saved.
|
||||
|
||||
### Test Mode
|
||||
|
||||
When enabled (default if never saved), register and refresh requests include `"testMode": true` in the JSON body. The backend can use this to return dev-friendly schedules or route test traffic separately from production.
|
||||
|
||||
Test Mode is **independent of authentication**. It does not control whether `Authorization` headers are sent.
|
||||
|
||||
### Skip JWT Authentication (Local Development Only)
|
||||
|
||||
When **off** (default), the app resolves the active DID and sends `Authorization: Bearer …` on notification API calls.
|
||||
|
||||
When **on**, requests include only `Content-Type: application/json` — for local servers (localhost or ngrok) that intentionally accept unauthenticated notification requests during development.
|
||||
|
||||
Enable this **only** for local development backends that do not require JWT. Hosted shared test servers that require normal app authentication should leave this **off**.
|
||||
|
||||
The panel **Backend Status** section shows the active URL, `testMode`, and `bypassAuth` values.
|
||||
|
||||
---
|
||||
|
||||
## Recommended settings
|
||||
|
||||
### Hosted test server
|
||||
|
||||
Example: `https://test-notify-api.timesafari.app`
|
||||
|
||||
On development and test builds, this host is already the default via `VITE_DEFAULT_NOTIFY_API_SERVER`. You can leave **Notification Backend URL** empty, or paste the same URL explicitly.
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| **Notification Backend URL** | Empty (use default) or `https://test-notify-api.timesafari.app` |
|
||||
| **Test Mode** | **ON** (if the server expects `testMode: true`) |
|
||||
| **Skip JWT Authentication** | **OFF** |
|
||||
|
||||
Ensure the app has an **active identity (DID)** with a valid endorser session so JWT headers can be built.
|
||||
|
||||
### Local localhost / ngrok development
|
||||
|
||||
Example: `https://abc123.ngrok-free.app` or `http://127.0.0.1:3000`
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| **Notification Backend URL** | Your local or ngrok URL |
|
||||
| **Test Mode** | **ON** or **OFF** — match what your local **notification-wakeup-service** expects |
|
||||
| **Skip JWT Authentication** | **ON** only if the local server accepts unauthenticated requests; **OFF** if it validates JWT like production |
|
||||
|
||||
---
|
||||
|
||||
## Backend Testing actions
|
||||
|
||||
| Action | What it does |
|
||||
|--------|----------------|
|
||||
| **Register Token Now** | `POST {backend}/notifications/register` with current FCM token, `deviceId`, `platform`, and `testMode`. Forces re-registration (bypasses duplicate-token skip). |
|
||||
| **Refresh Notifications** | `POST {backend}/notifications/refresh` — same path used after a real WAKEUP_PING. Applies returned schedule to the native plugin. |
|
||||
| **Simulate WAKEUP_PING (Local)** | Calls the refresh API directly (no FCM). Quick test of backend URL + auth + refresh parsing without push delivery. |
|
||||
| **Send Real WAKEUP_PING** | `POST {backend}/debug/send-wakeup`; server sends a real FCM data message with `data.type = "WAKEUP_PING"`. Exercises backend → FCM → Capacitor listener → refresh → reschedule. Background the app before expecting delivery. |
|
||||
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
## Other panel sections
|
||||
|
||||
| Section | Purpose |
|
||||
|---------|---------|
|
||||
| **Mock Timing Presets** | Interval for mock refresh timestamps (30 sec – 10 min). |
|
||||
| **Trigger Mock Refresh** | Applies synthetic future timestamps locally — no backend call. |
|
||||
| **Wakeup Ping Simulator** | Runs the production push handler with a synthetic `WAKEUP_PING` payload (no FCM, no backend). |
|
||||
| **Flood Test** | Runs 20 sequential mock refreshes (stress test). |
|
||||
| **Pending Notification Inspector** | Lists locally scheduled notifications (iOS; Android may show unavailable). |
|
||||
| **Clear Notifications** | Clears/cancels all plugin-scheduled notifications on native. |
|
||||
|
||||
---
|
||||
|
||||
## Programmatic override (optional)
|
||||
|
||||
From a WebView dev console (`chrome://inspect` on Android, Safari Web Inspector on iOS):
|
||||
|
||||
```javascript
|
||||
import {
|
||||
setBackendBaseUrl,
|
||||
setTestMode,
|
||||
setBypassAuth,
|
||||
getNotificationApiBaseUrl,
|
||||
} from "@/services/notifications";
|
||||
|
||||
setBackendBaseUrl("https://abc123.ngrok-free.app");
|
||||
setTestMode(true);
|
||||
setBypassAuth(true); // local dev only
|
||||
getNotificationApiBaseUrl();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 401 Unauthorized (`registerToken failed: unauthorized`)
|
||||
|
||||
**Likely causes:** JWT required but **Skip JWT Authentication** is off and the session is missing or expired; or JWT sent but the server rejected it.
|
||||
|
||||
**Checks:**
|
||||
|
||||
1. Panel **Backend Status** → `bypassAuth: false` for hosted servers.
|
||||
2. App has an active DID and endorser login.
|
||||
3. Event Log: look for `Using authenticated notification request` vs `Using debug unauthenticated notification request`.
|
||||
4. For hosted test server: keep **Skip JWT Authentication** **OFF**.
|
||||
|
||||
**Fixes:** Sign in / restore identity; refresh endorser session; for local ngrok without JWT support, enable **Skip JWT Authentication**.
|
||||
|
||||
### `registerToken auth unavailable` / `Waiting for auth before registration`
|
||||
|
||||
The app deferred registration because JWT could not be built (no active DID or empty token) and **Skip JWT Authentication** is **off**.
|
||||
|
||||
**Fixes:** Complete identity setup in the app, or enable **Skip JWT Authentication** only for an intentionally unauthenticated local backend.
|
||||
|
||||
### Failed to fetch / network error
|
||||
|
||||
**Likely causes:** Backend down, wrong URL, stale ngrok tunnel, device offline, or TLS/certificate issues.
|
||||
|
||||
**Checks:** Panel **Backend Status** URL; `curl -sS "$URL/health"` from your machine; ngrok inspect UI for incoming requests.
|
||||
|
||||
**Fixes:** Restart backend and ngrok; **Save Backend URL** with the current HTTPS forwarding URL (no trailing slash).
|
||||
|
||||
### Backend unreachable / no requests in ngrok
|
||||
|
||||
Same as above. Confirm the **Active** URL in the panel matches your running tunnel or local server port.
|
||||
|
||||
### Register succeeds but Send Real WAKEUP_PING does not trigger refresh
|
||||
|
||||
**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; **Simulate WAKEUP_PING (Local)** works (isolates FCM from refresh API).
|
||||
|
||||
See platform-specific guides for extended ngrok and FCM workflows:
|
||||
|
||||
- [local-android-testing-ngrok.md](./local-android-testing-ngrok.md)
|
||||
- [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md)
|
||||
|
||||
---
|
||||
|
||||
## Key source files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/constants/app.ts` | `DEFAULT_NOTIFY_API_SERVER`, `PROD_NOTIFY_API_SERVER`, `TEST_NOTIFY_API_SERVER` |
|
||||
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
|
||||
| `src/services/notifications/NotificationDebugConfig.ts` | Base URL resolution, testMode, bypassAuth persistence |
|
||||
| `src/services/notifications/notificationApiAuth.ts` | JWT vs unauthenticated headers |
|
||||
| `src/services/notifications/notificationApiDebugMode.ts` | Auth bypass gate |
|
||||
| `src/services/notifications/NotificationDebugService.ts` | Panel action handlers |
|
||||
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
|
||||
| `src/services/notifications/NativeNotificationService.ts` | `POST /notifications/refresh`, WAKEUP_PING handler |
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
- [notification-system-overview.md](./notification-system-overview.md)
|
||||
- [notification-from-api-call.md](./notification-from-api-call.md)
|
||||
- [local-android-testing-ngrok.md](./local-android-testing-ngrok.md)
|
||||
- [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md)
|
||||
@@ -59,6 +59,8 @@ The app must:
|
||||
|
||||
### iOS
|
||||
|
||||
**Parity outline (API, starred plans, plugin vs app work):** See **`doc/new-activity-notifications-ios-android-parity.md`**.
|
||||
|
||||
- **Confirm iOS native fetcher / dual schedule**
|
||||
Plugin exposes `configureNativeFetcher` on iOS. Confirm whether the plugin expects an iOS-specific native fetcher registration (similar to Android’s `setNativeFetcher`) and, if so, register a TimeSafari fetcher implementation for iOS so API-driven notifications work on iPhone.
|
||||
- **Verify dual schedule on iOS**
|
||||
@@ -99,5 +101,8 @@ Add a short “New Activity notifications” section to BUILDING.md or a user-fa
|
||||
| Settings type | `src/interfaces/accountView.ts` |
|
||||
| Android native fetcher | `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` |
|
||||
| Android registration | `android/app/src/main/java/app/timesafari/MainActivity.java` |
|
||||
| iOS native fetcher | `ios/App/App/TimeSafariNativeFetcher.swift` |
|
||||
| iOS registration | `ios/App/App/AppDelegate.swift` (`DailyNotificationPlugin.registerNativeFetcher`) |
|
||||
| Plugin 3.x handoff | `doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md` |
|
||||
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ The steps and expected notification copy below are **Android-specific**: this re
|
||||
|
||||
## 8. Plugin Repo Alignment and Attention Items
|
||||
|
||||
Comparison with the **daily-notification-plugin** repo (e.g. `daily-notification-plugin_test` or gitea `master`) to confirm our documentation and usage line up, and to flag anything that needs attention for the New Activity feature.
|
||||
Comparison with the **daily-notification-plugin** repo on gitea (`trent_larson/daily-notification-plugin`, `master` or the tag this app pins) to confirm our documentation and usage line up, and to flag anything that needs attention for the New Activity feature.
|
||||
|
||||
### 8.1 What lines up
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date:** 2026-02-18
|
||||
**Generated:** 2026-02-18 17:47:06 PST
|
||||
**Target repo:** daily-notification-plugin (local copy at `daily-notification-plugin_test`)
|
||||
**Target repo:** `@timesafari/daily-notification-plugin` (https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin)
|
||||
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||
**Platform:** Android
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://dev-api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://dev-image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://dev-partner-api.endorser.ch
|
||||
VITE_DEFAULT_PUSH_SERVER=https://dev.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_PASSKEYS_ENABLED=true
|
||||
|
||||
# .env.test
|
||||
@@ -141,6 +142,7 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://staging-api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://staging-image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://staging-partner-api.endorser.ch
|
||||
VITE_DEFAULT_PUSH_SERVER=https://staging.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_PASSKEYS_ENABLED=true
|
||||
|
||||
# .env.production
|
||||
@@ -149,6 +151,7 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
|
||||
VITE_DEFAULT_PUSH_SERVER=https://timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
|
||||
VITE_PASSKEYS_ENABLED=true
|
||||
```
|
||||
|
||||
|
||||
@@ -15,9 +15,13 @@
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; };
|
||||
B7E1C4F82A9D3E506F1B2C8D /* TimeSafariNativeFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F8E2D91B4C5E60718293A4 /* TimeSafariNativeFetcher.swift */; };
|
||||
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */; };
|
||||
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */; };
|
||||
C8E73DD12FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */; };
|
||||
C8E73DD22FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */; };
|
||||
E9F1A0022EE05A8B00737D01 /* NotificationInspectorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F1A0012EE05A8B00737D01 /* NotificationInspectorPlugin.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -55,11 +59,15 @@
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
90DCAFB4D8948F7A50C13800 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A3F8E2D91B4C5E60718293A4 /* TimeSafariNativeFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSafariNativeFetcher.swift; sourceTree = "<group>"; };
|
||||
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimeSafariShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C86585E52ED4577F00824752 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImageUtility.swift; sourceTree = "<group>"; };
|
||||
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImagePlugin.swift; sourceTree = "<group>"; };
|
||||
C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||
C8E73DD32FC6ECC30057F59A /* AppDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AppDebug.entitlements; sourceTree = "<group>"; };
|
||||
E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||
E9F1A0012EE05A8B00737D01 /* NotificationInspectorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationInspectorPlugin.swift; sourceTree = "<group>"; };
|
||||
EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -74,18 +82,7 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = TimeSafariShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -138,8 +135,11 @@
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C8E73DD32FC6ECC30057F59A /* AppDebug.entitlements */,
|
||||
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */,
|
||||
E9F1A0012EE05A8B00737D01 /* NotificationInspectorPlugin.swift */,
|
||||
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */,
|
||||
A3F8E2D91B4C5E60718293A4 /* TimeSafariNativeFetcher.swift */,
|
||||
C86585E52ED4577F00824752 /* App.entitlements */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
@@ -149,6 +149,7 @@
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
@@ -174,9 +175,9 @@
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
|
||||
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
|
||||
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
|
||||
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
|
||||
3FE25897CF40A571D4AC2ACE /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -204,8 +205,6 @@
|
||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
|
||||
);
|
||||
name = TimeSafariShareExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = TimeSafariShareExtension;
|
||||
productReference = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
@@ -218,7 +217,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 2610;
|
||||
LastUpgradeCheck = 1630;
|
||||
LastUpgradeCheck = 2660;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
@@ -260,6 +259,7 @@
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
C8E73DD12FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -267,6 +267,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C8E73DD22FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -293,19 +294,19 @@
|
||||
shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" \n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */ = {
|
||||
3FE25897CF40A571D4AC2ACE /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
92977BEA1068CC097A57FC77 /* [CP] Check Pods Manifest.lock */ = {
|
||||
@@ -357,8 +358,10 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */,
|
||||
E9F1A0022EE05A8B00737D01 /* NotificationInspectorPlugin.swift in Sources */,
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */,
|
||||
B7E1C4F82A9D3E506F1B2C8D /* TimeSafariNativeFetcher.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -456,6 +459,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -511,6 +515,7 @@
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
@@ -522,7 +527,8 @@
|
||||
baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
@@ -550,6 +556,7 @@
|
||||
baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1630"
|
||||
LastUpgradeVersion = "2660"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
12
ios/App/App/AppDebug.entitlements
Normal file
12
ios/App/App/AppDebug.entitlements
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.timesafari.share</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,6 +1,7 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import CapacitorCommunitySqlite
|
||||
import TimesafariDailyNotificationPlugin
|
||||
import UserNotifications
|
||||
|
||||
@UIApplicationMain
|
||||
@@ -9,6 +10,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// New Activity / dual schedule: plugin requires a registered native fetcher before configureNativeFetcher (parity with Android setNativeFetcher).
|
||||
DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)
|
||||
|
||||
// Set notification center delegate so notifications show in foreground and rollover is triggered
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
@@ -25,6 +29,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
attempts += 1
|
||||
if registerSharedImagePlugin() {
|
||||
print("[AppDelegate] ✅ Plugin registration successful on attempt \(attempts)")
|
||||
_ = registerNotificationInspectorPlugin()
|
||||
} else if attempts < maxAttempts {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(attempts) * 0.5) {
|
||||
tryRegister()
|
||||
@@ -60,6 +65,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func registerNotificationInspectorPlugin() -> Bool {
|
||||
guard let window = self.window,
|
||||
let bridgeVC = window.rootViewController as? CAPBridgeViewController,
|
||||
let bridge = bridgeVC.bridge else {
|
||||
return false
|
||||
}
|
||||
|
||||
let pluginInstance = NotificationInspectorPlugin()
|
||||
bridge.registerPluginInstance(pluginInstance)
|
||||
print("[AppDelegate] ✅ Registered NotificationInspectorPlugin (exposed as 'NotificationInspector')")
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
@@ -89,13 +108,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
/// Show notifications when app is in foreground and post DailyNotificationDelivered for rollover.
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
let userInfo = notification.request.content.userInfo
|
||||
if let notificationId = userInfo["notification_id"] as? String,
|
||||
let scheduledTime = userInfo["scheduled_time"] as? Int64 {
|
||||
NotificationCenter.default.post(
|
||||
name: NSNotification.Name("DailyNotificationDelivered"),
|
||||
object: nil,
|
||||
userInfo: ["notification_id": notificationId, "scheduled_time": scheduledTime]
|
||||
)
|
||||
if let notificationId = userInfo["notification_id"] as? String {
|
||||
let scheduledTime: Int64? = {
|
||||
if let v = userInfo["scheduled_time"] as? Int64 { return v }
|
||||
if let n = userInfo["scheduled_time"] as? NSNumber { return n.int64Value }
|
||||
if let i = userInfo["scheduled_time"] as? Int { return Int64(i) }
|
||||
return nil
|
||||
}()
|
||||
if let scheduledTime = scheduledTime {
|
||||
NotificationCenter.default.post(
|
||||
name: NSNotification.Name("DailyNotificationDelivered"),
|
||||
object: nil,
|
||||
userInfo: ["notification_id": notificationId, "scheduled_time": scheduledTime]
|
||||
)
|
||||
}
|
||||
}
|
||||
if #available(iOS 14.0, *) {
|
||||
completionHandler([.banner, .sound, .badge])
|
||||
|
||||
30
ios/App/App/GoogleService-Info.plist
Normal file
30
ios/App/App/GoogleService-Info.plist
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyDhiy46kW7TH4VvUxzl2pOTLEK7mT14mIo</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>1094643115061</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>app.timesafari</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>pc-api-7249509642322112640-286</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>pc-api-7249509642322112640-286.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:1094643115061:ios:587b9422d019375e887d7c</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -2,6 +2,13 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>org.timesafari.dailynotification.fetch</string>
|
||||
<string>org.timesafari.dailynotification.notify</string>
|
||||
<string>org.timesafari.dailynotification.content-fetch</string>
|
||||
<string>org.timesafari.dailynotification.notification-delivery</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -18,6 +25,17 @@
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>app.timesafari</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>timesafari</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
@@ -26,6 +44,14 @@
|
||||
<string>Time Safari allows you to take photos, and also scan QR codes from contacts.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Time Safari allows you to upload photos.</string>
|
||||
<key>NSUserNotificationAlertStyle</key>
|
||||
<string>alert</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -47,30 +73,5 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>app.timesafari</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>timesafari</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>org.timesafari.dailynotification.fetch</string>
|
||||
<string>org.timesafari.dailynotification.notify</string>
|
||||
<string>org.timesafari.dailynotification.content-fetch</string>
|
||||
<string>org.timesafari.dailynotification.notification-delivery</string>
|
||||
</array>
|
||||
<key>NSUserNotificationAlertStyle</key>
|
||||
<string>alert</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
88
ios/App/App/NotificationInspectorPlugin.swift
Normal file
88
ios/App/App/NotificationInspectorPlugin.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import UserNotifications
|
||||
|
||||
// DEV-only diagnostic plugin.
|
||||
// Kept separate from DailyNotificationPlugin intentionally
|
||||
// to avoid altering production notification scheduling behavior.
|
||||
|
||||
@objc(NotificationInspector)
|
||||
public class NotificationInspectorPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public var identifier: String { "NotificationInspector" }
|
||||
public var jsName: String { "NotificationInspector" }
|
||||
public var pluginMethods: [CAPPluginMethod] {
|
||||
[
|
||||
CAPPluginMethod(#selector(getPendingNotifications(_:)), returnType: .promise)
|
||||
]
|
||||
}
|
||||
|
||||
/// Stable wall-clock target: plugin `userInfo["scheduled_time"]`, or epoch ms in API notification identifiers.
|
||||
/// (Apple documents `UNTimeIntervalNotificationTrigger.nextTriggerDate()` as resampling ~now+interval when queried.)
|
||||
/// API notification identifiers use the `api_` prefix.
|
||||
private static let apiNotificationIdentifierPrefix = "api_"
|
||||
|
||||
private func wallClockMillis(from request: UNNotificationRequest) -> (ms: Int64, source: String)? {
|
||||
let info = request.content.userInfo
|
||||
if let v = info["scheduled_time"] as? Int64 {
|
||||
return (v, "userInfo.scheduled_time")
|
||||
}
|
||||
if let n = info["scheduled_time"] as? NSNumber {
|
||||
return (n.int64Value, "userInfo.scheduled_time")
|
||||
}
|
||||
if let i = info["scheduled_time"] as? Int {
|
||||
return (Int64(i), "userInfo.scheduled_time")
|
||||
}
|
||||
let prefix = Self.apiNotificationIdentifierPrefix
|
||||
if request.identifier.hasPrefix(prefix) {
|
||||
let suffix = String(request.identifier.dropFirst(prefix.count))
|
||||
if let ms = Int64(suffix) {
|
||||
return (ms, "identifier (API notification)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc public func getPendingNotifications(_ call: CAPPluginCall) {
|
||||
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
|
||||
let pending: [[String: Any]] = requests.map { req in
|
||||
var nextTriggerMs: NSNumber? = nil
|
||||
var triggerType: String? = nil
|
||||
|
||||
if let trigger = req.trigger as? UNCalendarNotificationTrigger {
|
||||
triggerType = "calendar"
|
||||
if let next = trigger.nextTriggerDate() {
|
||||
nextTriggerMs = NSNumber(value: Int64(next.timeIntervalSince1970 * 1000))
|
||||
}
|
||||
} else if let trigger = req.trigger as? UNTimeIntervalNotificationTrigger {
|
||||
triggerType = "timeInterval"
|
||||
if let next = trigger.nextTriggerDate() {
|
||||
nextTriggerMs = NSNumber(value: Int64(next.timeIntervalSince1970 * 1000))
|
||||
}
|
||||
} else if req.trigger != nil {
|
||||
triggerType = "other"
|
||||
} else {
|
||||
triggerType = nil
|
||||
}
|
||||
|
||||
var obj: [String: Any] = [
|
||||
"identifier": req.identifier
|
||||
]
|
||||
obj["nextTriggerDate"] = nextTriggerMs ?? NSNull()
|
||||
obj["triggerType"] = triggerType ?? NSNull()
|
||||
if let wall = self.wallClockMillis(from: req) {
|
||||
obj["wallClockMillis"] = NSNumber(value: wall.ms)
|
||||
obj["wallClockSource"] = wall.source
|
||||
} else {
|
||||
obj["wallClockMillis"] = NSNull()
|
||||
obj["wallClockSource"] = NSNull()
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
call.resolve([
|
||||
"pending": pending
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
215
ios/App/App/TimeSafariNativeFetcher.swift
Normal file
215
ios/App/App/TimeSafariNativeFetcher.swift
Normal file
@@ -0,0 +1,215 @@
|
||||
import Foundation
|
||||
import TimesafariDailyNotificationPlugin
|
||||
|
||||
/// Native content fetcher for API-driven New Activity notifications on iOS.
|
||||
/// Mirrors `TimeSafariNativeFetcher.java` (POST `plansLastUpdatedBetween`, starred plans, JWT pool, pagination).
|
||||
final class TimeSafariNativeFetcher: NativeNotificationContentFetcher {
|
||||
static let shared = TimeSafariNativeFetcher()
|
||||
|
||||
private let endpoint = "/api/v2/report/plansLastUpdatedBetween"
|
||||
private let readTimeoutSec: TimeInterval = 15
|
||||
private let maxRetries = 3
|
||||
private let retryDelayMs = 1_000
|
||||
|
||||
/// Matches plugin `updateStarredPlans` storage (`DailyNotificationPlugin.swift`).
|
||||
private let prefsStarredKey = "daily_notification_timesafari.starredPlanIds"
|
||||
/// Matches Java `TimeSafariNativeFetcher` prefs namespace `daily_notification_timesafari` + `last_acked_jwt_id`.
|
||||
private let prefsLastAckedKey = "daily_notification_timesafari.last_acked_jwt_id"
|
||||
|
||||
private var apiBaseUrl: String?
|
||||
private var activeDid: String?
|
||||
private var jwtToken: String?
|
||||
private var jwtTokenPool: [String]?
|
||||
|
||||
private init() {}
|
||||
|
||||
func configure(apiBaseUrl: String, activeDid: String, jwtToken: String, jwtTokenPool: [String]?) {
|
||||
self.apiBaseUrl = apiBaseUrl.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(
|
||||
of: "/$",
|
||||
with: "",
|
||||
options: .regularExpression
|
||||
)
|
||||
self.activeDid = activeDid
|
||||
self.jwtToken = jwtToken
|
||||
self.jwtTokenPool = (jwtTokenPool?.isEmpty == false) ? jwtTokenPool : nil
|
||||
}
|
||||
|
||||
func fetchContent(context: FetchContext) async throws -> [NotificationContent] {
|
||||
try await fetchContentWithRetry(context: context, retryCount: 0)
|
||||
}
|
||||
|
||||
/// One pool entry per UTC day (epoch day mod pool size); else primary `jwtToken` — same as Java.
|
||||
private func selectBearerTokenForRequest() -> String? {
|
||||
guard let pool = jwtTokenPool, !pool.isEmpty else { return jwtToken }
|
||||
let epochDay = Int64(Date().timeIntervalSince1970 * 1000) / (24 * 60 * 60 * 1000)
|
||||
let idx = Int(epochDay) % pool.count
|
||||
let t = pool[idx]
|
||||
if t.isEmpty { return jwtToken }
|
||||
return t
|
||||
}
|
||||
|
||||
private func fetchContentWithRetry(context: FetchContext, retryCount: Int) async throws -> [NotificationContent] {
|
||||
guard let base = apiBaseUrl, !base.isEmpty,
|
||||
activeDid != nil,
|
||||
let bearer = selectBearerTokenForRequest(), !bearer.isEmpty
|
||||
else {
|
||||
NSLog("[TimeSafariNativeFetcher] Not configured; call configureNativeFetcher from JS first.")
|
||||
return []
|
||||
}
|
||||
|
||||
guard let url = URL(string: base + endpoint) else {
|
||||
return []
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization")
|
||||
request.timeoutInterval = readTimeoutSec
|
||||
|
||||
let planIds = getStarredPlanIds()
|
||||
var afterId = getLastAcknowledgedJwtId() ?? "0"
|
||||
if afterId.isEmpty { afterId = "0" }
|
||||
|
||||
let body: [String: Any] = [
|
||||
"planIds": planIds,
|
||||
"afterId": afterId,
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
NSLog(
|
||||
"[TimeSafariNativeFetcher] POST \(endpoint) planCount=\(planIds.count) afterId=\(afterId.prefix(12))…"
|
||||
)
|
||||
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = readTimeoutSec
|
||||
config.timeoutIntervalForResource = readTimeoutSec
|
||||
let session = URLSession(configuration: config)
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
return []
|
||||
}
|
||||
|
||||
if http.statusCode == 200 {
|
||||
let bodyStr = String(data: data, encoding: .utf8) ?? ""
|
||||
let contents = parseApiResponse(responseBody: bodyStr, context: context)
|
||||
if !contents.isEmpty {
|
||||
updateLastAckedJwtIdFromResponse(responseBody: bodyStr)
|
||||
}
|
||||
return contents
|
||||
}
|
||||
|
||||
if retryCount < maxRetries && (http.statusCode >= 500 || http.statusCode == 429) {
|
||||
let delayMs = retryDelayMs * (1 << retryCount)
|
||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
return try await fetchContentWithRetry(context: context, retryCount: retryCount + 1)
|
||||
}
|
||||
|
||||
NSLog("[TimeSafariNativeFetcher] API error \(http.statusCode)")
|
||||
return []
|
||||
} catch {
|
||||
NSLog("[TimeSafariNativeFetcher] Fetch failed: \(error.localizedDescription)")
|
||||
if retryCount < maxRetries {
|
||||
let delayMs = retryDelayMs * (1 << retryCount)
|
||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
return try await fetchContentWithRetry(context: context, retryCount: retryCount + 1)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func getStarredPlanIds() -> [String] {
|
||||
guard let jsonStr = UserDefaults.standard.string(forKey: prefsStarredKey),
|
||||
!jsonStr.isEmpty, jsonStr != "[]",
|
||||
let data = jsonStr.data(using: .utf8),
|
||||
let arr = try? JSONSerialization.jsonObject(with: data) as? [Any]
|
||||
else {
|
||||
return []
|
||||
}
|
||||
return arr.compactMap { $0 as? String }
|
||||
}
|
||||
|
||||
private func getLastAcknowledgedJwtId() -> String? {
|
||||
let s = UserDefaults.standard.string(forKey: prefsLastAckedKey)
|
||||
return (s?.isEmpty == false) ? s : nil
|
||||
}
|
||||
|
||||
private func updateLastAckedJwtIdFromResponse(responseBody: String) {
|
||||
guard let data = responseBody.data(using: .utf8),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let dataArray = root["data"] as? [[String: Any]], !dataArray.isEmpty
|
||||
else { return }
|
||||
|
||||
let lastItem = dataArray[dataArray.count - 1]
|
||||
var jwtId: String?
|
||||
if let j = lastItem["jwtId"] as? String {
|
||||
jwtId = j
|
||||
} else if let plan = lastItem["plan"] as? [String: Any], let j = plan["jwtId"] as? String {
|
||||
jwtId = j
|
||||
}
|
||||
if let jwtId = jwtId, !jwtId.isEmpty {
|
||||
UserDefaults.standard.set(jwtId, forKey: prefsLastAckedKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractProjectDisplayTitle(_ item: [String: Any]) -> String {
|
||||
if let plan = item["plan"] as? [String: Any],
|
||||
let name = plan["name"] as? String,
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return "Unnamed Project"
|
||||
}
|
||||
|
||||
private func extractJwtIdFromItem(_ item: [String: Any]) -> String? {
|
||||
if let plan = item["plan"] as? [String: Any], let j = plan["jwtId"] as? String, !j.isEmpty {
|
||||
return j
|
||||
}
|
||||
if let j = item["jwtId"] as? String, !j.isEmpty { return j }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseApiResponse(responseBody: String, context: FetchContext) -> [NotificationContent] {
|
||||
guard let data = responseBody.data(using: .utf8),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let dataArray = root["data"] as? [[String: Any]], !dataArray.isEmpty
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
let firstItem = dataArray[0]
|
||||
let firstTitle = extractProjectDisplayTitle(firstItem)
|
||||
let jwtId = extractJwtIdFromItem(firstItem)
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let scheduledMs: Int64 = context.scheduledTimeMillis ?? (nowMs + 3_600_000)
|
||||
|
||||
let n = dataArray.count
|
||||
let quotedFirst = "\u{201C}\(firstTitle)\u{201D}"
|
||||
let title: String
|
||||
let body: String
|
||||
if n == 1 {
|
||||
title = "Starred Project Update"
|
||||
body = "\(quotedFirst) has been updated."
|
||||
} else {
|
||||
title = "Starred Project Updates"
|
||||
let more = n - 1
|
||||
body = "\(quotedFirst) + \(more) more have been updated."
|
||||
}
|
||||
|
||||
let id = "endorser_\(jwtId ?? "batch_\(nowMs)")"
|
||||
return [
|
||||
NotificationContent(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
scheduledTime: scheduledMs,
|
||||
fetchedAt: nowMs,
|
||||
url: apiBaseUrl,
|
||||
payload: nil,
|
||||
etag: nil
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
|
||||
|
||||
platform :ios, '13.0'
|
||||
use_frameworks!
|
||||
# Static linkage helps isolate SQLCipher from Apple's system SQLite module/headers.
|
||||
use_frameworks! :linkage => :static
|
||||
|
||||
# workaround to avoid Xcode caching of Pods that requires
|
||||
# Product -> Clean Build Folder after new Cordova plugins installed
|
||||
@@ -17,6 +18,8 @@ def capacitor_pods
|
||||
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
|
||||
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
||||
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
|
||||
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
|
||||
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
|
||||
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
|
||||
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
|
||||
pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker'
|
||||
@@ -28,11 +31,92 @@ target 'App' do
|
||||
# Add your Pods here
|
||||
end
|
||||
|
||||
def merge_sqlite_omit_load_extension_definition(config)
|
||||
defs = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS']
|
||||
if defs.nil?
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = ['$(inherited)', 'SQLITE_OMIT_LOAD_EXTENSION']
|
||||
elsif defs.is_a?(Array)
|
||||
unless defs.any? { |d| d.to_s.include?('SQLITE_OMIT_LOAD_EXTENSION') }
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = defs + ['SQLITE_OMIT_LOAD_EXTENSION']
|
||||
end
|
||||
else
|
||||
s = defs.to_s
|
||||
unless s.include?('SQLITE_OMIT_LOAD_EXTENSION')
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = "#{s} SQLITE_OMIT_LOAD_EXTENSION".squeeze(' ').strip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def strip_system_sqlite_from_pod_config(config)
|
||||
bad_header = lambda do |path|
|
||||
p = path.to_s
|
||||
p.include?('/usr/include') || p.include?('/usr/local/include')
|
||||
end
|
||||
%w[HEADER_SEARCH_PATHS USER_HEADER_SEARCH_PATHS].each do |key|
|
||||
paths = config.build_settings[key]
|
||||
next unless paths
|
||||
if paths.is_a?(Array)
|
||||
config.build_settings[key] = paths.reject(&bad_header)
|
||||
else
|
||||
kept = paths.to_s.split(/\s+/).reject(&bad_header)
|
||||
config.build_settings[key] = kept.join(' ')
|
||||
end
|
||||
end
|
||||
%w[OTHER_LDFLAGS OTHER_LIBTOOLFLAGS].each do |key|
|
||||
val = config.build_settings[key]
|
||||
next unless val
|
||||
if val.is_a?(Array)
|
||||
config.build_settings[key] = val.reject do |x|
|
||||
s = x.to_s
|
||||
s.match?(/libsqlite3\.tbd/) || s == '-l"sqlite3"' || s.match?(/-l\s*sqlite3\b/)
|
||||
end
|
||||
else
|
||||
s = val.to_s.gsub(/\s*-l"sqlite3"\s+/, ' ')
|
||||
.gsub(/\s*-l\s*sqlite3\b/, ' ')
|
||||
.gsub(/[^\s]*libsqlite3\.tbd[^\s]*/, ' ')
|
||||
config.build_settings[key] = s.squeeze(' ').strip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
assertDeploymentTarget(installer)
|
||||
installer.pods_project.targets.each do |target|
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64'
|
||||
config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
|
||||
merge_sqlite_omit_load_extension_definition(config)
|
||||
strip_system_sqlite_from_pod_config(config)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Aggregate Pods-App xcconfigs merge -l"sqlite3" from dependencies; that pulls in Apple's
|
||||
# libsqlite3 alongside SQLCipher. Strip it after CocoaPods writes the files (post_install is too early).
|
||||
# Also strip SQLCipher header-guard macros leaked into GCC_PREPROCESSOR_DEFINITIONS: Swift explicit
|
||||
# modules build the SDK SQLite3.modulemap PCM with the same -D flags; _SQLITE3_H_=1 empties sqlite3.h
|
||||
# and breaks sqlite3ext.h (unknown sqlite3_* types).
|
||||
def strip_aggregate_pods_app_xcconfig(contents)
|
||||
# Unlink system libsqlite3 (SQLCipher is the only SQLite).
|
||||
patched = contents.gsub(/\s+-l"sqlite3"\s+/, ' ')
|
||||
.gsub(/\s+-lsqlite3\b/, ' ')
|
||||
# SQLCipher leaks sqlite3*.h guard macros into GCC_PREPROCESSOR_DEFINITIONS; Swift explicit
|
||||
# modules must not inherit them when building the SDK SQLite3 module.
|
||||
%w[_SQLITE3_H_=1 _FTS5_H=1 _SQLITE3RTREE_H_=1].each do |macro|
|
||||
escaped = Regexp.escape(macro)
|
||||
patched.gsub!(/(?:^|\s)-D#{escaped}(?=\s|$)/, ' ')
|
||||
patched.gsub!(/(?:^|\s)#{escaped}(?=\s|$)/, ' ')
|
||||
end
|
||||
patched.gsub(/[ \t]+/, ' ')
|
||||
end
|
||||
|
||||
post_integrate do |installer|
|
||||
support = File.join(installer.sandbox.root, 'Target Support Files', 'Pods-App')
|
||||
%w[Pods-App.debug.xcconfig Pods-App.release.xcconfig].each do |name|
|
||||
path = File.join(support, name)
|
||||
next unless File.exist?(path)
|
||||
contents = File.read(path)
|
||||
patched = strip_aggregate_pods_app_xcconfig(contents)
|
||||
File.write(path, patched) if patched != contents
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,6 +17,10 @@ PODS:
|
||||
- CapacitorMlkitBarcodeScanning (6.2.0):
|
||||
- Capacitor
|
||||
- GoogleMLKit/BarcodeScanning (= 5.0.0)
|
||||
- CapacitorPreferences (6.0.4):
|
||||
- Capacitor
|
||||
- CapacitorPushNotifications (6.0.5):
|
||||
- Capacitor
|
||||
- CapacitorShare (6.0.3):
|
||||
- Capacitor
|
||||
- CapacitorStatusBar (6.0.2):
|
||||
@@ -81,14 +85,14 @@ PODS:
|
||||
- nanopb/decode (2.30910.0)
|
||||
- nanopb/encode (2.30910.0)
|
||||
- PromisesObjC (2.4.0)
|
||||
- SQLCipher (4.9.0):
|
||||
- SQLCipher/standard (= 4.9.0)
|
||||
- SQLCipher/common (4.9.0)
|
||||
- SQLCipher/standard (4.9.0):
|
||||
- SQLCipher (4.10.0):
|
||||
- SQLCipher/standard (= 4.10.0)
|
||||
- SQLCipher/common (4.10.0)
|
||||
- SQLCipher/standard (4.10.0):
|
||||
- SQLCipher/common
|
||||
- TimesafariDailyNotificationPlugin (2.1.1):
|
||||
- TimesafariDailyNotificationPlugin (4.0.1):
|
||||
- Capacitor
|
||||
- ZIPFoundation (0.9.19)
|
||||
- ZIPFoundation (0.9.20)
|
||||
|
||||
DEPENDENCIES:
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
@@ -99,6 +103,8 @@ DEPENDENCIES:
|
||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
|
||||
- "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)"
|
||||
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
|
||||
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
|
||||
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
|
||||
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
|
||||
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)"
|
||||
@@ -138,6 +144,10 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@capacitor/filesystem"
|
||||
CapacitorMlkitBarcodeScanning:
|
||||
:path: "../../node_modules/@capacitor-mlkit/barcode-scanning"
|
||||
CapacitorPreferences:
|
||||
:path: "../../node_modules/@capacitor/preferences"
|
||||
CapacitorPushNotifications:
|
||||
:path: "../../node_modules/@capacitor/push-notifications"
|
||||
CapacitorShare:
|
||||
:path: "../../node_modules/@capacitor/share"
|
||||
CapacitorStatusBar:
|
||||
@@ -156,6 +166,8 @@ SPEC CHECKSUMS:
|
||||
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
|
||||
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74
|
||||
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e
|
||||
CapacitorPreferences: 5848e0691b36b4bb4acc98e481ab56d451578d30
|
||||
CapacitorPushNotifications: 35abece14371c57172e8321c9ccc8b6fa35fabfe
|
||||
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e
|
||||
CapacitorStatusBar: b16799a26320ffa52f6c8b01737d5a95bbb8f3eb
|
||||
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd
|
||||
@@ -171,10 +183,10 @@ SPEC CHECKSUMS:
|
||||
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79
|
||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
|
||||
TimesafariDailyNotificationPlugin: ab9860e6ab9db8019f64f3c08f115a0c4ffd32d9
|
||||
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
|
||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||
TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: 6d92bfa46c6c2d31d19b8c0c38f56a8ae9fd222f
|
||||
PODFILE CHECKSUM: 3a6079307b3952d27d8dbfc0ce9abb523ecce7f0
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
3262
package-lock.json
generated
3262
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -138,7 +138,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.1",
|
||||
"@capacitor-community/sqlite": "6.0.2",
|
||||
"@capacitor-community/sqlite": "^6.0.2",
|
||||
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
|
||||
"@capacitor/android": "^6.2.0",
|
||||
"@capacitor/app": "^6.0.0",
|
||||
@@ -148,6 +148,8 @@
|
||||
"@capacitor/core": "^6.2.0",
|
||||
"@capacitor/filesystem": "^6.0.0",
|
||||
"@capacitor/ios": "^6.2.0",
|
||||
"@capacitor/preferences": "^6.0.4",
|
||||
"@capacitor/push-notifications": "^6.0.5",
|
||||
"@capacitor/share": "^6.0.3",
|
||||
"@capacitor/status-bar": "^6.0.2",
|
||||
"@capawesome/capacitor-file-picker": "^6.2.0",
|
||||
@@ -194,6 +196,7 @@
|
||||
"electron-builder": "^26.0.12",
|
||||
"ethereum-cryptography": "^2.1.3",
|
||||
"ethereumjs-util": "^7.1.5",
|
||||
"firebase": "^12.12.1",
|
||||
"jdenticon": "^3.2.0",
|
||||
"js-generate-password": "^0.1.9",
|
||||
"js-yaml": "^4.1.0",
|
||||
|
||||
@@ -222,7 +222,8 @@ build_ios_app() {
|
||||
|
||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||
build_config="Debug"
|
||||
destination="platform=iOS Simulator,name=iPhone 15 Pro"
|
||||
# Any Simulator — avoids hardcoding a device name (e.g. iPhone 15 Pro) that may not exist in newer Xcode runtimes
|
||||
destination="generic/platform=iOS Simulator"
|
||||
else
|
||||
build_config="Release"
|
||||
destination="platform=iOS,id=auto"
|
||||
@@ -232,15 +233,21 @@ build_ios_app() {
|
||||
|
||||
cd ios/App
|
||||
|
||||
# Build the app
|
||||
xcodebuild -workspace App.xcworkspace \
|
||||
# Build the app:
|
||||
# -quiet: skip the huge export VAR dump (compiler warnings still show unless suppressed below).
|
||||
# SWIFT_SUPPRESS_WARNINGS / GCC_WARN_INHIBIT_ALL_WARNINGS: quiet CLI output from Pods + plugins;
|
||||
# build in Xcode for full diagnostics. Real errors still fail the build.
|
||||
xcodebuild -quiet \
|
||||
-workspace App.xcworkspace \
|
||||
-scheme "$scheme" \
|
||||
-configuration "$build_config" \
|
||||
-destination "$destination" \
|
||||
build \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
SWIFT_SUPPRESS_WARNINGS=YES \
|
||||
GCC_WARN_INHIBIT_ALL_WARNINGS=YES
|
||||
|
||||
cd ../..
|
||||
|
||||
@@ -564,16 +571,19 @@ safe_execute "Building iOS app" "build_ios_app" || exit 5
|
||||
if [ "$BUILD_IPA" = true ]; then
|
||||
log_info "Building IPA package..."
|
||||
cd ios/App
|
||||
xcodebuild -workspace App.xcworkspace \
|
||||
xcodebuild -quiet \
|
||||
-workspace App.xcworkspace \
|
||||
-scheme App \
|
||||
-configuration Release \
|
||||
-archivePath build/App.xcarchive \
|
||||
archive \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
SWIFT_SUPPRESS_WARNINGS=YES \
|
||||
GCC_WARN_INHIBIT_ALL_WARNINGS=YES
|
||||
|
||||
xcodebuild -exportArchive \
|
||||
xcodebuild -quiet -exportArchive \
|
||||
-archivePath build/App.xcarchive \
|
||||
-exportPath build/ \
|
||||
-exportOptionsPlist exportOptions.plist
|
||||
|
||||
588
src/components/dev/NotificationDebugPanel.vue
Normal file
588
src/components/dev/NotificationDebugPanel.vue
Normal file
@@ -0,0 +1,588 @@
|
||||
<template>
|
||||
<section class="bg-slate-100 rounded-md overflow-hidden px-4 py-4">
|
||||
<!-- Backend testing -->
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 font-bold">Backend Testing</h2>
|
||||
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||
Notification Backend URL
|
||||
</label>
|
||||
<input
|
||||
v-model="backendUrlDraft"
|
||||
type="url"
|
||||
class="w-full text-sm px-3 py-2 rounded border border-slate-300 bg-white mb-1"
|
||||
placeholder="Leave empty for default (DEFAULT_NOTIFY_API_SERVER)"
|
||||
:disabled="busy"
|
||||
@keydown.enter="onSaveBackendUrl"
|
||||
/>
|
||||
<p class="text-xs text-slate-500 mb-2">
|
||||
Active:
|
||||
<code class="text-[11px] break-all">{{ activeBackendUrl }}</code>
|
||||
</p>
|
||||
<button
|
||||
class="w-full text-sm mb-4 px-3 py-2 rounded border border-slate-300 bg-white"
|
||||
:disabled="busy"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': busy }"
|
||||
@click="onSaveBackendUrl"
|
||||
>
|
||||
Save Backend URL
|
||||
</button>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm mb-4 cursor-pointer">
|
||||
<input
|
||||
v-model="testModeEnabled"
|
||||
type="checkbox"
|
||||
class="rounded border-slate-300"
|
||||
:disabled="busy"
|
||||
@change="onTestModeChange"
|
||||
/>
|
||||
<span>Test Mode</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm mb-1 cursor-pointer">
|
||||
<input
|
||||
v-model="bypassAuthEnabled"
|
||||
type="checkbox"
|
||||
class="rounded border-slate-300"
|
||||
:disabled="busy"
|
||||
@change="onBypassAuthChange"
|
||||
/>
|
||||
<span>Skip JWT Authentication (Local Development Only)</span>
|
||||
</label>
|
||||
<p class="text-xs text-slate-500 mb-4">
|
||||
Enable only when using a local development server (for example localhost
|
||||
or ngrok) that intentionally accepts unauthenticated notification
|
||||
requests.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2 mb-4">
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-emerald-400 to-emerald-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="onRegisterToken"
|
||||
>
|
||||
Register Token Now
|
||||
</button>
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-cyan-500 to-cyan-800 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="onUploadAlertAuthorization"
|
||||
>
|
||||
Upload AlertSearch Authorization
|
||||
</button>
|
||||
<p
|
||||
v-if="alertAuthorizationStatus"
|
||||
class="text-xs rounded px-3 py-2 border"
|
||||
:class="
|
||||
alertAuthorizationStatus.ok
|
||||
? 'text-emerald-900 bg-emerald-50 border-emerald-200'
|
||||
: 'text-rose-900 bg-rose-50 border-rose-200'
|
||||
"
|
||||
role="status"
|
||||
>
|
||||
{{ alertAuthorizationStatus.message }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-slate-500">
|
||||
Manually mints and uploads 100 delegated day JWTs. Requires an active
|
||||
did:ethr identity and JWT authentication; Test Mode is not used.
|
||||
</p>
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
:disabled="busy"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': busy }"
|
||||
@click="onBackendRefresh"
|
||||
>
|
||||
Refresh Notifications
|
||||
</button>
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-violet-400 to-violet-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
:disabled="busy"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': busy }"
|
||||
@click="onSimulateWakeupRefresh"
|
||||
>
|
||||
Simulate WAKEUP_PING (Local)
|
||||
</button>
|
||||
<p class="text-xs text-slate-500">
|
||||
Local simulation only — calls the refresh API directly (no FCM push).
|
||||
</p>
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-amber-400 to-amber-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
:disabled="busy"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': busy }"
|
||||
@click="onSendRealWakeupPing"
|
||||
>
|
||||
Send Real WAKEUP_PING
|
||||
</button>
|
||||
<p
|
||||
v-if="realWakeupStatus"
|
||||
class="text-xs rounded px-3 py-2 border"
|
||||
:class="
|
||||
realWakeupStatus.ok
|
||||
? 'text-emerald-900 bg-emerald-50 border-emerald-200'
|
||||
: 'text-rose-900 bg-rose-50 border-rose-200'
|
||||
"
|
||||
role="status"
|
||||
>
|
||||
{{ realWakeupStatus.message }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-slate-500">
|
||||
Full pipeline — backend `/debug/send-wakeup` → FCM → WAKEUP_PING
|
||||
handler.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="text-sm font-bold mb-1">Current FCM Token</h3>
|
||||
<div
|
||||
v-if="fcmToken"
|
||||
class="bg-white rounded border border-slate-200 px-3 py-2 text-xs font-mono break-all flex gap-2 items-start"
|
||||
>
|
||||
<span class="min-w-0 flex-1">{{ truncatedFcmToken }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-sm px-2 py-1 rounded border border-slate-300 bg-slate-50"
|
||||
@click="onCopyFcmToken"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="text-sm text-slate-500 bg-white rounded px-3 py-2 border border-slate-200"
|
||||
>
|
||||
(not available — try Register Token Now on native)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<h3 class="text-sm font-bold mb-1">Backend Status</h3>
|
||||
<dl
|
||||
class="bg-white rounded border border-slate-200 px-3 py-2 text-xs space-y-1"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-slate-500 shrink-0">URL</dt>
|
||||
<dd class="break-all font-mono">{{ activeBackendUrl }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-slate-500 shrink-0">testMode</dt>
|
||||
<dd>{{ testModeEnabled ? "true" : "false" }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-slate-500 shrink-0">bypassAuth</dt>
|
||||
<dd>{{ bypassAuthEnabled ? "true" : "false" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION F: Mock Timing Presets -->
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 font-bold">Mock Timing Presets</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.ms"
|
||||
class="px-3 py-2 rounded border border-slate-300 bg-white text-sm"
|
||||
:class="{
|
||||
'border-blue-500 ring-1 ring-blue-300': intervalMs === preset.ms,
|
||||
}"
|
||||
@click="intervalMs = preset.ms"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-2">
|
||||
Selected interval: <b>{{ intervalLabel }}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION A: Mock Refresh Controls -->
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 font-bold">Mock Refresh Controls</h2>
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
:disabled="busy"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': busy }"
|
||||
@click="onMockRefresh"
|
||||
>
|
||||
Trigger Mock Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- SECTION B: Wakeup Ping Simulator -->
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 font-bold">Wakeup Ping Simulator</h2>
|
||||
<p class="text-xs text-slate-500 mb-2">
|
||||
Exercises the production push handler (not the refresh API shortcut
|
||||
above).
|
||||
</p>
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
:disabled="busy"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': busy }"
|
||||
@click="onWakeupPing"
|
||||
>
|
||||
Simulate WAKEUP_PING
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- SECTION C: Flood Test -->
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 font-bold">Flood Test</h2>
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-rose-400 to-rose-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
:disabled="busy"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': busy }"
|
||||
@click="onFloodTest"
|
||||
>
|
||||
Run 20 Refreshes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- SECTION D: Pending Notification Inspector -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h2 class="font-bold">Pending Notification Inspector</h2>
|
||||
<button
|
||||
class="ms-auto text-sm px-3 py-2 rounded border border-slate-300 bg-white"
|
||||
:disabled="busy"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': busy }"
|
||||
@click="refreshPending"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="pendingInspectorMessage"
|
||||
class="text-sm text-amber-900 bg-amber-50 rounded px-3 py-2 border border-amber-200"
|
||||
role="status"
|
||||
>
|
||||
{{ pendingInspectorMessage }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="pending.length === 0"
|
||||
class="text-sm text-slate-500 bg-white rounded px-3 py-2 border border-slate-200"
|
||||
>
|
||||
(none)
|
||||
</div>
|
||||
<ul v-else class="bg-white rounded border border-slate-200 divide-y">
|
||||
<li
|
||||
v-for="p in pending"
|
||||
:key="p.identifier"
|
||||
class="px-3 py-2 text-sm flex gap-3 items-start"
|
||||
>
|
||||
<code class="truncate min-w-0">{{ p.identifier }}</code>
|
||||
<span
|
||||
class="ms-auto text-xs text-right text-slate-600 max-w-[58%] shrink-0"
|
||||
>
|
||||
<template v-if="p.wallClockMillis != null">
|
||||
<span class="block font-medium">{{
|
||||
formatIsoMs(p.wallClockMillis)
|
||||
}}</span>
|
||||
<span class="block text-[10px] text-slate-400"
|
||||
>Scheduled target ({{ p.wallClockSource }})</span
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
p.nextTriggerDate != null &&
|
||||
Math.abs(p.nextTriggerDate - p.wallClockMillis) > 5000
|
||||
"
|
||||
class="block text-[10px] text-amber-800 mt-0.5"
|
||||
>iOS nextTriggerDate (resamples on each fetch for interval
|
||||
triggers): {{ formatIsoMs(p.nextTriggerDate) }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="block">{{
|
||||
formatIsoMs(p.nextTriggerDate ?? null)
|
||||
}}</span>
|
||||
<span class="block text-[10px] text-slate-400"
|
||||
>iOS nextTriggerDate</span
|
||||
>
|
||||
</template>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- SECTION E: Clear Notifications -->
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 font-bold">Clear Notifications</h2>
|
||||
<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="onClearNotifications"
|
||||
>
|
||||
Clear Notifications
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- SECTION G: Event Log -->
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h2 class="font-bold">Event Log</h2>
|
||||
<button
|
||||
class="ms-auto text-sm px-3 py-2 rounded border border-slate-300 bg-white"
|
||||
@click="NotificationDebugService.clearDebugLogs()"
|
||||
>
|
||||
Clear Log
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="bg-white rounded border border-slate-200 px-3 py-2 text-xs font-mono whitespace-pre-wrap min-h-[8rem]"
|
||||
>
|
||||
<div v-if="eventLog.length === 0" class="text-slate-400">(empty)</div>
|
||||
<div v-for="(line, idx) in eventLog" v-else :key="idx">
|
||||
{{ line }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { copyToClipboard } from "@/services/ClipboardService";
|
||||
import { subscribe } from "@/services/notifications/NotificationDebugEvents";
|
||||
import { NotificationDebugService } from "@/services/notifications/NotificationDebugService";
|
||||
|
||||
type PendingInfo = {
|
||||
identifier: string;
|
||||
nextTriggerDate?: number | null;
|
||||
triggerType?: string | null;
|
||||
wallClockMillis?: number | 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 pending = ref<PendingInfo[]>([]);
|
||||
const pendingInspectorMessage = ref<string | null>(null);
|
||||
const backendUrlDraft = ref("");
|
||||
const testModeEnabled = ref(NotificationDebugService.isTestModeEnabled());
|
||||
const bypassAuthEnabled = ref(NotificationDebugService.isBypassAuthEnabled());
|
||||
const fcmToken = ref<string | null>(NotificationDebugService.getFcmToken());
|
||||
|
||||
const activeBackendUrl = ref(NotificationDebugService.getActiveBackendUrl());
|
||||
const realWakeupStatus = ref<{ ok: boolean; message: string } | null>(null);
|
||||
const alertAuthorizationStatus = ref<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
const truncatedFcmToken = computed(() => {
|
||||
const t = fcmToken.value?.trim() ?? "";
|
||||
if (!t) {
|
||||
return "";
|
||||
}
|
||||
if (t.length <= 24) {
|
||||
return t;
|
||||
}
|
||||
return `${t.slice(0, 12)}…${t.slice(-8)}`;
|
||||
});
|
||||
|
||||
const eventLog = ref<string[]>([]);
|
||||
let unsubscribeEventLog: (() => void) | undefined;
|
||||
|
||||
const intervalLabel = computed(() => {
|
||||
const preset = presets.find((p) => p.ms === intervalMs.value);
|
||||
return preset?.label ?? `${intervalMs.value}ms`;
|
||||
});
|
||||
|
||||
function formatIsoMs(ms: number | null | undefined): string {
|
||||
if (ms == null || !Number.isFinite(ms)) {
|
||||
return "";
|
||||
}
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
async function withBusy(fn: () => Promise<void>): Promise<void> {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPending(): Promise<void> {
|
||||
const result = await NotificationDebugService.getPendingNotifications();
|
||||
pending.value = result.pending;
|
||||
pendingInspectorMessage.value = result.inspectorUnavailableMessage ?? null;
|
||||
}
|
||||
|
||||
async function onMockRefresh(): Promise<void> {
|
||||
await withBusy(async () => {
|
||||
await NotificationDebugService.triggerMockRefresh(intervalMs.value);
|
||||
await refreshPending();
|
||||
});
|
||||
}
|
||||
|
||||
async function onWakeupPing(): Promise<void> {
|
||||
await withBusy(async () => {
|
||||
await NotificationDebugService.simulateWakeupPing();
|
||||
await refreshPending();
|
||||
});
|
||||
}
|
||||
|
||||
async function onFloodTest(): Promise<void> {
|
||||
await withBusy(async () => {
|
||||
await NotificationDebugService.runFloodTest(intervalMs.value);
|
||||
await refreshPending();
|
||||
});
|
||||
}
|
||||
|
||||
async function onClearNotifications(): Promise<void> {
|
||||
await withBusy(async () => {
|
||||
await NotificationDebugService.clearNotifications();
|
||||
await refreshPending();
|
||||
});
|
||||
}
|
||||
|
||||
function syncBackendState(): void {
|
||||
backendUrlDraft.value =
|
||||
NotificationDebugService.getBackendUrlOverride() ?? "";
|
||||
testModeEnabled.value = NotificationDebugService.isTestModeEnabled();
|
||||
bypassAuthEnabled.value = NotificationDebugService.isBypassAuthEnabled();
|
||||
fcmToken.value = NotificationDebugService.getFcmToken();
|
||||
activeBackendUrl.value = NotificationDebugService.getActiveBackendUrl();
|
||||
}
|
||||
|
||||
function onSaveBackendUrl(): void {
|
||||
NotificationDebugService.saveBackendBaseUrl(backendUrlDraft.value);
|
||||
syncBackendState();
|
||||
}
|
||||
|
||||
function onTestModeChange(): void {
|
||||
NotificationDebugService.setTestModeEnabled(testModeEnabled.value);
|
||||
}
|
||||
|
||||
function onBypassAuthChange(): void {
|
||||
NotificationDebugService.setBypassAuthEnabled(bypassAuthEnabled.value);
|
||||
}
|
||||
|
||||
async function onRegisterToken(): Promise<void> {
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await NotificationDebugService.registerTokenNow();
|
||||
} catch {
|
||||
// logged in panel
|
||||
} finally {
|
||||
fcmToken.value = NotificationDebugService.getFcmToken();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onUploadAlertAuthorization(): Promise<void> {
|
||||
alertAuthorizationStatus.value = null;
|
||||
await withBusy(async () => {
|
||||
const result =
|
||||
await NotificationDebugService.uploadAlertSearchAuthorization();
|
||||
alertAuthorizationStatus.value = result.ok
|
||||
? {
|
||||
ok: true,
|
||||
message: [
|
||||
`Uploaded ${result.jwtCount} JWTs (HTTP ${result.status}).`,
|
||||
`Batch ${result.batchId}.`,
|
||||
`${result.timezone}: ${result.firstDay} through ${result.lastDay}.`,
|
||||
result.message,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
message: [
|
||||
`AlertSearch authorization upload failed: ${result.errorMessage}`,
|
||||
result.status != null ? `(HTTP ${result.status})` : undefined,
|
||||
result.errorCode ? `Code: ${result.errorCode}.` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function onBackendRefresh(): Promise<void> {
|
||||
await withBusy(async () => {
|
||||
await NotificationDebugService.triggerBackendRefresh();
|
||||
await refreshPending();
|
||||
});
|
||||
}
|
||||
|
||||
async function onSimulateWakeupRefresh(): Promise<void> {
|
||||
await withBusy(async () => {
|
||||
await NotificationDebugService.simulateWakeupViaRefresh();
|
||||
await refreshPending();
|
||||
});
|
||||
}
|
||||
|
||||
function formatRealWakeupStatusMessage(
|
||||
result: Awaited<
|
||||
ReturnType<typeof NotificationDebugService.sendRealWakeupPing>
|
||||
>,
|
||||
): string {
|
||||
if (result.ok) {
|
||||
const body =
|
||||
typeof result.responseBody === "object" && result.responseBody !== null
|
||||
? (result.responseBody as Record<string, unknown>)
|
||||
: null;
|
||||
const parts = ["Real WAKEUP_PING sent via backend."];
|
||||
if (typeof body?.message === "string" && body.message.trim()) {
|
||||
parts.push(body.message.trim());
|
||||
}
|
||||
if (typeof body?.tokenSuffix === "string" && body.tokenSuffix.trim()) {
|
||||
parts.push(`token …${body.tokenSuffix.trim()}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
const parts = [`Real WAKEUP_PING failed: ${result.errorMessage}`];
|
||||
if (result.status != null) {
|
||||
parts.push(`(HTTP ${result.status})`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
async function onSendRealWakeupPing(): Promise<void> {
|
||||
realWakeupStatus.value = null;
|
||||
await withBusy(async () => {
|
||||
const result = await NotificationDebugService.sendRealWakeupPing();
|
||||
realWakeupStatus.value = {
|
||||
ok: result.ok,
|
||||
message: formatRealWakeupStatusMessage(result),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function onCopyFcmToken(): Promise<void> {
|
||||
const token = fcmToken.value?.trim();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
await copyToClipboard(token);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribeEventLog = subscribe((entries) => {
|
||||
eventLog.value = [...entries];
|
||||
});
|
||||
syncBackendState();
|
||||
void refreshPending();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribeEventLog?.();
|
||||
});
|
||||
</script>
|
||||
@@ -1,6 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { inject } from "vue";
|
||||
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
|
||||
@@ -29,6 +31,38 @@ export function useNotifications() {
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -93,5 +127,8 @@ export function useNotifications() {
|
||||
notAGive,
|
||||
notificationOff,
|
||||
downloadStarted,
|
||||
/** POST FCM token to `/notifications/register` (same as startup native hook). */
|
||||
registerFcmToken: registerToken,
|
||||
refreshNotifications: refreshNotificationsDebounced,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ export enum AppString {
|
||||
TEST1_PUSH_SERVER = "https://test.timesafari.app",
|
||||
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
|
||||
|
||||
PROD_NOTIFY_API_SERVER = "https://notify-api.timesafari.app",
|
||||
TEST_NOTIFY_API_SERVER = "https://test-notify-api.timesafari.app",
|
||||
|
||||
NO_CONTACT_NAME = "(no name)",
|
||||
}
|
||||
|
||||
@@ -47,6 +50,10 @@ export const DEFAULT_PARTNER_API_SERVER =
|
||||
export const DEFAULT_PUSH_SERVER =
|
||||
import.meta.env.VITE_DEFAULT_PUSH_SERVER || AppString.PROD_PUSH_SERVER;
|
||||
|
||||
export const DEFAULT_NOTIFY_API_SERVER =
|
||||
import.meta.env.VITE_DEFAULT_NOTIFY_API_SERVER ||
|
||||
AppString.PROD_NOTIFY_API_SERVER;
|
||||
|
||||
export const IMAGE_TYPE_PROFILE = "profile";
|
||||
|
||||
/**
|
||||
|
||||
5
src/constants/delegatedNotificationJwt.ts
Normal file
5
src/constants/delegatedNotificationJwt.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Delegated authorization JWTs for notification-wakeup-service via notify-api.
|
||||
* Distinct from the native background prefetch pool in backgroundJwt.ts.
|
||||
*/
|
||||
export const DELEGATED_NOTIFICATION_JWT_COUNT = 100;
|
||||
125
src/interfaces/alertSearch.ts
Normal file
125
src/interfaces/alertSearch.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* alertSearch response item and envelope types.
|
||||
*
|
||||
* These buckets are new to this app. Item shapes are taken from the endorser-ch
|
||||
* SELECT lists for alertSearch, not from similarly named existing report types.
|
||||
*
|
||||
* Existing types that were considered and not reused:
|
||||
* - GenericCredWrapper — claims lack a `claim` body; extra jwt columns differ.
|
||||
* - GiveSummaryRecord / OfferSummaryRecord — those use `jwtId` and give/offer
|
||||
* summary fields; alertSearch jwt rows use `id` and jwt table columns.
|
||||
* - PlanSummaryAndPreviousClaim — `/plansLastUpdatedBetween` wraps `{ plan,
|
||||
* wrappedClaimBefore }`; alertSearch `trackedPlanUpdates` are plan_claim rows.
|
||||
* - PlanSummaryRecord — overlapping plan fields, but the app type is a subset
|
||||
* (missing fulfillsLinkConfirmed, result*, etc.) and required fields differ.
|
||||
* - UserProfile — partner nearby rows include `updatedAt` / `rowId` and omit
|
||||
* embedding flags that UserProfile models.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Server-issued ULID on a stored JWT/plan record, used as alertSearch afterId /
|
||||
* beforeId. Not an authentication JWT and not a delegated notification JWT.
|
||||
*/
|
||||
export type AlertSearchCursorUlid = string;
|
||||
|
||||
/**
|
||||
* JWT row from endorser `jwtsWithDidAfterId` (no claim body).
|
||||
* Cursor field: `id`.
|
||||
*/
|
||||
export interface AlertSearchClaimRecord {
|
||||
id: AlertSearchCursorUlid;
|
||||
issuedAt: string;
|
||||
issuer: string;
|
||||
subject?: string;
|
||||
claimType?: string;
|
||||
handleId?: string;
|
||||
fromEntity?: string;
|
||||
toEntity?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT row from `jwtsForUserPlanContributions` and
|
||||
* `jwtsGiveActionOfferForPlanHandleIds`. `claim` is the jwt table TEXT
|
||||
* (canonical JSON string); alertSearch does not JSON.parse it.
|
||||
* Cursor field: `id`.
|
||||
*/
|
||||
export interface AlertSearchJwtWithClaimRecord extends AlertSearchClaimRecord {
|
||||
claim?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* plan_claim row from `plansLastUpdatedBetween` and `plansByLocationAfterId`.
|
||||
* Cursor field: `jwtId` (not `id`).
|
||||
*/
|
||||
export interface AlertSearchPlanRecord {
|
||||
handleId: string;
|
||||
jwtId: AlertSearchCursorUlid;
|
||||
issuerDid?: string;
|
||||
agentDid?: string;
|
||||
fulfillsLinkConfirmed?: boolean | number;
|
||||
fulfillsPlanClaimId?: string;
|
||||
fulfillsPlanHandleId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
endTime?: string;
|
||||
startTime?: string;
|
||||
locLat?: number;
|
||||
locLon?: number;
|
||||
resultDescription?: string;
|
||||
resultIdentifier?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* user_profile row from partner `profilesByLocationAfterDate`.
|
||||
* Profiles have no JWT `id`; partner paging uses dates decoded from cursor ULIDs.
|
||||
*/
|
||||
export interface AlertSearchProfileRecord {
|
||||
rowId?: number;
|
||||
issuerDid: string;
|
||||
updatedAt?: string;
|
||||
description: string;
|
||||
locLat?: number;
|
||||
locLon?: number;
|
||||
locLat2?: number;
|
||||
locLon2?: number;
|
||||
}
|
||||
|
||||
export interface EndorserAlertSearchData {
|
||||
claims: AlertSearchClaimRecord[];
|
||||
personalPlanContributions: AlertSearchJwtWithClaimRecord[];
|
||||
trackedPlanUpdates: AlertSearchPlanRecord[];
|
||||
trackedPlanClaims: AlertSearchJwtWithClaimRecord[];
|
||||
plansNearby: AlertSearchPlanRecord[];
|
||||
}
|
||||
|
||||
export interface PartnerAlertSearchData {
|
||||
profilesNearby: AlertSearchProfileRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Endorser GET/POST /api/v2/report/alertSearch body.
|
||||
* Per-bucket SQL hitLimit is not currently copied onto this envelope.
|
||||
* Timeouts may set `userMessage` instead.
|
||||
*/
|
||||
export interface EndorserAlertSearchResponse {
|
||||
data: EndorserAlertSearchData;
|
||||
userMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partner GET/POST /api/partner/alertSearch body.
|
||||
*/
|
||||
export interface PartnerAlertSearchResponse {
|
||||
data: PartnerAlertSearchData;
|
||||
userMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of the six alertSearch buckets for a future combined daily run.
|
||||
* Not returned by a single server endpoint today.
|
||||
*/
|
||||
export interface CombinedAlertSearchData
|
||||
extends EndorserAlertSearchData,
|
||||
PartnerAlertSearchData {}
|
||||
29
src/interfaces/delegatedNotificationJwt.ts
Normal file
29
src/interfaces/delegatedNotificationJwt.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Batch of per-UTC-day delegated JWTs for notify-api / wakeup-service.
|
||||
*
|
||||
* Not authentication JWTs, not alertSearch cursor ULIDs, and not the native
|
||||
* background prefetch pool (`mintBackgroundJwtTokenPool`).
|
||||
*/
|
||||
|
||||
export interface DelegatedNotificationJwtWindow {
|
||||
/** 1-based; 1 is the UTC calendar day of `now`. */
|
||||
sequence: number;
|
||||
/** Calendar date of this slot, YYYY-MM-DD in UTC. */
|
||||
utcDay: string;
|
||||
/** Unix seconds at 00:00:00Z of this UTC day. */
|
||||
nbf: number;
|
||||
/** Unix seconds at 00:00:00Z of the following UTC day. */
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface DelegatedNotificationJwtSlot
|
||||
extends DelegatedNotificationJwtWindow {
|
||||
jwt: string;
|
||||
}
|
||||
|
||||
export interface DelegatedNotificationJwtBatch {
|
||||
did: string;
|
||||
timeZone: string;
|
||||
mintedAtEpoch: number;
|
||||
tokens: DelegatedNotificationJwtSlot[];
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./alertSearch";
|
||||
export * from "./claims";
|
||||
export * from "./delegatedNotificationJwt";
|
||||
export * from "./claims-result";
|
||||
export * from "./common";
|
||||
export * from "./deepLinks";
|
||||
|
||||
67
src/libs/alertSearch.ts
Normal file
67
src/libs/alertSearch.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Typed alertSearch API contract only. No HTTP client yet.
|
||||
*
|
||||
* Hosts: use DEFAULT_ENDORSER_API_SERVER and DEFAULT_PARTNER_API_SERVER from
|
||||
* `@/constants/app`. Do not duplicate those constants here.
|
||||
*
|
||||
* JWT kinds (do not mix these):
|
||||
* - Authentication JWT: short-lived access token (`iss`/`iat`/`exp`) sent as
|
||||
* `Authorization: Bearer` for interactive API calls (`accessToken` /
|
||||
* `getHeaders`). Identifies the requester DID.
|
||||
* - Delegated notification JWT: 100 per-UTC-day tokens from
|
||||
* `mintDelegatedNotificationJwtBatch` for notify-api / wakeup-service.
|
||||
* - Native background pool: `mintBackgroundJwtTokenPool` for daily-notification
|
||||
* plugin prefetch. Unrelated to alertSearch and to the delegated batch.
|
||||
* - alertSearch cursor ULID: server-issued record/JWT primary id (26-char
|
||||
* ULID). `afterId` means ids strictly greater than that ULID; `beforeId`
|
||||
* means strictly less. First daily run omits afterId. `beforeId` is for
|
||||
* pagination within a run. These are not auth JWTs and not signed tokens.
|
||||
*
|
||||
* Truncation: each endorser bucket query uses a server hit-limit (typically
|
||||
* 50). That flag is not currently returned on the alertSearch JSON envelope.
|
||||
* Timeouts may add `userMessage`. A later caller must still paginate with
|
||||
* beforeId when a bucket may be incomplete.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AlertSearchCursorUlid,
|
||||
CombinedAlertSearchData,
|
||||
EndorserAlertSearchResponse,
|
||||
PartnerAlertSearchResponse,
|
||||
} from "@/interfaces/alertSearch";
|
||||
|
||||
export const ENDORSER_ALERT_SEARCH_PATH = "/api/v2/report/alertSearch";
|
||||
export const PARTNER_ALERT_SEARCH_PATH = "/api/partner/alertSearch";
|
||||
|
||||
export interface AlertSearchLocationBBox {
|
||||
minLocLat: number;
|
||||
maxLocLat: number;
|
||||
minLocLon: number;
|
||||
maxLocLon: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query/body params accepted by endorser and partner alertSearch (GET or POST).
|
||||
* GET is the planned daily method; the server also accepts POST.
|
||||
*/
|
||||
export interface AlertSearchRequestParams {
|
||||
afterId?: AlertSearchCursorUlid;
|
||||
beforeId?: AlertSearchCursorUlid;
|
||||
afterDate?: string;
|
||||
beforeDate?: string;
|
||||
location?: AlertSearchLocationBBox;
|
||||
minLocLat?: number;
|
||||
maxLocLat?: number;
|
||||
minLocLon?: number;
|
||||
maxLocLon?: number;
|
||||
planHandleIds?: string[];
|
||||
planIds?: string[];
|
||||
handleIds?: string[];
|
||||
}
|
||||
|
||||
export type {
|
||||
AlertSearchCursorUlid,
|
||||
CombinedAlertSearchData,
|
||||
EndorserAlertSearchResponse,
|
||||
PartnerAlertSearchResponse,
|
||||
};
|
||||
101
src/libs/delegatedNotificationJwt.test.ts
Normal file
101
src/libs/delegatedNotificationJwt.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
|
||||
import {
|
||||
buildDelegatedNotificationJwtWindows,
|
||||
mintDelegatedNotificationJwtBatch,
|
||||
} from "@/libs/delegatedNotificationJwt";
|
||||
|
||||
jest.mock("@/libs/util", () => ({
|
||||
retrieveAccountMetadata: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
jest.mock("@/libs/crypto/vc", () => ({
|
||||
isFromPasskey: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
jest.mock("@/libs/endorserServer", () => ({
|
||||
createEndorserJwtForDid: jest.fn(
|
||||
async (_did: string, claims: { jti?: string }) =>
|
||||
`signed:${String(claims.jti)}`,
|
||||
),
|
||||
}));
|
||||
|
||||
const FIXED_NOW = new Date("2026-09-10T16:00:00.000Z");
|
||||
const UTC_DAY_0 = "2026-09-10";
|
||||
const NBF_0 = Math.floor(Date.UTC(2026, 8, 10) / 1000);
|
||||
const EXP_0 = Math.floor(Date.UTC(2026, 8, 11) / 1000);
|
||||
|
||||
describe("buildDelegatedNotificationJwtWindows", () => {
|
||||
it("builds 100 consecutive UTC calendar-day windows from a fixed now", () => {
|
||||
const windows = buildDelegatedNotificationJwtWindows(
|
||||
DELEGATED_NOTIFICATION_JWT_COUNT,
|
||||
"Asia/Manila",
|
||||
FIXED_NOW,
|
||||
);
|
||||
|
||||
expect(windows).toHaveLength(DELEGATED_NOTIFICATION_JWT_COUNT);
|
||||
expect(windows[0]!.sequence).toBe(1);
|
||||
expect(windows[0]!.utcDay).toBe(UTC_DAY_0);
|
||||
expect(windows[0]!.nbf).toBe(NBF_0);
|
||||
expect(windows[0]!.exp).toBe(EXP_0);
|
||||
expect(windows[0]!.exp - windows[0]!.nbf).toBe(86_400);
|
||||
|
||||
const manilaLocalDate = DateTime.fromJSDate(FIXED_NOW, {
|
||||
zone: "Asia/Manila",
|
||||
}).toFormat("yyyy-LL-dd");
|
||||
expect(manilaLocalDate).toBe("2026-09-11");
|
||||
expect(windows[0]!.utcDay).not.toBe(manilaLocalDate);
|
||||
|
||||
for (let i = 0; i < windows.length; i++) {
|
||||
const window = windows[i]!;
|
||||
const expectedDay = DateTime.fromMillis(FIXED_NOW.getTime(), {
|
||||
zone: "utc",
|
||||
})
|
||||
.startOf("day")
|
||||
.plus({ days: i });
|
||||
expect(window.sequence).toBe(i + 1);
|
||||
expect(window.utcDay).toBe(expectedDay.toFormat("yyyy-LL-dd"));
|
||||
expect(window.utcDay).toBe(
|
||||
new Date(window.nbf * 1000).toISOString().slice(0, 10),
|
||||
);
|
||||
expect(window.nbf).toBe(Math.floor(expectedDay.toSeconds()));
|
||||
expect(window.exp).toBe(
|
||||
Math.floor(expectedDay.plus({ days: 1 }).toSeconds()),
|
||||
);
|
||||
expect(window.exp - window.nbf).toBe(86_400);
|
||||
}
|
||||
|
||||
expect(windows[windows.length - 1]!.sequence).toBe(100);
|
||||
expect(windows[windows.length - 1]!.utcDay).toBe("2026-12-18");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mintDelegatedNotificationJwtBatch jti", () => {
|
||||
it("uses the UTC calendar day in jti even when the device zone is Asia/Manila", async () => {
|
||||
const { createEndorserJwtForDid } = jest.requireMock(
|
||||
"@/libs/endorserServer",
|
||||
) as {
|
||||
createEndorserJwtForDid: jest.Mock;
|
||||
};
|
||||
createEndorserJwtForDid.mockClear();
|
||||
|
||||
const did = `did:ethr:0x${"a".repeat(40)}`;
|
||||
const batch = await mintDelegatedNotificationJwtBatch(did, {
|
||||
timeZone: "Asia/Manila",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
|
||||
expect(batch.tokens[0]!.utcDay).toBe(UTC_DAY_0);
|
||||
expect(createEndorserJwtForDid).toHaveBeenCalled();
|
||||
const firstClaims = createEndorserJwtForDid.mock.calls[0]![1] as {
|
||||
jti: string;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
};
|
||||
expect(firstClaims.jti).toBe(`${did}#delegated-notify#${UTC_DAY_0}`);
|
||||
expect(firstClaims.jti).not.toContain("2026-09-11");
|
||||
expect(firstClaims.nbf).toBe(NBF_0);
|
||||
expect(firstClaims.exp).toBe(EXP_0);
|
||||
});
|
||||
});
|
||||
117
src/libs/delegatedNotificationJwt.ts
Normal file
117
src/libs/delegatedNotificationJwt.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Mint 100 delegated notification JWTs (one UTC calendar day each) for notify-api.
|
||||
*
|
||||
* JWT kinds (do not mix):
|
||||
* - Authentication JWT: short-lived `accessToken` / `getHeaders` Bearer for the
|
||||
* setup request itself (not generated here).
|
||||
* - Delegated notification JWT: this module. Signed like other Endorser JWTs
|
||||
* (`createEndorserJwtForDid`). `nbf`/`exp` are that UTC day's bounds.
|
||||
* Sequence is array order: index 0 / sequence 1 = the UTC calendar day of `now`.
|
||||
* - Native background pool: `mintBackgroundJwtTokenPool` — unchanged, unused here.
|
||||
* - alertSearch cursor ULID: server record id, not a signed token.
|
||||
*
|
||||
* Timezone: device IANA zone via Luxon `DateTime.local().zoneName` (same source
|
||||
* as project create/edit). Stored on the batch for the wakeup-service optional
|
||||
* `timezone` field; it does not change the UTC-day JWT windows. Pass `timeZone`
|
||||
* to override.
|
||||
*
|
||||
* Passkey (JWANT) identities cannot carry per-day nbf/exp; minting throws.
|
||||
*/
|
||||
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
|
||||
import type {
|
||||
DelegatedNotificationJwtBatch,
|
||||
DelegatedNotificationJwtSlot,
|
||||
DelegatedNotificationJwtWindow,
|
||||
} from "@/interfaces/delegatedNotificationJwt";
|
||||
import { isFromPasskey } from "@/libs/crypto/vc";
|
||||
import { createEndorserJwtForDid } from "@/libs/endorserServer";
|
||||
import { retrieveAccountMetadata } from "@/libs/util";
|
||||
|
||||
export function resolveUserTimeZone(timeZone?: string): string {
|
||||
const zone = timeZone ?? DateTime.local().zoneName ?? undefined;
|
||||
if (!zone) {
|
||||
throw new Error("Could not determine the user's timezone.");
|
||||
}
|
||||
const probe = DateTime.now().setZone(zone);
|
||||
if (!probe.isValid) {
|
||||
throw new Error(
|
||||
"Invalid timezone for delegated notification JWTs: " + zone,
|
||||
);
|
||||
}
|
||||
return zone;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC-day [nbf, exp) windows for sequence 1..count.
|
||||
* `timeZone` is accepted for call-site compatibility and is not used for bounds.
|
||||
*/
|
||||
export function buildDelegatedNotificationJwtWindows(
|
||||
count: number = DELEGATED_NOTIFICATION_JWT_COUNT,
|
||||
timeZone?: string,
|
||||
now: Date = new Date(),
|
||||
): DelegatedNotificationJwtWindow[] {
|
||||
if (timeZone !== undefined) {
|
||||
resolveUserTimeZone(timeZone);
|
||||
}
|
||||
const todayStart = DateTime.fromJSDate(now, { zone: "utc" }).startOf("day");
|
||||
const windows: DelegatedNotificationJwtWindow[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const dayStart = todayStart.plus({ days: i });
|
||||
const nextStart = dayStart.plus({ days: 1 });
|
||||
windows.push({
|
||||
sequence: i + 1,
|
||||
utcDay: dayStart.toFormat("yyyy-LL-dd"),
|
||||
nbf: Math.floor(dayStart.toSeconds()),
|
||||
exp: Math.floor(nextStart.toSeconds()),
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
export function delegatedNotificationJwtStrings(
|
||||
batch: DelegatedNotificationJwtBatch,
|
||||
): string[] {
|
||||
return batch.tokens.map((slot) => slot.jwt);
|
||||
}
|
||||
|
||||
export async function mintDelegatedNotificationJwtBatch(
|
||||
did: string,
|
||||
options?: { timeZone?: string; now?: Date },
|
||||
): Promise<DelegatedNotificationJwtBatch> {
|
||||
if (!did) {
|
||||
throw new Error("A DID is required to mint delegated notification JWTs.");
|
||||
}
|
||||
|
||||
const account = await retrieveAccountMetadata(did);
|
||||
if (isFromPasskey(account)) {
|
||||
throw new Error(
|
||||
"Delegated notification JWTs with per-day nbf/exp require a local signing key. Passkey JWANT tokens cannot carry those claims.",
|
||||
);
|
||||
}
|
||||
|
||||
const timeZone = resolveUserTimeZone(options?.timeZone);
|
||||
const now = options?.now ?? new Date();
|
||||
const mintedAtEpoch = Math.floor(now.getTime() / 1000);
|
||||
const windows = buildDelegatedNotificationJwtWindows(
|
||||
DELEGATED_NOTIFICATION_JWT_COUNT,
|
||||
timeZone,
|
||||
now,
|
||||
);
|
||||
|
||||
const tokens: DelegatedNotificationJwtSlot[] = [];
|
||||
for (const window of windows) {
|
||||
const jwt = await createEndorserJwtForDid(did, {
|
||||
iss: did,
|
||||
iat: mintedAtEpoch,
|
||||
nbf: window.nbf,
|
||||
exp: window.exp,
|
||||
jti: `${did}#delegated-notify#${window.utcDay}`,
|
||||
});
|
||||
tokens.push({ ...window, jwt });
|
||||
}
|
||||
|
||||
return { did, timeZone, mintedAtEpoch, tokens };
|
||||
}
|
||||
@@ -43,7 +43,11 @@ import "./utils/safeAreaInset";
|
||||
|
||||
// Load Daily Notification plugin at startup so native performRecovery() runs at launch (rollover recovery)
|
||||
import "@timesafari/daily-notification-plugin";
|
||||
import { configureNativeFetcherIfReady } from "@/services/notifications";
|
||||
import {
|
||||
configureNativeFetcherIfReady,
|
||||
initializeNativePushAndFirebaseMessaging,
|
||||
onNotificationAuthMayBeReady,
|
||||
} from "@/services/notifications";
|
||||
|
||||
logger.log("[Capacitor] 🚀 Starting initialization");
|
||||
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
|
||||
@@ -462,6 +466,7 @@ if (
|
||||
// Refresh JWT for background New Activity prefetch (WorkManager cannot run JS;
|
||||
// short-lived tokens would expire between configure and T−5 fetch without this).
|
||||
await configureNativeFetcherIfReady();
|
||||
onNotificationAuthMayBeReady();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -474,6 +479,8 @@ setTimeout(async () => {
|
||||
);
|
||||
await registerDeepLinkListener();
|
||||
logger.info(`[Main] 🎉 Deep link system fully initialized!`);
|
||||
// Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token, delivery listeners)
|
||||
await initializeNativePushAndFirebaseMessaging();
|
||||
// Configure native fetcher for API-driven daily notifications (activeDid + JWT)
|
||||
await configureNativeFetcherIfReady();
|
||||
} catch (error) {
|
||||
|
||||
17
src/plugins/NotificationInspectorPlugin.ts
Normal file
17
src/plugins/NotificationInspectorPlugin.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { registerPlugin } from "@capacitor/core";
|
||||
|
||||
export type PendingNotificationInfo = {
|
||||
identifier: string;
|
||||
nextTriggerDate?: number | null;
|
||||
triggerType?: string | null;
|
||||
/** Epoch ms for intended fire time when known (userInfo or API notification id); stable across refresh. */
|
||||
wallClockMillis?: number | null;
|
||||
wallClockSource?: string | null;
|
||||
};
|
||||
|
||||
export interface NotificationInspectorPlugin {
|
||||
getPendingNotifications(): Promise<{ pending: PendingNotificationInfo[] }>;
|
||||
}
|
||||
|
||||
export const NotificationInspector =
|
||||
registerPlugin<NotificationInspectorPlugin>("NotificationInspector");
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
retrieveAccountDids,
|
||||
generateSaveAndActivateIdentity,
|
||||
} from "../libs/util";
|
||||
import { includeDevToolkitRoutes } from "../utils/includeDevToolkitRoutes";
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
@@ -290,6 +291,19 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "user-profile",
|
||||
component: () => import("../views/UserProfileView.vue"),
|
||||
},
|
||||
...(includeDevToolkitRoutes
|
||||
? ([
|
||||
{
|
||||
path: "/dev/notifications",
|
||||
name: "dev-notifications",
|
||||
component: () => import("../views/dev/NotificationDebugView.vue"),
|
||||
meta: {
|
||||
title: "Notification Debug",
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
] satisfies Array<RouteRecordRaw>)
|
||||
: []),
|
||||
// Catch-all route for 404 errors - must be last
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
|
||||
@@ -12,8 +12,27 @@
|
||||
*/
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { PushNotificationSchema } from "@capacitor/push-notifications";
|
||||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||
import { getOrCreateDeviceId } from "./deviceId";
|
||||
import { REMINDER_ID_DAILY_REMINDER } from "./reminderIds";
|
||||
import { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
|
||||
import {
|
||||
getNotificationApiBaseUrl,
|
||||
getTestMode,
|
||||
} from "./NotificationDebugConfig";
|
||||
import {
|
||||
logRefreshFailure,
|
||||
logRefreshStarted,
|
||||
logRefreshSuccess,
|
||||
logScheduleReplacement,
|
||||
} from "./notificationLog";
|
||||
import {
|
||||
getNotificationApiHeaders,
|
||||
httpAuthErrorMessage,
|
||||
logSkippingRefreshDueToMissingAuth,
|
||||
} from "./notificationApiAuth";
|
||||
import { logNotification } from "./NotificationDebugEvents";
|
||||
|
||||
/**
|
||||
* Extended type for DailyNotification that includes the actual Swift implementation
|
||||
@@ -542,3 +561,197 @@ export class NativeNotificationService implements NotificationServiceInterface {
|
||||
return this.platformName;
|
||||
}
|
||||
}
|
||||
|
||||
export type RefreshNotificationsResult = {
|
||||
ok: boolean;
|
||||
scheduledCount: number;
|
||||
status?: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-applies native API fetcher credentials (JWT pool, active DID) so background
|
||||
* notification workers can run. No UI; safe from push handlers while backgrounded.
|
||||
*/
|
||||
export async function refreshNotificationsWithDiagnostics(options?: {
|
||||
source?: string;
|
||||
}): Promise<RefreshNotificationsResult> {
|
||||
const startedAt = performance.now();
|
||||
const source = options?.source;
|
||||
logRefreshStarted(source);
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
const errorMessage = "not a native platform";
|
||||
logRefreshFailure(startedAt, errorMessage, undefined, source);
|
||||
return {
|
||||
ok: false,
|
||||
scheduledCount: 0,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await getNotificationApiHeaders("refresh");
|
||||
if (!auth.ok) {
|
||||
logSkippingRefreshDueToMissingAuth();
|
||||
logRefreshFailure(startedAt, auth.message, undefined, source);
|
||||
return {
|
||||
ok: false,
|
||||
scheduledCount: 0,
|
||||
errorMessage: auth.message,
|
||||
};
|
||||
}
|
||||
|
||||
let deviceId: string | undefined;
|
||||
try {
|
||||
deviceId = await getOrCreateDeviceId();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[NativeNotificationService] Could not obtain deviceId; refresh proceeding without deviceId",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = getNotificationApiBaseUrl();
|
||||
const res = await fetch(`${baseUrl}/notifications/refresh`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify({
|
||||
deviceId,
|
||||
platform: Capacitor.getPlatform(),
|
||||
testMode: getTestMode(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage =
|
||||
res.status === 401 || res.status === 403
|
||||
? httpAuthErrorMessage(res.status)
|
||||
: res.statusText || `HTTP ${res.status}`;
|
||||
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 data: unknown = await res.json();
|
||||
const payload = data as NotificationRefreshPayload;
|
||||
const scheduledCount = Array.isArray(payload?.nextNotifications)
|
||||
? payload.nextNotifications.length
|
||||
: 0;
|
||||
await applyNotificationRefreshPayload(data);
|
||||
logRefreshSuccess(startedAt, scheduledCount, source);
|
||||
return { ok: true, scheduledCount };
|
||||
} catch (err) {
|
||||
logger.error("[NativeNotificationService] Refresh failed", err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logRefreshFailure(startedAt, message, undefined, source);
|
||||
return { ok: false, scheduledCount: 0, errorMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshNotifications(): Promise<void> {
|
||||
await refreshNotificationsWithDiagnostics();
|
||||
}
|
||||
|
||||
export type NotificationRefreshPayload = {
|
||||
shouldNotify?: boolean;
|
||||
nextNotifications?: Array<{ timestamp?: number }>;
|
||||
};
|
||||
|
||||
// `handleCapacitorPushNotificationReceived` and `applyNotificationRefreshPayload` are used by
|
||||
// DEV notification simulation tooling; they must stay production-safe because that tooling
|
||||
// exercises real flows. (`applyNotificationRefreshPayload` is also used by production refresh.)
|
||||
|
||||
/**
|
||||
* Apply a "refresh notifications" payload by clearing and scheduling timestamps via the native plugin.
|
||||
*
|
||||
* This is the shared implementation used by:
|
||||
* - production refresh flow (`refreshNotifications` fetching from backend)
|
||||
* - dev-only debug flows (mock refresh with local payloads)
|
||||
*
|
||||
* Important: This function intentionally mirrors production behavior and does not introduce
|
||||
* any scheduling logic in UI layers.
|
||||
*/
|
||||
export async function applyNotificationRefreshPayload(
|
||||
payload: unknown,
|
||||
): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = payload as NotificationRefreshPayload;
|
||||
const nextNotifications = data?.nextNotifications;
|
||||
if (!Array.isArray(nextNotifications)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamps = nextNotifications
|
||||
.map((n) => (n as { timestamp?: unknown })?.timestamp)
|
||||
.filter((t): t is number => typeof t === "number" && Number.isFinite(t));
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
logNotification("Schedule replacement skipped (no valid timestamps)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep existing behavior: ensure background worker credentials are current.
|
||||
await configureNativeFetcherIfReady();
|
||||
|
||||
logScheduleReplacement(timestamps.length);
|
||||
|
||||
if (typeof DailyNotification.clearApiNotifications !== "function") {
|
||||
logger.warn(
|
||||
"[NativeNotificationService] API notification clear unavailable (plugin clearApiNotifications missing); cannot replace schedule",
|
||||
);
|
||||
logNotification(
|
||||
"Schedule replacement aborted (API notification clear unavailable on plugin)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logNotification("Clearing API notifications before refresh");
|
||||
await DailyNotification.clearApiNotifications();
|
||||
logNotification("Cleared API notifications");
|
||||
|
||||
if (typeof DailyNotification.scheduleApiNotifications !== "function") {
|
||||
logger.warn(
|
||||
"[NativeNotificationService] scheduleApiNotifications not available on plugin; cannot apply timestamps",
|
||||
);
|
||||
logNotification(
|
||||
"Schedule replacement aborted (scheduleApiNotifications unavailable)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await DailyNotification.scheduleApiNotifications({ timestamps });
|
||||
logNotification(
|
||||
`Schedule replacement applied (${timestamps.length} timestamp(s))`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Silent FCM/APNs data push: refresh native notification pipeline when requested by backend.
|
||||
*/
|
||||
export async function handleCapacitorPushNotificationReceived(
|
||||
notification: PushNotificationSchema,
|
||||
): Promise<void> {
|
||||
if (notification.data?.type === "WAKEUP_PING") {
|
||||
logNotification("WAKEUP_PING handler — invoking refresh");
|
||||
await refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING" });
|
||||
return;
|
||||
}
|
||||
const type =
|
||||
typeof notification.data?.type === "string"
|
||||
? notification.data.type
|
||||
: "(none)";
|
||||
logNotification(`push handler ignored type=${type}`);
|
||||
}
|
||||
|
||||
50
src/services/notifications/NotificationDebugConfig.test.ts
Normal file
50
src/services/notifications/NotificationDebugConfig.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
jest.mock("@/constants/app", () => ({
|
||||
DEFAULT_NOTIFY_API_SERVER: "https://notify-api.timesafari.app",
|
||||
}));
|
||||
|
||||
import {
|
||||
getNotificationDebugOverrideHeaders,
|
||||
NGROK_SKIP_BROWSER_WARNING_HEADER,
|
||||
NGROK_SKIP_BROWSER_WARNING_VALUE,
|
||||
setBackendBaseUrl,
|
||||
} from "./NotificationDebugConfig";
|
||||
|
||||
const STORAGE_KEY_BACKEND_URL = "notificationDebug.backendBaseUrl";
|
||||
|
||||
describe("getNotificationDebugOverrideHeaders", () => {
|
||||
const memory = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
memory.clear();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => memory.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
memory.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
memory.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the ngrok skip header when the debug backend override is set", () => {
|
||||
setBackendBaseUrl("https://detail-frown-machine.ngrok-free.dev");
|
||||
|
||||
expect(getNotificationDebugOverrideHeaders()).toEqual({
|
||||
[NGROK_SKIP_BROWSER_WARNING_HEADER]: NGROK_SKIP_BROWSER_WARNING_VALUE,
|
||||
});
|
||||
expect(memory.get(STORAGE_KEY_BACKEND_URL)).toBe(
|
||||
"https://detail-frown-machine.ngrok-free.dev",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the ngrok skip header for the default/production notification API", () => {
|
||||
setBackendBaseUrl("");
|
||||
|
||||
expect(getNotificationDebugOverrideHeaders()).toEqual({});
|
||||
expect(memory.get(STORAGE_KEY_BACKEND_URL)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
132
src/services/notifications/NotificationDebugConfig.ts
Normal file
132
src/services/notifications/NotificationDebugConfig.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Lightweight debug configuration for notification backend testing.
|
||||
* Persists overrides in localStorage; production defaults apply when unset.
|
||||
*/
|
||||
|
||||
import { DEFAULT_NOTIFY_API_SERVER } from "@/constants/app";
|
||||
|
||||
const LOG = "[NotificationDebug]";
|
||||
const STORAGE_KEY_BACKEND_URL = "notificationDebug.backendBaseUrl";
|
||||
const STORAGE_KEY_TEST_MODE = "notificationDebug.testMode";
|
||||
const STORAGE_KEY_BYPASS_AUTH = "notificationDebug.bypassAuth";
|
||||
|
||||
/** Free-ngrok interstitial bypass; only sent when the debug backend override is set. */
|
||||
export const NGROK_SKIP_BROWSER_WARNING_HEADER = "ngrok-skip-browser-warning";
|
||||
export const NGROK_SKIP_BROWSER_WARNING_VALUE = "true";
|
||||
|
||||
/** Trim whitespace, drop trailing slash; empty input becomes null. */
|
||||
export function normalizeNotificationBackendUrl(url: string): string | null {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function readStorage(key: string): string | null {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: string | null): void {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (value === null) {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
} catch {
|
||||
// Quota / privacy mode — ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Backend URL override, or null when using the default Notification API. */
|
||||
export function getBackendBaseUrl(): string | null {
|
||||
const raw = readStorage(STORAGE_KEY_BACKEND_URL);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
return normalizeNotificationBackendUrl(raw);
|
||||
}
|
||||
|
||||
export function setBackendBaseUrl(url: string): void {
|
||||
const normalized = normalizeNotificationBackendUrl(url);
|
||||
if (normalized === null) {
|
||||
writeStorage(STORAGE_KEY_BACKEND_URL, null);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${LOG} backend URL cleared (using default)`);
|
||||
return;
|
||||
}
|
||||
writeStorage(STORAGE_KEY_BACKEND_URL, normalized);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${LOG} backend URL set to ${normalized}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* When never configured via debug UI/console, matches prior hardcoded `testMode: true`.
|
||||
*/
|
||||
export function getTestMode(): boolean {
|
||||
const raw = readStorage(STORAGE_KEY_TEST_MODE);
|
||||
if (raw === null) {
|
||||
return true;
|
||||
}
|
||||
return raw === "true";
|
||||
}
|
||||
|
||||
export function setTestMode(enabled: boolean): void {
|
||||
writeStorage(STORAGE_KEY_TEST_MODE, enabled ? "true" : "false");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${LOG} test mode ${enabled ? "enabled" : "disabled"}`);
|
||||
}
|
||||
|
||||
/** When never configured via debug UI/console, auth bypass is off (JWT used). */
|
||||
export function getBypassAuth(): boolean {
|
||||
const raw = readStorage(STORAGE_KEY_BYPASS_AUTH);
|
||||
if (raw === null) {
|
||||
return false;
|
||||
}
|
||||
return raw === "true";
|
||||
}
|
||||
|
||||
export function setBypassAuth(enabled: boolean): void {
|
||||
writeStorage(STORAGE_KEY_BYPASS_AUTH, enabled ? "true" : "false");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${LOG} auth bypass ${enabled ? "enabled" : "disabled"}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL for `/notifications/*` API calls.
|
||||
* Uses debug override when set; otherwise DEFAULT_NOTIFY_API_SERVER.
|
||||
*/
|
||||
export function getNotificationApiBaseUrl(): string {
|
||||
const override = getBackendBaseUrl();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
return (
|
||||
normalizeNotificationBackendUrl(DEFAULT_NOTIFY_API_SERVER) ??
|
||||
DEFAULT_NOTIFY_API_SERVER
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra headers for notification API calls when the Debug Panel backend URL
|
||||
* override is set. Production/default hosts do not get this header.
|
||||
*/
|
||||
export function getNotificationDebugOverrideHeaders(): Record<string, string> {
|
||||
if (!getBackendBaseUrl()) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
[NGROK_SKIP_BROWSER_WARNING_HEADER]: NGROK_SKIP_BROWSER_WARNING_VALUE,
|
||||
};
|
||||
}
|
||||
74
src/services/notifications/NotificationDebugEvents.ts
Normal file
74
src/services/notifications/NotificationDebugEvents.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Lightweight in-memory notification debug log + console observability.
|
||||
* Used by production notification flows and the Notification Debug Panel.
|
||||
*/
|
||||
|
||||
export const NOTIFICATION_LOG_PREFIX = "[Notifications]";
|
||||
|
||||
const MAX_ENTRIES = 100;
|
||||
|
||||
type LogListener = (entries: readonly string[]) => void;
|
||||
|
||||
const entries: string[] = [];
|
||||
const listeners = new Set<LogListener>();
|
||||
|
||||
function formatTime(d: Date): string {
|
||||
const hh = d.getHours().toString().padStart(2, "0");
|
||||
const mm = d.getMinutes().toString().padStart(2, "0");
|
||||
const ss = d.getSeconds().toString().padStart(2, "0");
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function formatPanelLine(message: string): string {
|
||||
return `[${formatTime(new Date())}] ${message}`;
|
||||
}
|
||||
|
||||
function notifyListeners(): void {
|
||||
const snapshot = [...entries] as readonly string[];
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
/** Append a timestamped line to the in-memory debug log (panel). */
|
||||
export function appendLog(message: string): void {
|
||||
entries.push(formatPanelLine(message));
|
||||
if (entries.length > MAX_ENTRIES) {
|
||||
entries.splice(0, entries.length - MAX_ENTRIES);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
export function subscribe(listener: LogListener): () => void {
|
||||
listeners.add(listener);
|
||||
listener([...entries]);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function clearNotificationDebugLogs(): void {
|
||||
entries.length = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
export function getNotificationDebugLogEntries(): readonly string[] {
|
||||
return [...entries];
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured console log (`[Notifications] …`) plus debug panel entry.
|
||||
*/
|
||||
export function logNotification(
|
||||
message: string,
|
||||
detail?: Record<string, unknown>,
|
||||
): void {
|
||||
if (detail !== undefined) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${NOTIFICATION_LOG_PREFIX} ${message}`, detail);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${NOTIFICATION_LOG_PREFIX} ${message}`);
|
||||
}
|
||||
appendLog(message);
|
||||
}
|
||||
386
src/services/notifications/NotificationDebugService.ts
Normal file
386
src/services/notifications/NotificationDebugService.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* DEV-only notification testing utilities.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* This service intentionally routes through the same production notification
|
||||
* orchestration paths used by refresh flows, wakeup pushes, and replacement.
|
||||
* Avoid adding duplicate scheduling logic here.
|
||||
*/
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { PushNotificationSchema } from "@capacitor/push-notifications";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { getOrCreateDeviceId } from "./deviceId";
|
||||
import {
|
||||
clearNotificationDebugLogs,
|
||||
logNotification,
|
||||
} from "./NotificationDebugEvents";
|
||||
import { logNotificationClearing } from "./notificationLog";
|
||||
import {
|
||||
getBackendBaseUrl,
|
||||
getBypassAuth,
|
||||
getNotificationApiBaseUrl,
|
||||
getTestMode,
|
||||
setBackendBaseUrl,
|
||||
setBypassAuth,
|
||||
setTestMode,
|
||||
} from "./NotificationDebugConfig";
|
||||
import {
|
||||
getLastKnownFcmToken,
|
||||
reregisterFcmTokenNow,
|
||||
} from "./firebaseMessagingClient";
|
||||
import {
|
||||
getNotificationApiHeaders,
|
||||
httpAuthErrorMessage,
|
||||
} from "./notificationApiAuth";
|
||||
import {
|
||||
applyNotificationRefreshPayload,
|
||||
handleCapacitorPushNotificationReceived,
|
||||
refreshNotificationsWithDiagnostics,
|
||||
type NotificationRefreshPayload,
|
||||
} from "./NativeNotificationService";
|
||||
import { truncateFcmTokenForLog } from "./notificationLog";
|
||||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||
import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin";
|
||||
import {
|
||||
uploadAlertSearchAuthorization as uploadAlertSearchAuthorizationBatch,
|
||||
type AlertAuthorizationUploadResult,
|
||||
} from "./alertAuthorization";
|
||||
|
||||
type PendingNotificationInfo = {
|
||||
identifier: string;
|
||||
nextTriggerDate?: number | null;
|
||||
triggerType?: string | null;
|
||||
wallClockMillis?: number | null;
|
||||
wallClockSource?: string | null;
|
||||
};
|
||||
|
||||
export type PendingNotificationsResult = {
|
||||
pending: PendingNotificationInfo[];
|
||||
/** Native layer does not implement inspection on this platform (e.g. Android). */
|
||||
inspectorUnavailableMessage?: string;
|
||||
};
|
||||
|
||||
export type SendRealWakeupPingResult =
|
||||
| { ok: true; responseBody?: unknown }
|
||||
| {
|
||||
ok: false;
|
||||
errorMessage: string;
|
||||
status?: number;
|
||||
responseBody?: unknown;
|
||||
};
|
||||
|
||||
function wakeupPingResponseDetail(body: unknown): Record<string, unknown> {
|
||||
if (typeof body !== "object" || body === null) {
|
||||
return {};
|
||||
}
|
||||
const record = body as Record<string, unknown>;
|
||||
const detail: Record<string, unknown> = {};
|
||||
for (const key of [
|
||||
"success",
|
||||
"message",
|
||||
"reason",
|
||||
"error",
|
||||
"tokenSuffix",
|
||||
"deviceId",
|
||||
] as const) {
|
||||
if (record[key] !== undefined) {
|
||||
detail[key] = record[key];
|
||||
}
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
function wakeupPingFailureMessage(status: number, body: unknown): string {
|
||||
if (typeof body === "object" && body !== null) {
|
||||
const record = body as Record<string, unknown>;
|
||||
for (const key of ["message", "reason", "error"] as const) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
return httpAuthErrorMessage(status);
|
||||
}
|
||||
return `HTTP ${status}`;
|
||||
}
|
||||
|
||||
function isUnimplementedError(e: unknown): boolean {
|
||||
return (
|
||||
typeof e === "object" &&
|
||||
e !== null &&
|
||||
"code" in e &&
|
||||
(e as { code?: string }).code === "UNIMPLEMENTED"
|
||||
);
|
||||
}
|
||||
|
||||
const LOG = "[NotificationDebugService]";
|
||||
|
||||
export const NotificationDebugService = {
|
||||
clearDebugLogs(): void {
|
||||
clearNotificationDebugLogs();
|
||||
},
|
||||
|
||||
getActiveBackendUrl(): string {
|
||||
return getNotificationApiBaseUrl();
|
||||
},
|
||||
|
||||
getBackendUrlOverride(): string | null {
|
||||
return getBackendBaseUrl();
|
||||
},
|
||||
|
||||
saveBackendBaseUrl(url: string): void {
|
||||
setBackendBaseUrl(url);
|
||||
logNotification(
|
||||
url.trim()
|
||||
? `Backend URL saved (${getNotificationApiBaseUrl()})`
|
||||
: "Backend URL cleared (using default)",
|
||||
);
|
||||
},
|
||||
|
||||
setTestModeEnabled(enabled: boolean): void {
|
||||
setTestMode(enabled);
|
||||
logNotification(`Test mode ${enabled ? "enabled" : "disabled"}`);
|
||||
},
|
||||
|
||||
isTestModeEnabled(): boolean {
|
||||
return getTestMode();
|
||||
},
|
||||
|
||||
setBypassAuthEnabled(enabled: boolean): void {
|
||||
setBypassAuth(enabled);
|
||||
logNotification(
|
||||
`Auth bypass ${enabled ? "enabled" : "disabled"} (local dev only)`,
|
||||
);
|
||||
},
|
||||
|
||||
isBypassAuthEnabled(): boolean {
|
||||
return getBypassAuth();
|
||||
},
|
||||
|
||||
getFcmToken(): string | null {
|
||||
return getLastKnownFcmToken();
|
||||
},
|
||||
|
||||
async registerTokenNow(): Promise<void> {
|
||||
logNotification("Register token now (debug panel)");
|
||||
await reregisterFcmTokenNow();
|
||||
},
|
||||
|
||||
async uploadAlertSearchAuthorization(): Promise<AlertAuthorizationUploadResult> {
|
||||
logNotification("AlertSearch authorization upload requested");
|
||||
const result = await uploadAlertSearchAuthorizationBatch();
|
||||
if (result.ok) {
|
||||
logNotification("AlertSearch authorization upload succeeded", {
|
||||
batchId: result.batchId,
|
||||
timezone: result.timezone,
|
||||
jwtCount: result.jwtCount,
|
||||
firstDay: result.firstDay,
|
||||
lastDay: result.lastDay,
|
||||
status: result.status,
|
||||
});
|
||||
} else {
|
||||
logNotification(
|
||||
`AlertSearch authorization upload failed: ${result.errorMessage}`,
|
||||
{
|
||||
...(result.status != null ? { status: result.status } : {}),
|
||||
...(result.errorCode ? { errorCode: result.errorCode } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async triggerBackendRefresh(): Promise<void> {
|
||||
await refreshNotificationsWithDiagnostics({ source: "debug panel" });
|
||||
},
|
||||
|
||||
/** Local simulation: same API call as a WAKEUP_PING handler (no push payload). */
|
||||
async simulateWakeupViaRefresh(): Promise<void> {
|
||||
logNotification("WAKEUP_PING simulation (local refresh API only)");
|
||||
await refreshNotificationsWithDiagnostics({
|
||||
source: "WAKEUP_PING simulation",
|
||||
});
|
||||
},
|
||||
|
||||
/** Full pipeline: backend `/debug/send-wakeup` → FCM → native WAKEUP_PING handler. */
|
||||
async sendRealWakeupPing(): Promise<SendRealWakeupPingResult> {
|
||||
logNotification("Real WAKEUP_PING requested");
|
||||
|
||||
const fcmToken = getLastKnownFcmToken()?.trim() ?? "";
|
||||
if (!fcmToken) {
|
||||
const errorMessage = "no FCM token (register first)";
|
||||
logNotification(`Real WAKEUP_PING failed: ${errorMessage}`);
|
||||
return { ok: false, errorMessage };
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await getNotificationApiHeaders();
|
||||
if (!auth.ok) {
|
||||
logNotification(`Real WAKEUP_PING failed: ${auth.message}`);
|
||||
return { ok: false, errorMessage: auth.message };
|
||||
}
|
||||
|
||||
const deviceId = await getOrCreateDeviceId();
|
||||
const baseUrl = getNotificationApiBaseUrl();
|
||||
const res = await fetch(`${baseUrl}/debug/send-wakeup`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify({
|
||||
deviceId,
|
||||
fcmToken,
|
||||
platform: Capacitor.getPlatform(),
|
||||
testMode: getTestMode(),
|
||||
}),
|
||||
});
|
||||
|
||||
let responseBody: unknown;
|
||||
try {
|
||||
responseBody = await res.json();
|
||||
} catch {
|
||||
responseBody = undefined;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = wakeupPingFailureMessage(res.status, responseBody);
|
||||
logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, {
|
||||
status: res.status,
|
||||
token: truncateFcmTokenForLog(fcmToken),
|
||||
...wakeupPingResponseDetail(responseBody),
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage,
|
||||
status: res.status,
|
||||
responseBody,
|
||||
};
|
||||
}
|
||||
|
||||
logNotification("Real WAKEUP_PING success", {
|
||||
token: truncateFcmTokenForLog(fcmToken),
|
||||
deviceId,
|
||||
...wakeupPingResponseDetail(responseBody),
|
||||
});
|
||||
return { ok: true, responseBody };
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, {
|
||||
token: truncateFcmTokenForLog(fcmToken),
|
||||
});
|
||||
logger.warn(`${LOG} sendRealWakeupPing failed`, err);
|
||||
return { ok: false, errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
generateMockNotifications(
|
||||
intervalMs: number = 60_000,
|
||||
): NotificationRefreshPayload {
|
||||
const now = Date.now();
|
||||
const future1 = now + intervalMs;
|
||||
const future2 = now + intervalMs * 2;
|
||||
|
||||
return {
|
||||
shouldNotify: true,
|
||||
nextNotifications: [{ timestamp: future1 }, { timestamp: future2 }],
|
||||
};
|
||||
},
|
||||
|
||||
async triggerMockRefresh(intervalMs?: number): Promise<void> {
|
||||
logNotification("Mock refresh requested");
|
||||
|
||||
const payload = this.generateMockNotifications(intervalMs);
|
||||
const timestamps = payload.nextNotifications?.map((n) => n.timestamp) ?? [];
|
||||
logNotification(`Mock payload generated (${timestamps.length} timestamps)`);
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
logNotification("Mock refresh skipped: not running on native platform");
|
||||
return;
|
||||
}
|
||||
|
||||
await applyNotificationRefreshPayload(payload);
|
||||
logNotification("Mock refresh applied");
|
||||
},
|
||||
|
||||
async simulateWakeupPing(): Promise<void> {
|
||||
logNotification("Simulating WAKEUP_PING (production push handler)");
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
logNotification("WAKEUP_PING simulation skipped: not native platform");
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = {
|
||||
title: "WAKEUP_PING",
|
||||
body: "",
|
||||
id: "dev_wakeup_ping",
|
||||
data: { type: "WAKEUP_PING" },
|
||||
} as unknown as PushNotificationSchema;
|
||||
|
||||
await handleCapacitorPushNotificationReceived(notification);
|
||||
},
|
||||
|
||||
async runFloodTest(intervalMs?: number): Promise<void> {
|
||||
logNotification("Flood test started (20 sequential refreshes)");
|
||||
for (let i = 0; i < 20; i++) {
|
||||
logNotification(`Flood iteration ${i + 1}/20`);
|
||||
await this.triggerMockRefresh(intervalMs);
|
||||
}
|
||||
logNotification("Flood test completed");
|
||||
},
|
||||
|
||||
async clearNotifications(): Promise<void> {
|
||||
logNotification("Clear notifications (debug panel)");
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
logNotification("Clear skipped: not running on native platform");
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = DailyNotification as unknown as {
|
||||
clearAllNotifications?: () => Promise<void>;
|
||||
cancelAllNotifications?: () => Promise<void>;
|
||||
};
|
||||
|
||||
if (typeof plugin.clearAllNotifications === "function") {
|
||||
logNotificationClearing("clearAllNotifications");
|
||||
await plugin.clearAllNotifications();
|
||||
} else if (typeof plugin.cancelAllNotifications === "function") {
|
||||
logNotificationClearing("cancelAllNotifications");
|
||||
await plugin.cancelAllNotifications();
|
||||
} else {
|
||||
logNotification("Clear not available (plugin method missing)");
|
||||
return;
|
||||
}
|
||||
|
||||
logNotification("Notifications cleared");
|
||||
},
|
||||
|
||||
async getPendingNotifications(): Promise<PendingNotificationsResult> {
|
||||
logNotification("Fetching pending notifications");
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
logNotification("Pending fetch skipped: not running on native platform");
|
||||
return { pending: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await NotificationInspector.getPendingNotifications();
|
||||
const items = (res?.pending ?? []) as PendingNotificationInfo[];
|
||||
logNotification(`Pending fetched (${items.length})`);
|
||||
return { pending: items };
|
||||
} catch (e: unknown) {
|
||||
if (isUnimplementedError(e)) {
|
||||
return {
|
||||
pending: [],
|
||||
inspectorUnavailableMessage:
|
||||
"Pending notification inspection is currently supported on iOS only.",
|
||||
};
|
||||
}
|
||||
logNotification("Pending fetch failed");
|
||||
logger.warn(`${LOG} getPendingNotifications failed`, e);
|
||||
return { pending: [] };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -14,9 +14,68 @@
|
||||
*/
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { getOrCreateDeviceId } from "./deviceId";
|
||||
import {
|
||||
getNotificationApiBaseUrl,
|
||||
getTestMode,
|
||||
} from "./NotificationDebugConfig";
|
||||
import {
|
||||
getNotificationApiHeaders,
|
||||
httpAuthErrorMessage,
|
||||
logNotificationAuthFailure,
|
||||
} from "./notificationApiAuth";
|
||||
import {
|
||||
logTokenRegistrationFailure,
|
||||
logTokenRegistrationStarted,
|
||||
logTokenRegistrationSuccess,
|
||||
} from "./notificationLog";
|
||||
import { NativeNotificationService } from "./NativeNotificationService";
|
||||
import { WebPushNotificationService } from "./WebPushNotificationService";
|
||||
|
||||
/**
|
||||
* Registers an FCM device token with the app backend (native Capacitor token or web getToken).
|
||||
*/
|
||||
export async function registerToken(fcmToken: string): Promise<void> {
|
||||
logTokenRegistrationStarted(fcmToken);
|
||||
const deviceId = await getOrCreateDeviceId();
|
||||
const baseUrl = getNotificationApiBaseUrl();
|
||||
try {
|
||||
const auth = await getNotificationApiHeaders("register");
|
||||
if (!auth.ok) {
|
||||
logNotificationAuthFailure("register", auth.message);
|
||||
throw new Error(`registerToken auth unavailable: ${auth.message}`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/notifications/register`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify({
|
||||
deviceId,
|
||||
fcmToken,
|
||||
platform: Capacitor.getPlatform(),
|
||||
testMode: getTestMode(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const authDetail =
|
||||
res.status === 401 || res.status === 403
|
||||
? httpAuthErrorMessage(res.status)
|
||||
: `HTTP ${res.status}`;
|
||||
logger.warn("[NotificationService] registerToken failed", {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
authDetail,
|
||||
});
|
||||
throw new Error(`registerToken failed: ${authDetail}`);
|
||||
}
|
||||
logTokenRegistrationSuccess(fcmToken);
|
||||
} catch (err) {
|
||||
logTokenRegistrationFailure(fcmToken, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for scheduling a daily notification
|
||||
*/
|
||||
|
||||
241
src/services/notifications/alertAuthorization.test.ts
Normal file
241
src/services/notifications/alertAuthorization.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
|
||||
import type { DelegatedNotificationJwtBatch } from "@/interfaces/delegatedNotificationJwt";
|
||||
import {
|
||||
type AlertAuthorizationDependencies,
|
||||
uploadAlertSearchAuthorization,
|
||||
} from "./alertAuthorization";
|
||||
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
|
||||
|
||||
jest.mock("@/libs/crypto/vc", () => ({
|
||||
ETHR_DID_PREFIX: "did:ethr:",
|
||||
}));
|
||||
jest.mock("@/libs/delegatedNotificationJwt", () => ({
|
||||
mintDelegatedNotificationJwtBatch: jest.fn(),
|
||||
}));
|
||||
jest.mock("./NotificationDebugConfig", () => ({
|
||||
getNotificationApiBaseUrl: jest.fn(),
|
||||
}));
|
||||
jest.mock("./notificationApiAuth", () => ({
|
||||
getActiveNotificationDid: jest.fn(),
|
||||
getNotificationApiHeaders: jest.fn(),
|
||||
httpAuthErrorMessage: (status: number) => `HTTP ${status}`,
|
||||
}));
|
||||
jest.mock("./notificationApiDebugMode", () => ({
|
||||
shouldBypassNotificationAuth: jest.fn(),
|
||||
}));
|
||||
|
||||
const ACTIVE_DID = `did:ethr:0x${"0".repeat(40)}`;
|
||||
const TIME_ZONE = "America/Denver";
|
||||
const BATCH_ID = "batch-fixture";
|
||||
|
||||
function createMintedBatch(): DelegatedNotificationJwtBatch {
|
||||
return {
|
||||
did: ACTIVE_DID,
|
||||
timeZone: TIME_ZONE,
|
||||
mintedAtEpoch: 1_788_220_800,
|
||||
tokens: Array.from(
|
||||
{ length: DELEGATED_NOTIFICATION_JWT_COUNT },
|
||||
(_, index) => ({
|
||||
sequence: index + 1,
|
||||
utcDay: new Date(Date.UTC(2026, 8, index + 1))
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
nbf: 1_788_220_800 + index * 86_400,
|
||||
exp: 1_788_307_200 + index * 86_400,
|
||||
jwt: `delegated-token-${index + 1}`,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function createResponse(
|
||||
status: number,
|
||||
body: Record<string, unknown>,
|
||||
): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: jest.fn().mockResolvedValue(body),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function createDependencies(overrides?: {
|
||||
did?: string | null;
|
||||
bypassAuth?: boolean;
|
||||
response?: Response;
|
||||
}): {
|
||||
dependencies: AlertAuthorizationDependencies;
|
||||
fetchMock: jest.Mock;
|
||||
mintMock: jest.Mock;
|
||||
authMock: jest.Mock;
|
||||
} {
|
||||
const fetchMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue(overrides?.response ?? createResponse(200, {}));
|
||||
const mintMock = jest.fn().mockResolvedValue(createMintedBatch());
|
||||
const authMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
authenticated: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer access-token-fixture",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
dependencies: {
|
||||
getActiveDid: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
overrides && "did" in overrides ? overrides.did : ACTIVE_DID,
|
||||
),
|
||||
isAuthBypassEnabled: jest
|
||||
.fn()
|
||||
.mockReturnValue(overrides?.bypassAuth ?? false),
|
||||
mintBatch: mintMock,
|
||||
getAuthHeaders: authMock,
|
||||
getBaseUrl: () => "https://notification-backend.invalid",
|
||||
createBatchId: () => BATCH_ID,
|
||||
fetch: fetchMock,
|
||||
},
|
||||
fetchMock,
|
||||
mintMock,
|
||||
authMock,
|
||||
};
|
||||
}
|
||||
|
||||
describe("uploadAlertSearchAuthorization", () => {
|
||||
it("uploads the existing 100-token batch with authenticated PUT semantics", async () => {
|
||||
const { dependencies, fetchMock, mintMock, authMock } =
|
||||
createDependencies();
|
||||
|
||||
const result = await uploadAlertSearchAuthorization(dependencies);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
batchId: BATCH_ID,
|
||||
timezone: TIME_ZONE,
|
||||
jwtCount: DELEGATED_NOTIFICATION_JWT_COUNT,
|
||||
});
|
||||
expect(mintMock).toHaveBeenCalledWith(ACTIVE_DID);
|
||||
expect(authMock).toHaveBeenCalledWith(undefined, ACTIVE_DID);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://notification-backend.invalid/notifications/alert-authorization",
|
||||
);
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(init.headers).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer access-token-fixture",
|
||||
});
|
||||
|
||||
const body = JSON.parse(String(init.body)) as {
|
||||
batchId: string;
|
||||
notifyHourUtc: number;
|
||||
notifyMinuteUtc: number;
|
||||
timezone: string;
|
||||
jwts: Array<{
|
||||
sequence: number;
|
||||
day: string;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
jwt: string;
|
||||
}>;
|
||||
};
|
||||
const expectedNotify = alertSearchNotifyTimeUtcFromLocalNineAm(TIME_ZONE);
|
||||
expect(body.batchId).toBe(BATCH_ID);
|
||||
expect(body.notifyHourUtc).toBe(expectedNotify.notifyHourUtc);
|
||||
expect(body.notifyMinuteUtc).toBe(expectedNotify.notifyMinuteUtc);
|
||||
expect(Number.isInteger(body.notifyHourUtc)).toBe(true);
|
||||
expect(Number.isInteger(body.notifyMinuteUtc)).toBe(true);
|
||||
expect(body.notifyHourUtc).toBeGreaterThanOrEqual(0);
|
||||
expect(body.notifyHourUtc).toBeLessThanOrEqual(23);
|
||||
expect(body.notifyMinuteUtc).toBeGreaterThanOrEqual(0);
|
||||
expect(body.notifyMinuteUtc).toBeLessThanOrEqual(59);
|
||||
expect(body.timezone).toBe(TIME_ZONE);
|
||||
expect(body.jwts).toHaveLength(DELEGATED_NOTIFICATION_JWT_COUNT);
|
||||
expect(body.jwts.map((entry) => entry.sequence)).toEqual(
|
||||
Array.from(
|
||||
{ length: DELEGATED_NOTIFICATION_JWT_COUNT },
|
||||
(_, index) => index + 1,
|
||||
),
|
||||
);
|
||||
expect(body.jwts.map((entry) => entry.jwt)).toEqual(
|
||||
createMintedBatch().tokens.map((slot) => slot.jwt),
|
||||
);
|
||||
expect(body.jwts[0]).toEqual({
|
||||
sequence: 1,
|
||||
day: createMintedBatch().tokens[0]!.utcDay,
|
||||
nbf: createMintedBatch().tokens[0]!.nbf,
|
||||
exp: createMintedBatch().tokens[0]!.exp,
|
||||
jwt: createMintedBatch().tokens[0]!.jwt,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not log delegated JWT contents", async () => {
|
||||
const logSpy = jest.spyOn(console, "log").mockImplementation();
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation();
|
||||
const errorSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
const { dependencies } = createDependencies();
|
||||
|
||||
await uploadAlertSearchAuthorization(dependencies);
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("rejects unsupported identities before minting or network submission", async () => {
|
||||
const { dependencies, fetchMock, mintMock, authMock } = createDependencies({
|
||||
did: "did:peer:unsupported",
|
||||
});
|
||||
|
||||
const result = await uploadAlertSearchAuthorization(dependencies);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errorMessage:
|
||||
"AlertSearch authorization requires an active did:ethr identity.",
|
||||
});
|
||||
expect(mintMock).not.toHaveBeenCalled();
|
||||
expect(authMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects notification auth bypass before minting or submission", async () => {
|
||||
const { dependencies, fetchMock, mintMock } = createDependencies({
|
||||
bypassAuth: true,
|
||||
});
|
||||
|
||||
const result = await uploadAlertSearchAuthorization(dependencies);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(mintMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces non-2xx responses using only safe response fields", async () => {
|
||||
const { dependencies } = createDependencies({
|
||||
response: createResponse(422, {
|
||||
code: "INVALID_BATCH",
|
||||
message: "Batch validation failed.",
|
||||
jwt: "must-not-be-returned",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await uploadAlertSearchAuthorization(dependencies);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
status: 422,
|
||||
errorCode: "INVALID_BATCH",
|
||||
errorMessage: "Batch validation failed.",
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("must-not-be-returned");
|
||||
});
|
||||
});
|
||||
226
src/services/notifications/alertAuthorization.ts
Normal file
226
src/services/notifications/alertAuthorization.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
|
||||
import type { DelegatedNotificationJwtBatch } from "@/interfaces/delegatedNotificationJwt";
|
||||
import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
|
||||
import { mintDelegatedNotificationJwtBatch } from "@/libs/delegatedNotificationJwt";
|
||||
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
|
||||
import { getNotificationApiBaseUrl } from "./NotificationDebugConfig";
|
||||
import {
|
||||
getActiveNotificationDid,
|
||||
getNotificationApiHeaders,
|
||||
httpAuthErrorMessage,
|
||||
} from "./notificationApiAuth";
|
||||
import { shouldBypassNotificationAuth } from "./notificationApiDebugMode";
|
||||
|
||||
export interface AlertAuthorizationRequestBody {
|
||||
batchId: string;
|
||||
notifyHourUtc: number;
|
||||
notifyMinuteUtc: number;
|
||||
timezone: string;
|
||||
jwts: Array<{
|
||||
sequence: number;
|
||||
day: string;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
jwt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type AlertAuthorizationUploadResult =
|
||||
| {
|
||||
ok: true;
|
||||
status: number;
|
||||
batchId: string;
|
||||
timezone: string;
|
||||
jwtCount: number;
|
||||
firstDay: string;
|
||||
lastDay: string;
|
||||
message?: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
errorMessage: string;
|
||||
status?: number;
|
||||
errorCode?: string;
|
||||
};
|
||||
|
||||
type FetchNotificationApi = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export interface AlertAuthorizationDependencies {
|
||||
getActiveDid: () => Promise<string | null>;
|
||||
isAuthBypassEnabled: () => boolean;
|
||||
mintBatch: (did: string) => Promise<DelegatedNotificationJwtBatch>;
|
||||
getAuthHeaders: typeof getNotificationApiHeaders;
|
||||
getBaseUrl: () => string;
|
||||
createBatchId: () => string;
|
||||
fetch: FetchNotificationApi;
|
||||
}
|
||||
|
||||
function createBatchId(): string {
|
||||
if (!globalThis.crypto?.randomUUID) {
|
||||
throw new Error("Secure batch ID generation is unavailable.");
|
||||
}
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
const defaultDependencies: AlertAuthorizationDependencies = {
|
||||
getActiveDid: getActiveNotificationDid,
|
||||
isAuthBypassEnabled: shouldBypassNotificationAuth,
|
||||
mintBatch: mintDelegatedNotificationJwtBatch,
|
||||
getAuthHeaders: getNotificationApiHeaders,
|
||||
getBaseUrl: getNotificationApiBaseUrl,
|
||||
createBatchId,
|
||||
fetch: globalThis.fetch.bind(globalThis),
|
||||
};
|
||||
|
||||
function buildRequestBody(
|
||||
batchId: string,
|
||||
batch: DelegatedNotificationJwtBatch,
|
||||
): AlertAuthorizationRequestBody {
|
||||
if (batch.tokens.length !== DELEGATED_NOTIFICATION_JWT_COUNT) {
|
||||
throw new Error(
|
||||
`Expected ${DELEGATED_NOTIFICATION_JWT_COUNT} delegated JWTs, received ${batch.tokens.length}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const notifyTime = alertSearchNotifyTimeUtcFromLocalNineAm(batch.timeZone);
|
||||
|
||||
return {
|
||||
batchId,
|
||||
notifyHourUtc: notifyTime.notifyHourUtc,
|
||||
notifyMinuteUtc: notifyTime.notifyMinuteUtc,
|
||||
timezone: batch.timeZone,
|
||||
jwts: batch.tokens.map((slot) => ({
|
||||
sequence: slot.sequence,
|
||||
day: slot.utcDay,
|
||||
nbf: slot.nbf,
|
||||
exp: slot.exp,
|
||||
jwt: slot.jwt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function readSafeResponseDetail(
|
||||
response: Response,
|
||||
): Promise<{ message?: string; errorCode?: string }> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (typeof body !== "object" || body === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const record = body as Record<string, unknown>;
|
||||
const messageKeys = ["message", "reason", "error"] as const;
|
||||
const codeKeys = ["code", "errorCode"] as const;
|
||||
const message = messageKeys
|
||||
.map((key) => record[key])
|
||||
.find((value): value is string => typeof value === "string" && !!value);
|
||||
const errorCode = codeKeys
|
||||
.map((key) => record[key])
|
||||
.find((value): value is string => typeof value === "string" && !!value);
|
||||
|
||||
return {
|
||||
...(message ? { message: message.trim() } : {}),
|
||||
...(errorCode ? { errorCode: errorCode.trim() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint and manually upload a delegated AlertSearch authorization batch.
|
||||
* This function never retries and refuses notification auth bypass mode.
|
||||
*/
|
||||
export async function uploadAlertSearchAuthorization(
|
||||
dependencies: AlertAuthorizationDependencies = defaultDependencies,
|
||||
): Promise<AlertAuthorizationUploadResult> {
|
||||
try {
|
||||
const did = await dependencies.getActiveDid();
|
||||
if (!did) {
|
||||
return { ok: false, errorMessage: "No active identity is available." };
|
||||
}
|
||||
if (!did.startsWith(ETHR_DID_PREFIX)) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage:
|
||||
"AlertSearch authorization requires an active did:ethr identity.",
|
||||
};
|
||||
}
|
||||
if (dependencies.isAuthBypassEnabled()) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage:
|
||||
"AlertSearch authorization cannot be uploaded while JWT authentication is skipped.",
|
||||
};
|
||||
}
|
||||
|
||||
const batch = await dependencies.mintBatch(did);
|
||||
if (batch.did !== did) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage:
|
||||
"Delegated JWT batch identity does not match active identity.",
|
||||
};
|
||||
}
|
||||
const batchId = dependencies.createBatchId();
|
||||
const body = buildRequestBody(batchId, batch);
|
||||
|
||||
const auth = await dependencies.getAuthHeaders(undefined, did);
|
||||
if (!auth.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: `Authentication unavailable: ${auth.message}`,
|
||||
};
|
||||
}
|
||||
if (!auth.authenticated) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage:
|
||||
"AlertSearch authorization requires authenticated notification API headers.",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await dependencies.fetch(
|
||||
`${dependencies.getBaseUrl()}/notifications/alert-authorization`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const detail = await readSafeResponseDetail(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const fallback =
|
||||
response.status === 401 || response.status === 403
|
||||
? httpAuthErrorMessage(response.status)
|
||||
: `HTTP ${response.status}`;
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
errorMessage: detail.message || fallback,
|
||||
...(detail.errorCode ? { errorCode: detail.errorCode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
batchId,
|
||||
timezone: body.timezone,
|
||||
jwtCount: body.jwts.length,
|
||||
firstDay: body.jwts[0]!.day,
|
||||
lastDay: body.jwts[body.jwts.length - 1]!.day,
|
||||
...(detail.message ? { message: detail.message } : {}),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: error instanceof Error ? error.message : "Upload failed.",
|
||||
};
|
||||
}
|
||||
}
|
||||
25
src/services/notifications/alertSearchNotifyTime.test.ts
Normal file
25
src/services/notifications/alertSearchNotifyTime.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
|
||||
|
||||
describe("alertSearchNotifyTimeUtcFromLocalNineAm", () => {
|
||||
it("converts 09:00 Asia/Manila to 01:00 UTC", () => {
|
||||
const result = alertSearchNotifyTimeUtcFromLocalNineAm(
|
||||
"Asia/Manila",
|
||||
new Date("2026-09-10T16:00:00.000Z"),
|
||||
);
|
||||
expect(result).toEqual({ notifyHourUtc: 1, notifyMinuteUtc: 0 });
|
||||
});
|
||||
|
||||
it("returns integers in the UTC hour/minute ranges", () => {
|
||||
const result = alertSearchNotifyTimeUtcFromLocalNineAm(
|
||||
"America/Denver",
|
||||
new Date("2026-01-15T12:00:00.000Z"),
|
||||
);
|
||||
expect(Number.isInteger(result.notifyHourUtc)).toBe(true);
|
||||
expect(Number.isInteger(result.notifyMinuteUtc)).toBe(true);
|
||||
expect(result.notifyHourUtc).toBeGreaterThanOrEqual(0);
|
||||
expect(result.notifyHourUtc).toBeLessThanOrEqual(23);
|
||||
expect(result.notifyMinuteUtc).toBeGreaterThanOrEqual(0);
|
||||
expect(result.notifyMinuteUtc).toBeLessThanOrEqual(59);
|
||||
expect(result).toEqual({ notifyHourUtc: 16, notifyMinuteUtc: 0 });
|
||||
});
|
||||
});
|
||||
47
src/services/notifications/alertSearchNotifyTime.ts
Normal file
47
src/services/notifications/alertSearchNotifyTime.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Debug/E2E AlertSearch notify-time conversion only.
|
||||
* Not a user-facing setting; does not read Daily Reminder or New Activity times.
|
||||
*/
|
||||
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
/** Temporary default wall-clock time for AlertSearch authorization uploads. */
|
||||
export const ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_HOUR = 9;
|
||||
export const ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_MINUTE = 0;
|
||||
|
||||
function requireIanaTimeZone(timeZone: string): string {
|
||||
const probe = DateTime.now().setZone(timeZone);
|
||||
if (!timeZone || !probe.isValid) {
|
||||
throw new Error(
|
||||
"Invalid IANA timezone for AlertSearch notify time: " + timeZone,
|
||||
);
|
||||
}
|
||||
return timeZone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert 09:00 in `timeZone` (IANA) on the calendar day of `now` to UTC hour/minute.
|
||||
*/
|
||||
export function alertSearchNotifyTimeUtcFromLocalNineAm(
|
||||
timeZone: string,
|
||||
now: Date = new Date(),
|
||||
): { notifyHourUtc: number; notifyMinuteUtc: number } {
|
||||
const zone = requireIanaTimeZone(timeZone);
|
||||
const localNine = DateTime.fromJSDate(now, { zone }).set({
|
||||
hour: ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_HOUR,
|
||||
minute: ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_MINUTE,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
});
|
||||
if (!localNine.isValid) {
|
||||
throw new Error(
|
||||
"Could not convert AlertSearch 09:00 local notify time to UTC for zone " +
|
||||
zone,
|
||||
);
|
||||
}
|
||||
const utc = localNine.toUTC();
|
||||
return {
|
||||
notifyHourUtc: utc.hour,
|
||||
notifyMinuteUtc: utc.minute,
|
||||
};
|
||||
}
|
||||
38
src/services/notifications/deviceId.ts
Normal file
38
src/services/notifications/deviceId.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Preferences } from "@capacitor/preferences";
|
||||
|
||||
const DEVICE_ID_KEY = "stable_device_id";
|
||||
|
||||
function generateDeviceId(): string {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[DeviceId] crypto.randomUUID unavailable, using fallback generator",
|
||||
);
|
||||
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export async function getOrCreateDeviceId(): Promise<string> {
|
||||
const existing = await Preferences.get({ key: DEVICE_ID_KEY });
|
||||
|
||||
if (existing.value) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[DeviceId] Loaded existing deviceId");
|
||||
return existing.value;
|
||||
}
|
||||
|
||||
const newId = generateDeviceId();
|
||||
|
||||
await Preferences.set({
|
||||
key: DEVICE_ID_KEY,
|
||||
value: newId,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[DeviceId] Generated new deviceId");
|
||||
|
||||
return newId;
|
||||
}
|
||||
314
src/services/notifications/firebaseMessagingClient.ts
Normal file
314
src/services/notifications/firebaseMessagingClient.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Firebase Cloud Messaging (JS SDK) + Capacitor Push Notifications (native bridge).
|
||||
*
|
||||
* Initializes the Firebase web app when VITE_FIREBASE_* env vars are set, wires
|
||||
* Capacitor push listeners, requests permission before registration/token flow,
|
||||
* and attaches Firebase messaging when the browser/WebView reports support.
|
||||
*/
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { PushNotifications } from "@capacitor/push-notifications";
|
||||
import {
|
||||
type FirebaseApp,
|
||||
type FirebaseOptions,
|
||||
getApps,
|
||||
initializeApp,
|
||||
} from "firebase/app";
|
||||
import {
|
||||
getMessaging,
|
||||
getToken,
|
||||
isSupported,
|
||||
onMessage,
|
||||
} from "firebase/messaging";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { handleCapacitorPushNotificationReceived } from "./NativeNotificationService";
|
||||
import { getNotificationApiHeaders } from "./notificationApiAuth";
|
||||
import { deferFcmRegistration } from "./notificationAuthLifecycle";
|
||||
import { registerToken } from "./NotificationService";
|
||||
import {
|
||||
logPushNotificationActionPerformed,
|
||||
logPushNotificationReceived,
|
||||
logTokenRegistrationSkippedDuplicate,
|
||||
} from "./notificationLog";
|
||||
|
||||
const LOG = "[FirebaseMessaging]";
|
||||
|
||||
let firebaseAppSingleton: FirebaseApp | null = null;
|
||||
let nativeInitPromise: Promise<void> | null = null;
|
||||
/** Avoid duplicate POSTs when the same token is delivered more than once. */
|
||||
let lastRegisteredFcmToken: string | null = null;
|
||||
/** Last token received from Capacitor/Firebase (may match registered). */
|
||||
let lastSeenFcmToken: string | null = null;
|
||||
|
||||
async function registerRetrievedToken(
|
||||
token: string,
|
||||
options?: { force?: boolean },
|
||||
): Promise<void> {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
lastSeenFcmToken = trimmed;
|
||||
if (!options?.force && trimmed === lastRegisteredFcmToken) {
|
||||
logTokenRegistrationSkippedDuplicate(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = await getNotificationApiHeaders("register");
|
||||
if (!auth.ok) {
|
||||
if (options?.force) {
|
||||
throw new Error(`FCM registration auth unavailable: ${auth.message}`);
|
||||
}
|
||||
deferFcmRegistration(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
await registerToken(trimmed);
|
||||
lastRegisteredFcmToken = trimmed;
|
||||
}
|
||||
|
||||
/** Most recent FCM token from native/web push registration (for debug UI). */
|
||||
export function getLastKnownFcmToken(): string | null {
|
||||
return lastSeenFcmToken ?? lastRegisteredFcmToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-runs token registration immediately (debug). Bypasses duplicate-token skip.
|
||||
*/
|
||||
export async function reregisterFcmTokenNow(): Promise<string> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
throw new Error("FCM registration is only available on native platforms");
|
||||
}
|
||||
|
||||
lastRegisteredFcmToken = null;
|
||||
|
||||
const cached = lastSeenFcmToken?.trim();
|
||||
if (cached) {
|
||||
await registerRetrievedToken(cached, { force: true });
|
||||
return cached;
|
||||
}
|
||||
|
||||
const app = ensureFirebaseApp();
|
||||
if (app && (await isSupported())) {
|
||||
const messaging = getMessaging(app);
|
||||
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as
|
||||
| string
|
||||
| undefined;
|
||||
const token = await getToken(
|
||||
messaging,
|
||||
vapidKey ? { vapidKey } : undefined,
|
||||
);
|
||||
if (!token?.trim()) {
|
||||
throw new Error("Firebase getToken returned an empty token");
|
||||
}
|
||||
await registerRetrievedToken(token, { force: true });
|
||||
return token.trim();
|
||||
}
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timeoutMs = 15_000;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void listenerPromise.then((h) => h.remove());
|
||||
reject(new Error("Timed out waiting for push registration token"));
|
||||
}, timeoutMs);
|
||||
|
||||
const listenerPromise = PushNotifications.addListener(
|
||||
"registration",
|
||||
(token) => {
|
||||
window.clearTimeout(timeoutId);
|
||||
void listenerPromise.then((h) => h.remove());
|
||||
const value = token.value?.trim() ?? "";
|
||||
if (!value) {
|
||||
reject(new Error("Capacitor registration returned an empty token"));
|
||||
return;
|
||||
}
|
||||
void registerRetrievedToken(value, { force: true })
|
||||
.then(() => resolve(value))
|
||||
.catch(reject);
|
||||
},
|
||||
);
|
||||
|
||||
void PushNotifications.register().catch((err) => {
|
||||
window.clearTimeout(timeoutId);
|
||||
void listenerPromise.then((h) => h.remove());
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readFirebaseOptions(): FirebaseOptions | null {
|
||||
const env = import.meta.env;
|
||||
const apiKey = env.VITE_FIREBASE_API_KEY as string | undefined;
|
||||
const projectId = env.VITE_FIREBASE_PROJECT_ID as string | undefined;
|
||||
const appId = env.VITE_FIREBASE_APP_ID as string | undefined;
|
||||
const messagingSenderId = env.VITE_FIREBASE_MESSAGING_SENDER_ID as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
if (!apiKey || !projectId || !appId || !messagingSenderId) {
|
||||
logger.debug(
|
||||
`${LOG} Missing one or more VITE_FIREBASE_* keys; Firebase app not initialized`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const authDomain =
|
||||
(env.VITE_FIREBASE_AUTH_DOMAIN as string | undefined) ||
|
||||
`${projectId}.firebaseapp.com`;
|
||||
const storageBucket =
|
||||
(env.VITE_FIREBASE_STORAGE_BUCKET as string | undefined) ||
|
||||
`${projectId}.appspot.com`;
|
||||
|
||||
const opts: FirebaseOptions = {
|
||||
apiKey,
|
||||
authDomain,
|
||||
projectId,
|
||||
storageBucket,
|
||||
messagingSenderId,
|
||||
appId,
|
||||
};
|
||||
|
||||
const measurementId = env.VITE_FIREBASE_MEASUREMENT_ID as string | undefined;
|
||||
if (measurementId) {
|
||||
opts.measurementId = measurementId;
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a single Firebase app instance for the client when config is present.
|
||||
*/
|
||||
export function ensureFirebaseApp(): FirebaseApp | null {
|
||||
if (firebaseAppSingleton) {
|
||||
return firebaseAppSingleton;
|
||||
}
|
||||
const options = readFirebaseOptions();
|
||||
if (!options) {
|
||||
return null;
|
||||
}
|
||||
firebaseAppSingleton =
|
||||
getApps().length > 0 ? getApps()[0]! : initializeApp(options);
|
||||
logger.info(`${LOG} Firebase app initialized`);
|
||||
return firebaseAppSingleton;
|
||||
}
|
||||
|
||||
async function attachFirebaseMessagingIfSupported(
|
||||
app: FirebaseApp,
|
||||
): Promise<void> {
|
||||
if (!(await isSupported())) {
|
||||
logger.debug(
|
||||
`${LOG} firebase/messaging not supported in this context; skipping getMessaging`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const messaging = getMessaging(app);
|
||||
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
const token = await getToken(
|
||||
messaging,
|
||||
vapidKey ? { vapidKey } : undefined,
|
||||
);
|
||||
logger.info(`${LOG} Firebase getToken completed`, {
|
||||
tokenPrefix: token ? `${token.slice(0, 12)}…` : "(empty)",
|
||||
});
|
||||
await registerRetrievedToken(token);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`${LOG} Firebase getToken failed (common on native WebView without SW)`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
onMessage(messaging, (payload) => {
|
||||
logger.debug(`${LOG} onMessage (foreground)`, payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Native: register Capacitor push listeners, request permissions, register for push,
|
||||
* then initialize Firebase Messaging when env config and platform support allow.
|
||||
*/
|
||||
async function initializeNativePushAndFirebaseMessagingImpl(): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const app = ensureFirebaseApp();
|
||||
|
||||
await PushNotifications.addListener("registration", (token) => {
|
||||
if (token.value?.trim()) {
|
||||
lastSeenFcmToken = token.value.trim();
|
||||
}
|
||||
logger.info(`${LOG} Capacitor registration token`, {
|
||||
valuePrefix: token.value ? `${token.value.slice(0, 12)}…` : "(empty)",
|
||||
});
|
||||
void registerRetrievedToken(token.value).catch((err) => {
|
||||
logger.warn(
|
||||
`${LOG} registerToken after Capacitor registration failed`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await PushNotifications.addListener("registrationError", (err) => {
|
||||
logger.error(`${LOG} registrationError`, err);
|
||||
});
|
||||
|
||||
await PushNotifications.addListener(
|
||||
"pushNotificationReceived",
|
||||
(notification) => {
|
||||
logger.debug(`${LOG} pushNotificationReceived`, notification);
|
||||
logPushNotificationReceived(notification);
|
||||
void handleCapacitorPushNotificationReceived(notification).catch(
|
||||
(err) => {
|
||||
logger.warn(
|
||||
`${LOG} handleCapacitorPushNotificationReceived failed`,
|
||||
err,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await PushNotifications.addListener(
|
||||
"pushNotificationActionPerformed",
|
||||
(action) => {
|
||||
logger.debug(`${LOG} pushNotificationActionPerformed`, action);
|
||||
logPushNotificationActionPerformed(action);
|
||||
},
|
||||
);
|
||||
|
||||
const perm = await PushNotifications.requestPermissions();
|
||||
if (perm.receive !== "granted") {
|
||||
logger.warn(`${LOG} Push permission not granted`, perm);
|
||||
return;
|
||||
}
|
||||
|
||||
await PushNotifications.register();
|
||||
|
||||
if (app) {
|
||||
await attachFirebaseMessagingIfSupported(app);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`${LOG} Native push / Firebase messaging init failed`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent startup hook for Capacitor iOS/Android.
|
||||
*/
|
||||
export function initializeNativePushAndFirebaseMessaging(): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!nativeInitPromise) {
|
||||
nativeInitPromise = initializeNativePushAndFirebaseMessagingImpl();
|
||||
}
|
||||
return nativeInitPromise;
|
||||
}
|
||||
@@ -13,11 +13,47 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { NotificationService } from "./NotificationService";
|
||||
export {
|
||||
getBackendBaseUrl,
|
||||
getBypassAuth,
|
||||
getNotificationApiBaseUrl,
|
||||
getNotificationDebugOverrideHeaders,
|
||||
getTestMode,
|
||||
NGROK_SKIP_BROWSER_WARNING_HEADER,
|
||||
NGROK_SKIP_BROWSER_WARNING_VALUE,
|
||||
normalizeNotificationBackendUrl,
|
||||
setBackendBaseUrl,
|
||||
setBypassAuth,
|
||||
setTestMode,
|
||||
} from "./NotificationDebugConfig";
|
||||
export { shouldBypassNotificationAuth } from "./notificationApiDebugMode";
|
||||
export {
|
||||
appendLog,
|
||||
clearNotificationDebugLogs,
|
||||
getNotificationDebugLogEntries,
|
||||
logNotification,
|
||||
NOTIFICATION_LOG_PREFIX,
|
||||
subscribe,
|
||||
} from "./NotificationDebugEvents";
|
||||
export { NotificationService, registerToken } from "./NotificationService";
|
||||
export { NativeNotificationService } from "./NativeNotificationService";
|
||||
export { WebPushNotificationService } from "./WebPushNotificationService";
|
||||
export { uploadAlertSearchAuthorization } from "./alertAuthorization";
|
||||
export type {
|
||||
AlertAuthorizationRequestBody,
|
||||
AlertAuthorizationUploadResult,
|
||||
} from "./alertAuthorization";
|
||||
|
||||
export { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
|
||||
export {
|
||||
deferFcmRegistration,
|
||||
flushDeferredFcmRegistration,
|
||||
onNotificationAuthMayBeReady,
|
||||
} from "./notificationAuthLifecycle";
|
||||
export {
|
||||
ensureFirebaseApp,
|
||||
initializeNativePushAndFirebaseMessaging,
|
||||
} from "./firebaseMessagingClient";
|
||||
export { syncStarredPlansToNativePlugin } from "./syncStarredPlansToNativePlugin";
|
||||
export {
|
||||
buildDualScheduleConfig,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { mintBackgroundJwtTokenPool } from "@/libs/crypto";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||
import { onNotificationAuthMayBeReady } from "./notificationAuthLifecycle";
|
||||
|
||||
/**
|
||||
* Configure the native notification content fetcher with API credentials.
|
||||
@@ -90,6 +91,7 @@ export async function configureNativeFetcherIfReady(
|
||||
jwtTokens.length +
|
||||
")",
|
||||
);
|
||||
onNotificationAuthMayBeReady();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("[nativeFetcherConfig] configureNativeFetcher failed:", error);
|
||||
|
||||
170
src/services/notifications/notificationApiAuth.ts
Normal file
170
src/services/notifications/notificationApiAuth.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Authenticated headers for notification backend API calls (`/notifications/*`).
|
||||
* Uses the same `getHeaders` + active DID flow as the rest of the app.
|
||||
* Debug/local config can bypass auth for ngrok and panel testing.
|
||||
*/
|
||||
|
||||
import { getHeaders } from "@/libs/endorserServer";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { getNotificationDebugOverrideHeaders } from "./NotificationDebugConfig";
|
||||
import { shouldBypassNotificationAuth } from "./notificationApiDebugMode";
|
||||
import { logNotification } from "./NotificationDebugEvents";
|
||||
|
||||
export type NotificationRequestKind = "register" | "refresh";
|
||||
|
||||
export type NotificationApiHeadersResult =
|
||||
| {
|
||||
ok: true;
|
||||
authenticated: boolean;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: "no_active_did" | "missing_token" | "identity_changed";
|
||||
message: string;
|
||||
};
|
||||
|
||||
const DEBUG_HEADERS: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
export async function getActiveNotificationDid(): Promise<string | null> {
|
||||
try {
|
||||
const service = PlatformServiceFactory.getInstance();
|
||||
const row = await service.dbGetOneRow(
|
||||
"SELECT activeDid FROM active_identity WHERE id = 1",
|
||||
);
|
||||
if (!row?.[0]) {
|
||||
return null;
|
||||
}
|
||||
const did = String(row[0]).trim();
|
||||
return did || null;
|
||||
} catch (err) {
|
||||
logger.warn("[notificationApiAuth] Failed to read active DID", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasBearerToken(headers: {
|
||||
Authorization?: string;
|
||||
}): headers is { Authorization: string } {
|
||||
const auth = headers.Authorization;
|
||||
return (
|
||||
typeof auth === "string" &&
|
||||
auth.startsWith("Bearer ") &&
|
||||
auth.length > "Bearer ".length
|
||||
);
|
||||
}
|
||||
|
||||
function logAuthBypassEnabled(): void {
|
||||
logNotification("Auth bypass enabled for debug/testing");
|
||||
}
|
||||
|
||||
function logAuthenticatedNotificationRequest(): void {
|
||||
logNotification("Using authenticated notification request");
|
||||
}
|
||||
|
||||
function logDebugUnauthenticatedNotificationRequest(): void {
|
||||
logNotification("Using debug unauthenticated notification request");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve headers for notification API requests.
|
||||
* @param kind Optional request kind for structured logs.
|
||||
* @param expectedDid Reject if the active identity changed before token minting.
|
||||
*/
|
||||
export async function getNotificationApiHeaders(
|
||||
kind?: NotificationRequestKind,
|
||||
expectedDid?: string,
|
||||
): Promise<NotificationApiHeadersResult> {
|
||||
if (shouldBypassNotificationAuth()) {
|
||||
logAuthBypassEnabled();
|
||||
if (kind) {
|
||||
logDebugUnauthenticatedNotificationRequest();
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
authenticated: false,
|
||||
headers: {
|
||||
...DEBUG_HEADERS,
|
||||
...getNotificationDebugOverrideHeaders(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const did = await getActiveNotificationDid();
|
||||
if (!did) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "no_active_did",
|
||||
message: "no active identity (cannot authenticate)",
|
||||
};
|
||||
}
|
||||
if (expectedDid && did !== expectedDid) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "identity_changed",
|
||||
message: "active identity changed before authentication",
|
||||
};
|
||||
}
|
||||
|
||||
const headers = await getHeaders(did);
|
||||
if (!hasBearerToken(headers)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "missing_token",
|
||||
message: "missing or empty Authorization token",
|
||||
};
|
||||
}
|
||||
|
||||
if (kind) {
|
||||
logAuthenticatedNotificationRequest();
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
authenticated: true,
|
||||
headers: {
|
||||
"Content-Type": headers["Content-Type"],
|
||||
Authorization: headers.Authorization,
|
||||
...getNotificationDebugOverrideHeaders(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function logNotificationRequestAuthenticated(
|
||||
kind: NotificationRequestKind,
|
||||
): void {
|
||||
logNotification(
|
||||
kind === "register"
|
||||
? "Register request authenticated"
|
||||
: "Refresh request authenticated",
|
||||
);
|
||||
}
|
||||
|
||||
export function logNotificationAuthFailure(
|
||||
kind: NotificationRequestKind,
|
||||
message: string,
|
||||
): void {
|
||||
const verb = kind === "register" ? "Register" : "Refresh";
|
||||
logNotification(`${verb} auth unavailable: ${message}`);
|
||||
}
|
||||
|
||||
export function logWaitingForAuthBeforeRegistration(): void {
|
||||
logNotification("Waiting for auth before registration");
|
||||
}
|
||||
|
||||
export function logSkippingRefreshDueToMissingAuth(): void {
|
||||
logNotification("Skipping refresh due to missing auth");
|
||||
}
|
||||
|
||||
export function httpAuthErrorMessage(status: number): string {
|
||||
if (status === 401) {
|
||||
return "unauthorized (expired or invalid auth)";
|
||||
}
|
||||
if (status === 403) {
|
||||
return "forbidden (not authorized)";
|
||||
}
|
||||
return `HTTP ${status}`;
|
||||
}
|
||||
11
src/services/notifications/notificationApiDebugMode.ts
Normal file
11
src/services/notifications/notificationApiDebugMode.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Debug/local notification API auth bypass (ngrok, Notification Debug Panel).
|
||||
* Controlled explicitly via `notificationDebug.bypassAuth`; off by default.
|
||||
*/
|
||||
|
||||
import { getBypassAuth } from "./NotificationDebugConfig";
|
||||
|
||||
/** True when debug panel explicitly allows unauthenticated API calls. */
|
||||
export function shouldBypassNotificationAuth(): boolean {
|
||||
return getBypassAuth();
|
||||
}
|
||||
115
src/services/notifications/notificationAuthLifecycle.ts
Normal file
115
src/services/notifications/notificationAuthLifecycle.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Defers notification register/refresh until app auth (active DID + Bearer) is available.
|
||||
* Bounded retries avoid racing startup and prevent infinite loops.
|
||||
*/
|
||||
|
||||
import { logger } from "@/utils/logger";
|
||||
import {
|
||||
getNotificationApiHeaders,
|
||||
logWaitingForAuthBeforeRegistration,
|
||||
} from "./notificationApiAuth";
|
||||
import { registerToken } from "./NotificationService";
|
||||
|
||||
const MAX_REGISTER_RETRY_ATTEMPTS = 6;
|
||||
const REGISTER_RETRY_BASE_MS = 2_000;
|
||||
const REGISTER_RETRY_MAX_MS = 60_000;
|
||||
|
||||
let pendingFcmToken: string | null = null;
|
||||
let registerRetryAttempt = 0;
|
||||
let registerRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let registerFlushInFlight: Promise<void> | null = null;
|
||||
|
||||
function clearRegisterRetryTimer(): void {
|
||||
if (registerRetryTimer != null) {
|
||||
clearTimeout(registerRetryTimer);
|
||||
registerRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDeferredRegistrationRetry(): void {
|
||||
if (!pendingFcmToken || registerRetryTimer != null) {
|
||||
return;
|
||||
}
|
||||
if (registerRetryAttempt >= MAX_REGISTER_RETRY_ATTEMPTS) {
|
||||
logger.warn(
|
||||
"[notificationAuthLifecycle] Stopped retrying deferred FCM registration (max attempts)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
REGISTER_RETRY_BASE_MS * 2 ** registerRetryAttempt,
|
||||
REGISTER_RETRY_MAX_MS,
|
||||
);
|
||||
registerRetryAttempt += 1;
|
||||
registerRetryTimer = setTimeout(() => {
|
||||
registerRetryTimer = null;
|
||||
void flushDeferredFcmRegistration("scheduled retry");
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue FCM token registration until Bearer auth is available; retries with backoff.
|
||||
*/
|
||||
export function deferFcmRegistration(token: string): void {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
pendingFcmToken = trimmed;
|
||||
logWaitingForAuthBeforeRegistration();
|
||||
scheduleDeferredRegistrationRetry();
|
||||
}
|
||||
|
||||
/** Attempt pending FCM registration when auth may now be ready (identity, resume, fetcher config). */
|
||||
export async function flushDeferredFcmRegistration(
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
if (!pendingFcmToken) {
|
||||
return;
|
||||
}
|
||||
if (registerFlushInFlight) {
|
||||
return registerFlushInFlight;
|
||||
}
|
||||
|
||||
registerFlushInFlight = (async () => {
|
||||
const token = pendingFcmToken;
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = await getNotificationApiHeaders("register");
|
||||
if (!auth.ok) {
|
||||
scheduleDeferredRegistrationRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
clearRegisterRetryTimer();
|
||||
registerRetryAttempt = 0;
|
||||
pendingFcmToken = null;
|
||||
|
||||
try {
|
||||
await registerToken(token);
|
||||
} catch (err) {
|
||||
pendingFcmToken = token;
|
||||
logger.warn(
|
||||
`[notificationAuthLifecycle] Deferred FCM registration failed${reason ? ` (${reason})` : ""}`,
|
||||
err,
|
||||
);
|
||||
scheduleDeferredRegistrationRetry();
|
||||
}
|
||||
})().finally(() => {
|
||||
registerFlushInFlight = null;
|
||||
});
|
||||
|
||||
return registerFlushInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call when active identity or session may have become available (additive hooks only).
|
||||
*/
|
||||
export function onNotificationAuthMayBeReady(): void {
|
||||
registerRetryAttempt = 0;
|
||||
clearRegisterRetryTimer();
|
||||
void flushDeferredFcmRegistration("auth may be ready");
|
||||
}
|
||||
117
src/services/notifications/notificationLog.ts
Normal file
117
src/services/notifications/notificationLog.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Shared observability helpers for notification flows (console + debug panel).
|
||||
*/
|
||||
|
||||
import { logNotification } from "./NotificationDebugEvents";
|
||||
|
||||
export function truncateFcmTokenForLog(token: string): string {
|
||||
const t = token.trim();
|
||||
if (t.length <= 24) {
|
||||
return t;
|
||||
}
|
||||
return `${t.slice(0, 12)}…${t.slice(-8)}`;
|
||||
}
|
||||
|
||||
export function logPushNotificationReceived(notification: {
|
||||
title?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}): void {
|
||||
const type =
|
||||
typeof notification.data?.type === "string"
|
||||
? notification.data.type
|
||||
: "(none)";
|
||||
logNotification(`pushNotificationReceived type=${type}`, {
|
||||
title: notification.title,
|
||||
dataType: type,
|
||||
});
|
||||
if (type === "WAKEUP_PING") {
|
||||
logNotification("WAKEUP_PING received — will trigger refresh");
|
||||
}
|
||||
}
|
||||
|
||||
export function logPushNotificationActionPerformed(action: {
|
||||
actionId?: string;
|
||||
notification?: { title?: string; data?: Record<string, unknown> };
|
||||
}): void {
|
||||
const type =
|
||||
typeof action.notification?.data?.type === "string"
|
||||
? action.notification.data.type
|
||||
: "(none)";
|
||||
logNotification(
|
||||
`pushNotificationActionPerformed actionId=${action.actionId ?? "(none)"} type=${type}`,
|
||||
{
|
||||
actionId: action.actionId,
|
||||
dataType: type,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function logTokenRegistrationStarted(token: string): void {
|
||||
logNotification("Token registration started", {
|
||||
token: truncateFcmTokenForLog(token),
|
||||
});
|
||||
}
|
||||
|
||||
export function logTokenRegistrationSuccess(token: string): void {
|
||||
logNotification("Token registration success", {
|
||||
token: truncateFcmTokenForLog(token),
|
||||
});
|
||||
}
|
||||
|
||||
export function logTokenRegistrationSkippedDuplicate(token: string): void {
|
||||
logNotification("Token registration skipped (duplicate token)", {
|
||||
token: truncateFcmTokenForLog(token),
|
||||
});
|
||||
}
|
||||
|
||||
export function logTokenRegistrationFailure(
|
||||
token: string,
|
||||
error: unknown,
|
||||
): void {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logNotification(`Token registration failure: ${message}`, {
|
||||
token: truncateFcmTokenForLog(token),
|
||||
});
|
||||
}
|
||||
|
||||
export function logRefreshStarted(source?: string): void {
|
||||
logNotification(source ? `Refresh started (${source})` : "Refresh started");
|
||||
}
|
||||
|
||||
function elapsedMsSince(startedAt: number): number {
|
||||
return performance.now() - startedAt;
|
||||
}
|
||||
|
||||
export function logRefreshSuccess(
|
||||
startedAt: number,
|
||||
scheduledCount: number,
|
||||
source?: string,
|
||||
): void {
|
||||
const elapsedMs = Math.round(elapsedMsSince(startedAt));
|
||||
const message = source
|
||||
? `Refresh completed (${source}) in ${elapsedMs}ms (scheduled ${scheduledCount})`
|
||||
: `Refresh completed in ${elapsedMs}ms (scheduled ${scheduledCount})`;
|
||||
logNotification(message);
|
||||
}
|
||||
|
||||
export function logRefreshFailure(
|
||||
startedAt: number,
|
||||
errorMessage: string,
|
||||
status?: number,
|
||||
source?: string,
|
||||
): void {
|
||||
const statusPart = status != null ? ` HTTP ${status}` : "";
|
||||
const elapsedMs = Math.round(elapsedMsSince(startedAt));
|
||||
const message = source
|
||||
? `Refresh failed (${source}) in ${elapsedMs}ms: ${errorMessage}${statusPart}`
|
||||
: `Refresh failed in ${elapsedMs}ms: ${errorMessage}${statusPart}`;
|
||||
logNotification(message);
|
||||
}
|
||||
|
||||
export function logNotificationClearing(method: string): void {
|
||||
logNotification(`Clearing notifications via ${method}`);
|
||||
}
|
||||
|
||||
export function logScheduleReplacement(count: number): void {
|
||||
logNotification(`Schedule replacement: ${count} notification(s)`);
|
||||
}
|
||||
@@ -139,6 +139,12 @@ export abstract class BaseDatabaseService {
|
||||
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
|
||||
[did],
|
||||
);
|
||||
if (did?.trim()) {
|
||||
const { onNotificationAuthMayBeReady } = await import(
|
||||
"@/services/notifications/notificationAuthLifecycle"
|
||||
);
|
||||
onNotificationAuthMayBeReady();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -231,6 +231,12 @@ export const PlatformServiceMixin = {
|
||||
logger.debug(
|
||||
`[PlatformServiceMixin] ActiveDid updated in active_identity table: ${newDid}`,
|
||||
);
|
||||
if (newDid) {
|
||||
const { onNotificationAuthMayBeReady } = await import(
|
||||
"@/services/notifications/notificationAuthLifecycle"
|
||||
);
|
||||
onNotificationAuthMayBeReady();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[PlatformServiceMixin] Error updating activeDid in active_identity table ${newDid}:`,
|
||||
|
||||
6
src/utils/includeDevToolkitRoutes.ts
Normal file
6
src/utils/includeDevToolkitRoutes.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* True for `vite dev` and for `vite build` when mode is not `production`
|
||||
* (e.g. `--mode capacitor`, `--mode test`). Use for dev-only routes and UI.
|
||||
*/
|
||||
export const includeDevToolkitRoutes =
|
||||
import.meta.env.DEV || import.meta.env.MODE !== "production";
|
||||
@@ -742,6 +742,15 @@
|
||||
>
|
||||
Logs
|
||||
</router-link>
|
||||
<!-- Non-production bundles only; route `dev-notifications` must exist
|
||||
(see `includeDevToolkitRoutes`). -->
|
||||
<router-link
|
||||
v-if="isDev"
|
||||
:to="{ name: 'dev-notifications' }"
|
||||
class="block w-fit text-center 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 mt-2"
|
||||
>
|
||||
Notification Debug Panel
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'test' }"
|
||||
class="block w-fit text-center 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 mt-2"
|
||||
@@ -826,6 +835,7 @@ import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
||||
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
||||
import { includeDevToolkitRoutes } from "@/utils/includeDevToolkitRoutes";
|
||||
import { AccountSettings, isApiError } from "@/interfaces/accountView";
|
||||
import {
|
||||
NotificationService,
|
||||
@@ -888,6 +898,7 @@ export default class AccountViewView extends Vue {
|
||||
readonly DEFAULT_IMAGE_API_SERVER: string = DEFAULT_IMAGE_API_SERVER;
|
||||
readonly DEFAULT_PARTNER_API_SERVER: string = DEFAULT_PARTNER_API_SERVER;
|
||||
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
|
||||
readonly isDev: boolean = includeDevToolkitRoutes;
|
||||
|
||||
// Identity and settings properties
|
||||
activeDid: string = "";
|
||||
|
||||
38
src/views/dev/NotificationDebugView.vue
Normal file
38
src/views/dev/NotificationDebugView.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<main class="p-6 pb-24 max-w-3xl mx-auto" role="main">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold leading-none">Notification Debug</h1>
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="ms-auto text-sm text-blue-600"
|
||||
>
|
||||
Back to Account
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isDev"
|
||||
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border rounded-md overflow-hidden px-4 py-3"
|
||||
role="alert"
|
||||
>
|
||||
This screen is hidden in production Vite builds (for example when built
|
||||
with
|
||||
<span class="font-mono text-sm">--mode production</span>).
|
||||
</div>
|
||||
|
||||
<NotificationDebugPanel v-else />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import NotificationDebugPanel from "@/components/dev/NotificationDebugPanel.vue";
|
||||
import { includeDevToolkitRoutes } from "@/utils/includeDevToolkitRoutes";
|
||||
|
||||
@Component({
|
||||
components: { NotificationDebugPanel },
|
||||
})
|
||||
export default class NotificationDebugView extends Vue {
|
||||
readonly isDev: boolean = includeDevToolkitRoutes;
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user