Compare commits

..
Author SHA1 Message Date
Jose Olarte III c84cce54af Document the retired WAKEUP_PING refresh path as inactive and drop unused refresh auth helpers.
Guides and the debug panel still described /notifications/refresh and api_* scheduling as current after Phase 5; keep Phase 4 cleanup and FCM send-wakeup diagnostics.
2026-09-24 22:35:48 +08:00
Jose Olarte III 4f339eba32 Raise leftover iOS deployment targets from 14.0 to 15.5 so Xcode 27 can build.
Align the project, Share Extension, and CocoaPods `post_install` with the existing 15.5 minimum. Also refresh the CocoaPods embed-frameworks phase and package-lock peer metadata.
2026-09-24 17:17:08 +08:00
Jose Olarte III eb2240b901 Retire WAKEUP_PING refresh consumption and clear leftover api_* schedules on upgrade.
Stop the app from scheduling via the legacy refresh pipeline, and add a one-time Preferences-gated startup cleanup that calls clearApiNotifications() so upgraded installs shed persisted api_* residue without touching daily reminder, dual, AlertSearch, or FCM.
2026-09-22 20:59:23 +08:00
Jose Olarte III 724131d9a8 Retire app consumption of WAKEUP_PING → /notifications/refresh → api_* scheduling.
Phase 2 of legacy notification retirement: drop the production push refresh chain and dead debug/composable paths while keeping FCM registration, AlertSearch, daily reminder, and dual notifications intact.
2026-09-22 20:39:37 +08:00
trentlarson e77d08e3a8 Merge branch 'fix/shared-photo-deeplink' 2026-09-21 18:57:00 -06:00
trentlarson ef476bbb57 fix problem with project ID for "given to" project links, fix deep-link redirect for web, update browserslist DB 2026-09-21 07:55:08 -06:00
trentlarson 2bbf230312 adjust the URL for sharing a photo with the app (since it was going to the root "/" home route for sharing and that caused conflicts when we actually want to deep-link to the home page) 2026-09-20 21:35:52 -06:00
trentlarson 45503f568a fix more types and make more code consistent (mobile test seems to hang) 2026-09-20 16:10:36 -06:00
trentlarson e350feb132 add some other deep links for useful pages, and fix a UI test 2026-09-20 15:17:58 -06:00
trentlarson 7e3f2aa004 add deep-links for "" (base), new-activity, and QR-scan pages 2026-09-20 14:26:05 -06:00
Jose Olarte III de951543c7 Merge branch 'notify-api_endpoint-query' 2026-09-17 14:46:01 +08:00
Jose Olarte III 7604a92f60 Hide the in-app notification channel on Account unless running a development build. 2026-09-16 17:26:04 +08:00
trentlarson 4bf2fc1009 fix potential build problems (Claude recommendations) 2026-09-14 13:06:19 -06:00
trentlarson 97f4f59eff make the background-search JWTs in the pool all unique, removing problematic jti 2026-09-14 12:33:52 -06:00
trentlarson 6baf2a98ac reconcile & fix changes in FCM & SMS notifications 2026-09-14 10:23:06 -06:00
trentlarson 44695a51cc Merge branch 'notify-api_endpoint-query_merge' into notify-api_endpoint-query 2026-09-14 08:09:49 -06:00
trentlarson 044344026c Merge commit '0c400b87' into notify-api_endpoint-query 2026-09-13 18:47:53 -06:00
trentlarson fe9a2f0cbb Merge branch 'notify-api-sms' into notify-api_endpoint-query 2026-09-13 18:45:36 -06:00
trentlarson 240c3c5a76 Consolidate notify-api JWT minting; add wire types (WIP before SMS merge) 2026-09-13 17:08:23 -06:00
Jose Olarte III 4fcc9a40d0 Align AlertSearch authorization uploads with the wakeup-service contract: UTC-day delegated JWTs and required notifyHourUtc/notifyMinuteUtc. 2026-09-10 22:07:00 +08:00
Jose Olarte III 2afe748292 Send the ngrok skip-browser-warning header only when the Notification Debug Panel backend override is set.
WebView fetch to free ngrok was blocked by the interstitial (no CORS ACAO). Keep production notify-api requests unchanged.
2026-09-03 21:12:05 +08:00
Jose Olarte III 34a8c51d2f Add a manual debug-panel action to mint and PUT the 100-day AlertSearch authorization batch.
Reuse the existing delegated JWT minting and notification API auth so a signed-in ethr identity can upload the batch to the configured notify backend without changing production defaults or sending it automatically.
2026-09-02 15:34:03 +08:00
trentlarson 0c400b8797 fix spacing in a doc diagram 2026-08-30 11:07:36 -06:00
Jose Olarte III d5bcdbae3f Mint 100 per-local-day delegated notification JWTs for notify-api without touching the native background pool.
Each token is signed with the existing Endorser path and bounded by the user’s timezone midnight, so the next phase can submit the batch without changing prefetch JWTs or notification scheduling.
2026-08-26 16:05:51 +08:00
Jose Olarte III 46a2e0aaf5 Add typed alertSearch API contract and response models without implementing the daily search flow.
This prepares the app for endorser and partner alertSearch by capturing the known buckets, cursor ULID semantics, and JWT kinds, without changing notification scheduling or the background JWT pool.
2026-08-26 15:25:19 +08:00
jose 6b65ad8554 Merge pull request 'New Giftopia App Icons and Splashes' (#236) from giftopia-app-icon into master
Reviewed-on: #236
2026-07-29 12:08:44 +00:00
Jose Olarte III 03658340f8 chore(assets): update icon and splash colors for Giftopia branding 2026-07-28 18:45:56 +08:00
Jose Olarte III d5c357b291 Document dedicated Notification API URL configuration.
Describe VITE_DEFAULT_NOTIFY_API_SERVER / DEFAULT_NOTIFY_API_SERVER,
the debug-override resolution order, and replace outdated APP_SERVER
assumptions in build and notification testing docs.
2026-07-22 20:56:20 +08:00
Jose Olarte III 86611fe50d Update notification debug panel default URL hint.
Replace the outdated APP_SERVER placeholder with DEFAULT_NOTIFY_API_SERVER
so the UI matches centralized notify-api base URL resolution.
2026-07-22 18:02:34 +08:00
Jose Olarte III 4152012838 Point notification API base URL at DEFAULT_NOTIFY_API_SERVER.
Keep debug localStorage overrides first; fall back to the dedicated
notify-api default instead of APP_SERVER.
2026-07-22 17:45:10 +08:00
Jose Olarte III 25110e3eea Align DEFAULT_NOTIFY_API_SERVER with other backend service defaults.
Drop getDefaultNotifyApiServer and the Endorser-based fallback so notify
config uses the same env-or-prod pattern as image, partner, and push.
2026-07-22 17:39:48 +08:00
Jose Olarte III 0ecd4c6dd7 Add dedicated Notification API URL config for prod and test.
Introduce PROD/TEST notify-api constants, DEFAULT_NOTIFY_API_SERVER, and
VITE_DEFAULT_NOTIFY_API_SERVER in env files so notification traffic can
target notify-api hosts independently of APP_SERVER.
2026-07-22 17:35:37 +08:00
Jose Olarte III 821d3b7d05 fix(assets): point Android adaptive icons at hyphenated filenames 2026-07-20 18:14:31 +08:00
Jose Olarte III 8e6c83021f fix(assets): rename splash_dark.png to splash-dark.png
Match the filename @capacitor/assets expects so iOS uses the custom
dark splash instead of the logo-on-black fallback.
2026-07-17 20:04:08 +08:00
Jose Olarte III cfe90fd04e chore(ios): remove unused SplashDark.imageset leftover
Drop the obsolete SplashDark catalog; dark launch screens live as
appearance variants inside Splash.imageset.
2026-07-17 17:52:44 +08:00
Jose Olarte III fb9da10fd2 fix(ios): validate resources/ and warn if assets/ shadows it
Point iOS asset checks at resources/ and document that a leftover assets/
directory makes @capacitor/assets ignore the canonical sources.
2026-07-17 17:48:40 +08:00
Jose Olarte III 6d221ee1ca Install optional iOS Dark/Tinted app icons after capacitor-assets generate.
Post-process AppIcon.appiconset so appearance variants are reapplied on every iOS build without changing the existing asset generation flow.
2026-07-16 18:34:22 +08:00
Jose Olarte III 823db447ca style(assets): refresh Giftopia icons and splashes with adaptive layers 2026-07-15 20:53:54 +08:00
trentlarson 87ffa025e8 bump version to 1.4.4 build 70 2026-06-22 12:14:29 -06:00
trentlarson 15c9088736 change the android build such that Play Store isn't required (also removed android/google-services.json) 2026-06-21 18:01:04 -06:00
trentlarson ec41dd52d5 bump version to v 1.4.3 build 69 2026-06-21 10:59:24 -06:00
trentlarson 463db39a6b remove hard-coded daily android notification 2026-06-19 23:43:40 -06:00
jose fe97dff752 Merge pull request 'Rework Thanks Button' (#234) from thanks-button-rework into master
Reviewed-on: #234
2026-06-19 07:21:37 +00:00
Jose Olarte III 903047f13b style(gift): center entity selection step heading and entity type toggle 2026-06-18 21:09:32 +08:00
Jose Olarte III 48be234af4 fix(home): offset scrolled Thank button for safe-area-inset-bottom 2026-06-17 17:57:13 +08:00
trentlarson 6c0907d905 remove unused function & duplicate comment 2026-06-16 16:09:31 -06:00
Jose Olarte III 8d8bcf2a7e style(home): rework Thank button and sticky scroll action bar
Replace the floating circular plus FAB with a full-width bottom bar that
matches the inline Thank button. Wrap the quick-action section in a styled
container and raise the scroll threshold to 120px.
2026-06-16 21:48:34 +08:00
jose a4b47904c8 Merge pull request 'Add footer to Gifted Details view' (#233) from gifted-details-footer into master
Reviewed-on: #233
2026-06-16 08:27:47 +00:00
Jose Olarte III bb890baacf fix(gifted-details): add QuickNav footer navigation 2026-06-15 17:20:18 +08:00
trentlarson dae23300fe point to a single .entitlements file (undo most of previous commit) 2026-06-02 15:50:17 -06:00
trentlarson 9e401febea add 'share' to the entitlements for production, for sharing with this app 2026-06-02 15:46:36 -06:00
trentlarson cd4b279703 Merge pull request '16kb-pages' (#232) from 16kb-pages into master
Reviewed-on: #232
2026-05-25 20:01:18 +00:00
trentlarson a3a2d97b9a update version to v 1.4.2 build 68 2026-05-24 21:50:39 -06:00
trentlarson 802050259c update android build, fix ios build for new version of MLKit BarcodeScanner (both build) 2026-05-24 21:24:18 -06:00
trentlarson efd7d50a84 fix build error 2026-05-24 19:12:40 -06:00
trentlarson 39c389cda8 make do-not-pair group verbiage more clear 2026-05-24 18:38:56 -06:00
trentlarson 93fdcaf7ff fix timing error for a click (that only showed in firefox) 2026-05-24 18:29:11 -06:00
trentlarson ad419efa0d utilize 'userMessage' if sent by server 2026-05-24 16:23:47 -06:00
trentlarson ce45ddb2bd update after 'audit fix' 2026-05-24 16:23:09 -06:00
trentlarson 7d306bd204 add first cut for 16kb page sizes, all by Claude 2026-05-10 10:15:10 -06:00
trentlarson 9713313a40 fix HTML syntax warning 2026-05-10 09:43:46 -06:00
Jose Olarte III ffa7bac319 fix(ios): ensure capacitor-assets output dirs exist on fresh clones
Gitignored AppIcon.appiconset and Splash.imageset are absent after clone,
which made `capacitor-assets generate --ios` fail (missing paths and
Contents.json). Add ensure_ios_capacitor_asset_directories in common.sh
to mkdir and seed minimal Contents.json when needed; call it from
build-ios.sh before asset generation and from the build:native npm script.
Document the behavior in ios/.gitignore.
2026-04-13 16:20:51 +08:00
trentlarson e0e0a0a183 bump version and add -beta 2026-04-05 20:08:24 -06:00
trentlarson ea662f4430 bump to v 1.3.13 (for a web release) 2026-04-05 19:58:36 -06:00
trentlarson 81647e1f3c make terms & conditions into a separate page 2026-04-05 19:21:43 -06:00
trentlarson bf1ee78025 allow a custom error message to stay on the screen indefinitely 2026-03-29 19:11:49 -06:00
Jose Olarte III 66b7d0f46e docs(readme): expand Setup & Building quick start for all platforms
Restructure the quick start with Web, Android, and iOS subheadings; put
each npm command in its own code block; fold the test-page step into the
Web section. Document Android (build:android:test:run + ADB, link to
BUILDING.md) and iOS (build:ios:studio + Xcode prerequisites).
2026-03-26 19:41:03 +08:00
Jose Olarte III 63dcf44125 fix(ios): make build-ios.sh work on current simulators and trim xcodebuild noise
Use generic/platform=iOS Simulator instead of a fixed device name so CLI builds
do not fail when that simulator is not installed (e.g. newer Xcode runtimes).

Pass -quiet to xcodebuild and enable SWIFT_SUPPRESS_WARNINGS plus
GCC_WARN_INHIBIT_ALL_WARNINGS for scripted builds and IPA archive/export so
terminal output stays smaller; full diagnostics remain available in Xcode.
2026-03-26 19:40:07 +08:00
trentlarson cf1ecdfb4c add registration for new contacts that are unregistered 2026-03-22 20:20:33 -06:00
trentlarson e9ad61b780 don't delete a gift image on an edit unless they hit 'save' 2026-03-22 20:07:59 -06:00
trentlarson ad8df3eb93 fix problem where canceling an edit deletes an image 2026-03-22 20:06:58 -06:00
trentlarson 05d346edce add project selection for one that this 'fulfills' 2026-03-22 17:58:46 -06:00
trentlarson e259e60fa7 bump version and add "-beta" 2026-03-22 17:39:46 -06:00
trentlarson 821de3f006 do not toggle off the 'advanced' section in account view with the 'general' toggle is disabled 2026-03-22 09:53:56 -06:00
trentlarson 43f83031d4 rename app from "Gifties" to "Giftopia" 2026-03-21 16:27:21 -06:00
trentlarson 688a48a332 bump to version 1.3.12 build 67 2026-03-21 16:22:14 -06:00
trentlarson 8938c242ee change more files to name the app "Gifties" 2026-03-20 19:33:04 -06:00
trentlarson 358af42afd rename from "Gift Economies" to "Gifties" 2026-03-19 21:18:11 -06:00
trentlarson 59c00241b8 add the nearest-neighbor feature to the claim screen 2026-03-19 20:24:09 -06:00
trentlarson 33ec90e571 move the 'discover' page 'starred' word to be on the same level 2026-03-18 19:44:25 -06:00
178 changed files with 11921 additions and 7142 deletions
+2
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
18.19.0
20.18.1
+1 -1
View File
@@ -1 +1 @@
18.19.0
20.18.1
+20
View File
@@ -0,0 +1,20 @@
# Agent Instructions for crowd-funder-for-time-pwa
## Android Build — Google Play Services / FOSS Compatibility
**Firebase is opt-in. Do NOT enable it accidentally.**
`android/google-services.json` is gitignored and may be present on disk for push notification development, but Firebase is only activated when you explicitly pass `-PfirebaseEnabled` to Gradle:
- **FOSS / APK / Aurora / Zapstore / F-Droid builds**: just `./gradlew assembleRelease` — Firebase stays off even if `google-services.json` is on disk.
- **Firebase / FCM / Play Store builds**: `./gradlew bundleRelease -PfirebaseEnabled` — explicitly opt in.
This guard is in `android/app/build.gradle`. Do NOT change this conditional to activate Firebase unconditionally based on file presence alone — that was the bug that broke FOSS distribution in June 2026.
`google-services.json` is intentionally excluded from git (`android/.gitignore`). Never commit it.
Full details, incident history, and F-Droid notes: `doc/development/android-firebase-gms.md`
## Android Build — MLKit Barcode Scanner
`@capacitor-mlkit/barcode-scanning` depends on `com.google.android.gms:play-services-code-scanner`, which merges `com.google.android.gms.version` into the APK manifest. This is a known long-term issue for strict FOSS/F-Droid builds. For now, the dependency is accepted; barcode scanning simply will not work on GMS-less devices (it fails gracefully at scan time, not at startup). Do not add additional GMS/Firebase dependencies without explicitly acknowledging this trade-off.
+36 -22
View File
@@ -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
@@ -333,11 +334,11 @@ The `serve` functionality provides a local HTTP server for testing production bu
- If there are DB changes: before updating the test server, open browser(s) with
current version to test DB migrations.
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run:
`npm install`.
- Run a build to make sure package-lock version is updated, linting works, etc:
`npm install && npm run build:web`
- Run a build to make sure linting works, etc:
`npm run build:web`
- Commit everything (since the commit hash is used the app).
@@ -346,7 +347,7 @@ current version to test DB migrations.
- Tag with the new version,
[online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or
`git tag 1.0.2 && git push origin 1.0.2`.
`git tag 1.3.13 && git push origin 1.3.13`.
- For test, build the app:
@@ -1140,7 +1141,7 @@ export GEM_PATH=$shortened_path
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
```bash
cd ios/App && xcrun agvtool new-version 65 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.3.8;/g" App.xcodeproj/project.pbxproj && cd -
cd ios/App && xcrun agvtool new-version 70 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.4;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
@@ -1362,7 +1363,7 @@ npm run assets:validate
**Source Assets (Required):**
- `resources/icon.png` - App icon source
- `resources/splash.png` - Splash screen source
- `resources/splash_dark.png` - Dark mode splash source
- `resources/splash-dark.png` - Dark mode splash source
**Android Resources (Generated):**
- `android/app/src/main/res/drawable/splash.png` - Splash screen drawable
@@ -1421,8 +1422,8 @@ The recommended way to build for Android is using the automated build script:
##### 1. Bump the version in package.json, then update these versions & run:
```bash
perl -p -i -e 's/versionCode .*/versionCode 66/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.4.1"/g' android/app/build.gradle
perl -p -i -e 's/versionCode .*/versionCode 70/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.4.4"/g' android/app/build.gradle
```
##### 2. Build
@@ -1460,17 +1461,18 @@ cd -
- Setup by adding the app/gradle.properties.secrets file (see properties at top
of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
- In app/build.gradle, bump the versionCode and maybe the versionName
- Then `bundleRelease`:
```bash
cd android
./gradlew bundleRelease -Dlint.baselines.continue=true
./gradlew bundleRelease -Dlint.baselines.continue=true -PfirebaseEnabled
cd -
```
... and find your `aab` file at app/build/outputs/bundle/release
* Note that F-Droid builds should omit `-PfirebaseEnabled`.
At play.google.com/console:
- Go to Production or the Closed Testing and either Create Track or Manage Track.
@@ -1605,6 +1607,11 @@ Use the commands above to check and fix code quality issues.
4. **Native Build**: Platform-specific compilation
5. **Package Creation**: APK/IPA generation
`resources/` is the canonical source for app icons and splash screens.
Do not keep a legacy top-level `assets/` directory unless it is intentionally
used: `@capacitor/assets` prioritizes `assets/` over `resources/`, which can
prevent the canonical assets from being discovered.
## Architecture Environment Configuration
### Environment Files
@@ -1650,6 +1657,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
@@ -1669,6 +1677,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
```
@@ -1679,6 +1688,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
```
@@ -1689,6 +1699,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
```
@@ -1716,20 +1727,10 @@ VITE_APP_SERVER=https://timesafari.app
fi
```
2. **Platform-Specific Overrides**
2. **Environment File Loading**
```bash
# scripts/build-android.sh
if [ "$BUILD_MODE" = "development" ]; then
export VITE_DEFAULT_ENDORSER_API_SERVER="http://10.0.2.2:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://10.0.2.2:3000"
fi
```
3. **Environment File Loading**
```bash
# scripts/build-web.sh
# scripts/build-web.sh, build-android.sh, build-ios.sh, build-electron.sh
local env_file=".env.$BUILD_MODE" # .env.development, .env.test, .env.production
if [ -f "$env_file" ]; then
load_env_file "$env_file"
@@ -1741,6 +1742,18 @@ VITE_APP_SERVER=https://timesafari.app
fi
```
3. **Platform-Specific Overrides**
These run last so the platform address wins over the `.env` files.
```bash
# scripts/build-android.sh
if [ "$BUILD_MODE" = "development" ]; then
export VITE_DEFAULT_ENDORSER_API_SERVER="http://10.0.2.2:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://10.0.2.2:3000"
fi
```
4. **Application Usage**
```typescript
@@ -1946,6 +1959,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
+38 -2
View File
@@ -8,12 +8,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [?] - 2026
### Added
- Full flow for setting up SMS notifications
- Full flow for setting up SMS notifications: phone registration and code
verification, delegated alertSearch JWT batches with a send hour, and
revocation to stop the texts
- Notification debug panel action to mint and upload a push-channel alertSearch
authorization, built on the same batch minter as the SMS channel
### Fixed
- Native build scripts set NODE_ENV, so `import.meta.env.DEV` matches the build
mode instead of always reporting a production build
- Android and iOS development builds keep their emulator and `--api-ip` API
addresses, which the `.env` files had been overwriting
## [1.3.8] - 2026
## [1.4.4] - 2026.06.21
### Changed
- More checks for Firebase so that it won't break, eg in Aurora store.
## [1.4.3] - 2026.06.19
### Removed
- Automatic "Check your starred projects" daily notification
### Changed
- Positioning for 'Thank' button and entity-type toggle link
## [1.4.2] - 2026.05.24
### Changed
- Support 16 KB page sizes
## [1.3.13] - 2026.04.05
### Added
- Ability to select project that the current one fulfills
- Separate Terms & Conditions page (required for SMS campaigns)
### Fixed
- Edits to a 'give' would delete the image
## [1.3.12] - 2026.03.21
### Added
- Device wake-up for notifications
### Changed
- Rename to "Gifties"
## [1.3.7]
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+11 -2
View File
@@ -43,6 +43,15 @@ 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).
## Tests
```
npm run test:web
# ... or do every check:
npm run test:all
```
## 🛡️ Build Architecture Guard
This project uses **Husky Git hooks** to protect the build system
@@ -112,7 +121,7 @@ See [Logging Configuration Guide](doc/logging-configuration.md) for complete det
## Notification Debug Panel (dev builds)
In non-production bundles (for example `vite dev` or a Vite build whose mode is not `production`), the **Notification Debug Panel** at `/dev/notifications` helps you test notification registration, backend refresh, WAKEUP_PING handling, and local schedule inspection on native builds.
In non-production bundles (for example `vite dev` or a Vite build whose mode is not `production`), the **Notification Debug Panel** at `/dev/notifications` helps you test FCM token registration, AlertSearch authorization upload, FCM wakeup delivery (`/debug/send-wakeup`), and local schedule inspection on native builds. The app no longer consumes `WAKEUP_PING` to call `/notifications/refresh` or schedule `api_*` notifications.
**Access:** **Account** → enable **Show All General Advanced Functions** → **Notification Debug Panel**.
@@ -220,7 +229,7 @@ icon and splash screen generation across all platforms.
### Asset Sources
- **Single source of truth**: `resources/` directory (Capacitor default)
- **Source files**: `icon.png`, `splash.png`, `splash_dark.png`
- **Source files**: `icon.png`, `splash.png`, `splash-dark.png`
- **Format**: PNG or SVG files for optimal quality
### Asset Generation
+19 -9
View File
@@ -29,16 +29,16 @@ android {
compileSdk rootProject.ext.compileSdkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
defaultConfig {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 66
versionName "1.4.1"
versionCode 70
versionName "1.4.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -72,13 +72,14 @@ android {
}
packagingOptions {
jniLibs {
// Required for 16 KB page-size support: keep native libs uncompressed and
// page-aligned inside the APK (default on AGP 8.x with minSdk 23+, set
// explicitly so it does not regress).
useLegacyPackaging = false
pickFirsts += ['**/lib/x86_64/libbarhopper_v3.so', '**/lib/x86_64/libimage_processing_util_jni.so', '**/lib/x86_64/libsqlcipher.so']
}
}
// Configure for 16 KB page size compatibility
// Enable bundle builds (without which it doesn't work right for bundleDebug vs bundleRelease)
bundle {
language {
@@ -129,11 +130,20 @@ dependencies {
apply from: 'capacitor.build.gradle'
// Firebase / Google Play Services are opt-in. Pass -PfirebaseEnabled to any Gradle command
// to activate Firebase (FCM push notifications). Without this flag the build works on
// F-Droid, Aurora, Zapstore, and plain APK sideloading even when google-services.json
// is present on disk (it is gitignored; see AGENTS.md for the full story).
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
if (servicesJSON.exists() && servicesJSON.text && project.hasProperty('firebaseEnabled')) {
apply plugin: 'com.google.gms.google-services'
logger.info("Firebase enabled: google-services plugin applied")
} else if (servicesJSON.exists() && !project.hasProperty('firebaseEnabled')) {
logger.info("google-services.json present but firebaseEnabled not set — skipping Firebase plugin (pass -PfirebaseEnabled to enable)")
} else {
logger.info("google-services.json not found — Firebase plugin not applied")
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
logger.info("google-services plugin not applied: ${e.message}")
}
+2 -2
View File
@@ -2,8 +2,8 @@
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
@@ -1,6 +1,6 @@
{
"appId": "app.timesafari",
"appName": "TimeSafari",
"appName": "Giftopia",
"webDir": "dist",
"server": {
"cleartext": true
@@ -41,12 +41,12 @@
"iosIsEncryption": false,
"iosBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"androidIsEncryption": false,
"androidBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"electronIsEncryption": false
},
@@ -107,7 +107,7 @@
},
"buildOptions": {
"appId": "app.timesafari",
"productName": "TimeSafari",
"productName": "Giftopia",
"directories": {
"output": "dist-electron-packages"
},
@@ -30,6 +30,7 @@ public class MainActivity extends BridgeActivity {
private static final String KEY_BASE64 = "shared_image_base64";
private static final String KEY_FILE_NAME = "shared_image_file_name";
private static final String KEY_READY = "shared_image_ready";
private static final Uri SHARED_PHOTO_DEEP_LINK = Uri.parse("timesafari://shared-photo");
@Override
public void onCreate(Bundle savedInstanceState) {
@@ -121,7 +122,9 @@ public class MainActivity extends BridgeActivity {
}
if (imageUri != null) {
String fileName = intent.getStringExtra(Intent.EXTRA_TEXT);
processSharedImage(imageUri, fileName);
if (processSharedImage(imageUri, fileName)) {
notifySharedPhotoDeepLink();
}
handled = true;
}
}
@@ -138,7 +141,9 @@ public class MainActivity extends BridgeActivity {
imageUris = uris;
}
if (imageUris != null && !imageUris.isEmpty()) {
processSharedImage(imageUris.get(0), null);
if (processSharedImage(imageUris.get(0), null)) {
notifySharedPhotoDeepLink();
}
handled = true;
}
}
@@ -157,10 +162,12 @@ public class MainActivity extends BridgeActivity {
}
/**
* Process a shared image: read it, convert to base64, and write to temp file
* Process a shared image: read it, convert to base64, and write it to SharedPreferences
* Uses try-with-resources to ensure proper stream cleanup and prevent network issues
*
* @return true when the image is available to the SharedImage plugin
*/
private void processSharedImage(Uri imageUri, String fileName) {
private boolean processSharedImage(Uri imageUri, String fileName) {
// Extract filename from URI or use default (do this before opening streams)
String actualFileName = fileName;
if (actualFileName == null || actualFileName.isEmpty()) {
@@ -183,7 +190,7 @@ public class MainActivity extends BridgeActivity {
if (inputStream == null) {
Log.e(TAG, "Failed to open input stream for shared image");
return;
return false;
}
// Read image bytes
@@ -199,21 +206,36 @@ public class MainActivity extends BridgeActivity {
String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP);
// Store in SharedPreferences for plugin to read
storeSharedImageInPreferences(base64String, actualFileName);
if (!storeSharedImageInPreferences(base64String, actualFileName)) {
return false;
}
Log.d(TAG, "Successfully processed shared image: " + actualFileName);
return true;
} catch (IOException e) {
Log.e(TAG, "Error processing shared image", e);
return false;
} catch (Exception e) {
Log.e(TAG, "Unexpected error processing shared image", e);
return false;
}
}
/**
* Deliver an App-plugin URL event after the shared image is ready. The App plugin retains
* URL-open events until JavaScript registers its listener, covering both cold and warm starts.
*/
private void notifySharedPhotoDeepLink() {
Intent deepLinkIntent = new Intent(Intent.ACTION_VIEW, SHARED_PHOTO_DEEP_LINK);
getBridge().onNewIntent(deepLinkIntent);
Log.d(TAG, "Delivered shared-photo deep link to Capacitor");
}
/**
* Store shared image data in SharedPreferences for plugin to read
* Plugin will read and clear the data when called
*/
private void storeSharedImageInPreferences(String base64, String fileName) {
private boolean storeSharedImageInPreferences(String base64, String fileName) {
try {
SharedPreferences prefs = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
@@ -223,9 +245,11 @@ public class MainActivity extends BridgeActivity {
editor.apply();
Log.d(TAG, "Stored shared image data in SharedPreferences");
return true;
} catch (Exception e) {
Log.e(TAG, "Error storing shared image in SharedPreferences", e);
return false;
}
}
}
}
@@ -98,7 +98,17 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
: ""));
}
/** One pool entry per UTC day (epoch day mod pool size); else primary jwtToken. */
/**
* Picks the pool entry whose validity window covers today, falling back to the
* primary jwtToken when no pool is configured.
*
* <p>Each pooled JWT carries nbf/exp spanning exactly one UTC day, and the minter
* (mintBackgroundJwtTokenPool) files the token for a given day at index
* {@code epochDay % size}. That is why the index below is the raw epoch day rather
* than a count from when the pool arrived: this side keeps no mint date, and the
* same arithmetic on both ends is what lines the slot up with the day it covers.
* A token read from the wrong slot is outside its window and Endorser rejects it.
*/
private String selectBearerTokenForRequest() {
List<String> pool = jwtTokenPool;
if (pool == null || pool.isEmpty()) {
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">TimeSafari</string>
<string name="title_activity_main">TimeSafari</string>
<string name="app_name">Giftopia</string>
<string name="title_activity_main">Giftopia</string>
<string name="package_name">timesafari.app</string>
<string name="custom_url_scheme">timesafari.app</string>
</resources>
@@ -1,5 +1,5 @@
ext {
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1'
}
@@ -9,7 +9,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.1'
classpath 'com.android.tools.build:gradle:8.7.2'
}
}
@@ -17,10 +17,10 @@ apply plugin: 'com.android.library'
android {
namespace "capacitor.cordova.android.plugins"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
versionCode 1
versionName "1.0"
}
@@ -28,8 +28,8 @@ android {
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
@@ -1,6 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
ext {
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
// Plugin gradle extensions can append to this to have code run at the end.
cdvPluginPostBuildExtras = []
cordovaConfig = [:]
+7
View File
@@ -13,4 +13,11 @@ ext {
androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1'
// Pin CameraX to 1.4.2: first stable line shipping a 16 KB page-size-aligned
// libimage_processing_util_jni.so. The barcode-scanning plugin still defaults to 1.1.0.
androidxCameraCamera2Version = '1.4.2'
androidxCameraCoreVersion = '1.4.2'
androidxCameraLifecycleVersion = '1.4.2'
androidxCameraViewVersion = '1.4.2'
}
+4 -4
View File
@@ -2,9 +2,9 @@
"icon": {
"android": {
"adaptive": {
"background": "#121212",
"foreground": "resources/icon.png",
"monochrome": "resources/icon.png"
"background": "resources/android/icon/icon-background.png",
"foreground": "resources/android/icon/icon-foreground.png",
"monochrome": "resources/android/icon/icon-monochrome.png"
},
"target": "android/app/src/main/res"
},
@@ -22,7 +22,7 @@
"scale": "cover",
"target": "android/app/src/main/res"
},
"darkSource": "resources/splash_dark.png",
"darkSource": "resources/splash-dark.png",
"ios": {
"target": "ios/App/App/Assets.xcassets",
"useStoryBoard": true
+4 -4
View File
@@ -1,6 +1,6 @@
{
"appId": "app.timesafari",
"appName": "TimeSafari",
"appName": "Giftopia",
"webDir": "dist",
"server": {
"cleartext": true
@@ -34,12 +34,12 @@
"iosIsEncryption": false,
"iosBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"androidIsEncryption": false,
"androidBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"electronIsEncryption": false
}
@@ -73,7 +73,7 @@
},
"buildOptions": {
"appId": "app.timesafari",
"productName": "TimeSafari",
"productName": "Giftopia",
"directories": {
"output": "dist-electron-packages"
},
+4 -4
View File
@@ -2,7 +2,7 @@ import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'app.timesafari',
appName: 'TimeSafari',
appName: 'Giftopia',
webDir: 'dist',
server: {
cleartext: true
@@ -39,12 +39,12 @@ const config: CapacitorConfig = {
iosIsEncryption: false,
iosBiometric: {
biometricAuth: false,
biometricTitle: 'Biometric login for TimeSafari'
biometricTitle: 'Biometric login for Giftopia'
},
androidIsEncryption: false,
androidBiometric: {
biometricAuth: false,
biometricTitle: 'Biometric login for TimeSafari'
biometricTitle: 'Biometric login for Giftopia'
},
electronIsEncryption: false
},
@@ -103,7 +103,7 @@ const config: CapacitorConfig = {
},
buildOptions: {
appId: 'app.timesafari',
productName: 'TimeSafari',
productName: 'Giftopia',
directories: {
output: 'dist-electron-packages'
},
+1 -1
View File
@@ -22,7 +22,7 @@
"scale": "cover",
"target": "android/app/src/main/res"
},
"darkSource": "resources/splash_dark.png",
"darkSource": "resources/splash-dark.png",
"ios": {
"target": "ios/App/App/Assets.xcassets",
"useStoryBoard": true
+40
View File
@@ -125,6 +125,35 @@ view: "details"
All deep links follow the format: `timesafari://<route>/<param>?<query>`
### App Routes
These take no parameters.
- `timesafari://` — no route at all opens the app at the home feed.
- `timesafari://account`
- `timesafari://discover`
- Query params, all optional:
- `searchText`: prefills the search box
- `searchPeople`: any value switches to the people tab
- `hideOnboarding`: "true" suppresses the onboarding prompt
- `timesafari://invite-one`
- `timesafari://new-activity`
- `timesafari://onboard-meeting-list`
- `timesafari://projects`
- `timesafari://recent-offers-to-user`
- `timesafari://recent-offers-to-user-projects`
- `timesafari://search-area`
- `timesafari://share-my-contact-info`
- `timesafari://statistics`
### Help Routes
- `timesafari://help`
- `timesafari://help-notifications`
- `timesafari://help-notification-types`
- `timesafari://help-onboarding`
- `timesafari://help-terms`
### Claim Routes
- `timesafari://claim/:id`
@@ -143,6 +172,17 @@ All deep links follow the format: `timesafari://<route>/<param>?<query>`
- `timesafari://contact-import/:jwt`
- Query params:
- `contacts`: JSON array of contacts
- `timesafari://contact-qr` and `timesafari://contact-qr-scan-full`
Both open the page that displays your contact QR code, scans someone else's,
and prompts you to set your name if your identity does not carry one. The
destination view is chosen by platform, not by which of the two paths was
used: a full-screen camera view where a native scanner is available, an
ordinary page where the in-page web reader is used.
Prefer `contact-qr` when publishing a link. The same path is appended to the
web address by `/deep-link/<path>`, and `/contact-qr` is the web route that
serves this page in a browser.
### Project Routes
+3 -3
View File
@@ -47,7 +47,7 @@ npm run build:android:studio
#### Source Assets (Required)
- `resources/icon.png` - App icon source
- `resources/splash.png` - Splash screen source
- `resources/splash_dark.png` - Dark mode splash source
- `resources/splash-dark.png` - Dark mode splash source
#### Android Resources (Generated)
- `android/app/src/main/res/drawable/splash.png` - Splash screen drawable
@@ -201,13 +201,13 @@ mkdir -p android/app/src/main/res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}
# Copy source assets to assets directory
cp resources/icon.png assets/
cp resources/splash.png assets/
cp resources/splash_dark.png assets/
cp resources/splash-dark.png assets/
# Generate assets manually
npx @capacitor/assets generate
# Clean up
rm assets/icon.png assets/splash.png assets/splash_dark.png
rm assets/icon.png assets/splash.png assets/splash-dark.png
```
## Future Enhancements
+66
View File
@@ -0,0 +1,66 @@
# Android — Firebase, Google Play Services, and FOSS Distribution
## Overview
The app is designed to work on Android devices with and without Google Play Services (GMS). Firebase/FCM push notifications are an opt-in feature at build time; all other functionality works on GMS-less devices (F-Droid, LineageOS without OpenGApps, etc.).
## How the opt-in guard works
`android/app/build.gradle` applies the `com.google.gms.google-services` Gradle plugin only when **both** conditions are true:
1. `android/google-services.json` is present on disk
2. The Gradle property `firebaseEnabled` is explicitly passed
```groovy
if (servicesJSON.exists() && servicesJSON.text && project.hasProperty('firebaseEnabled')) {
apply plugin: 'com.google.gms.google-services'
}
```
This means the file can live on disk for development purposes without accidentally activating Firebase.
## Build commands
| Target | Command | Firebase |
|---|---|---|
| APK / sideload / Zapstore | `./gradlew assembleRelease` | off |
| Aurora / Play Store without FCM | `./gradlew bundleRelease` | off |
| Play Store with FCM push notifications | `./gradlew bundleRelease -PfirebaseEnabled` | on |
| F-Droid | `./gradlew assembleRelease` | off (required) |
## Behavior on non-GMS devices
When built with `-PfirebaseEnabled`, Firebase SDKs check for GMS availability at startup and degrade gracefully if it is absent:
- Firebase initializes but detects no GMS
- FCM skips token registration silently (no token, no notifications)
- The app continues to work normally
This means a single Play Store AAB (`bundleRelease -PfirebaseEnabled`) covers both GMS and non-GMS users. GMS users get push notifications; non-GMS users get a fully functional app without them.
**Aurora Store** pulls the exact APK from Play Store servers, so Aurora users get whichever variant was uploaded. The Play Store AAB built with `-PfirebaseEnabled` is correct for Aurora.
**F-Droid** is stricter: their build policy rejects any APK with GMS dependencies at the binary level, even with graceful degradation. F-Droid submission would require a separate `assembleRelease` build (no flag) and a dedicated F-Droid listing.
## The `google-services.json` file
- Gitignored (`android/.gitignore` line 80) — never commit it
- Contains Firebase project credentials (project number, app ID, API key)
- Safe to leave on disk; has no effect unless `-PfirebaseEnabled` is passed
- Obtain from the Firebase console: Project Settings → Your apps → Android app → Download `google-services.json`
## Known GMS dependency: MLKit barcode scanner
`@capacitor-mlkit/barcode-scanning` unconditionally depends on `com.google.android.gms:play-services-code-scanner` (present since the plugin was first added at v6.0.0). This merges `com.google.android.gms.version` and `GoogleApiActivity` into the APK manifest regardless of the `-PfirebaseEnabled` flag.
Practical impact:
- **GMS devices**: barcode scanning works normally
- **Non-GMS devices**: barcode scanning fails at scan time (not at startup); the app launches and runs normally otherwise
This is an accepted trade-off. Removing it would require either forking the plugin or introducing a `foss` product flavor that excludes the MLKit plugin entirely — work to undertake if/when F-Droid submission is planned.
## Incident: June 2026
Jose Olarte III's `notify-api` branch placed a production `google-services.json` in `android/` to test Firebase Cloud Messaging. The branch was never merged to `master`, but because the file is gitignored it persisted on disk after switching branches. At the time, the Gradle conditional activated Firebase based on file presence alone (no opt-in flag), so all subsequent local builds embedded Firebase and required Google Play Services. This silently broke APK/Aurora/Zapstore distribution.
**Fix applied:** deleted `google-services.json` from disk, changed the Gradle conditional to require `-PfirebaseEnabled`, and documented the rule in `AGENTS.md`.
+17 -6
View File
@@ -128,18 +128,29 @@ Your Android device and computer **must be on the same Wi-Fi network** for the d
### Step 3: Configure API Endpoints
Create or edit `.env.development` with your computer's IP:
Pass your computer's IP to the build with `--api-ip`. The build script points
the claim and partner APIs at that address:
```bash
# .env.development - for physical device testing
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_APP_SERVER=http://192.168.1.100:8080
npm run build:android:dev -- --api-ip 192.168.1.100
```
**Important**: Replace `192.168.1.100` with your actual IP address.
Without `--api-ip`, a development build uses `10.0.2.2:3000`, which reaches the
host machine from an emulator but not from a physical device.
The build script applies `--api-ip` after loading `.env.development`, so the
flag wins for the claim and partner APIs. Other addresses come from that file,
which development web builds share:
```bash
# .env.development
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
```
### Step 4: Start Your Local Server
If testing against local API servers, ensure they're accessible from the network:
+1 -1
View File
@@ -89,7 +89,7 @@ system to a standardized, single-source asset configuration approach using
resources/ # Image sources ONLY
icon.png
splash.png
splash_dark.png
splash-dark.png
config/assets/ # Versioned config & schema
capacitor-assets.config.json
+155
View File
@@ -0,0 +1,155 @@
# Background New Activity JWT pool
How the app credentials native background prefetch for New Activity.
## 1. What the pool is for
Background prefetch runs in WorkManager on Android and a background task on iOS,
with no JavaScript executing. It calls Endorser directly and needs a Bearer JWT
that was minted while the app was awake, possibly days earlier.
The app mints a pool of `BACKGROUND_JWT_POOL_SIZE` JWTs and hands them to the
plugin through `configureNativeFetcher`. Each covers one UTC day, and the native
fetcher picks the one matching the day it runs.
Source: `src/libs/crypto/backgroundJwtPool.ts`,
`src/services/notifications/nativeFetcherConfig.ts`,
`src/constants/backgroundJwt.ts`.
## 2. Token shape
Each token in the pool carries:
| Claim | Value |
|-------|-------|
| `iss` | the minting DID |
| `iat` | mint time |
| `nbf` | its day's opening midnight, minus `BACKGROUND_JWT_WINDOW_SLACK_SECONDS` |
| `exp` | its day's closing midnight, plus `BACKGROUND_JWT_WINDOW_SLACK_SECONDS` |
The slack widens the window at both ends for clock skew between the device and
Endorser. Widening is safe; narrowing can leave a prefetch inside the day with
no usable token.
Two properties follow from the day windows, and both are load-bearing:
- **Each token grants one day.** A token read from a log line or a captured
header buys one day of Endorser access rather than the whole grant.
- **The tokens are distinct.** ES256K signing is deterministic, so JWTs built
from identical payloads are byte-identical. Differing windows are what keep
the pool from collapsing into one string repeated `POOL_SIZE` times, which
would defeat any duplicate-token rule the server applies.
## 3. Slot ordering
Both native fetchers select with `pool[epochDay % pool.size()]` and hold no
record of when the pool was minted. The minter therefore files the token
covering a given UTC day at index `epochDay % BACKGROUND_JWT_POOL_SIZE`.
Any `POOL_SIZE` consecutive days hit every index exactly once, so the array is
dense whatever day minting starts on.
This is a contract across three languages. Changing the index arithmetic on one
side without the others produces tokens presented outside their windows, which
Endorser rejects with no local error. `src/test/backgroundJwtPool.test.ts`
asserts the invariant by replaying the native selector against the minted pool.
Implementations: `TimeSafariNativeFetcher.selectBearerTokenForRequest` in
`android/app/src/main/java/app/timesafari/` and `ios/App/App/`.
## 4. Identities that can mint
Seed-phrase (`did:ethr`) identities only.
Passkey (`did:peer`) identities raise
`BackgroundJwtUnsupportedIdentityError`. Each of their signatures is a WebAuthn
assertion, so minting a pool would raise one biometric prompt per token, and
`createJwtNavigator` overrides the day window with a one-minute `exp` — the
tokens would expire long before the prefetch they were minted for.
`configureNativeFetcherIfReady` catches the error and leaves prefetch
unconfigured.
The delegated alertSearch batch rejects the same identities, with the
notify-api answering `DELEGATED_JWT_UNSUPPORTED_IDENTITY`.
## 5. Lifecycle
| Event | Action |
|-------|--------|
| App foreground, startup, notification-time change | `configureNativeFetcherIfReady` mints a pool and configures the fetcher |
| Active identity changes (`$setActiveDid`) | `clearNativeFetcherPool` drops the pool the fetcher holds |
The identity is decrypted once per mint and reused for every signature.
Decrypting per token costs seconds on a phone, and minting runs on every
foreground.
## 6. What the pool bounds
The grant is `BACKGROUND_JWT_POOL_SIZE` days wide and each token inside it is
one day wide.
`clearNativeFetcherPool` is custody, not revocation. Endorser exposes no
revocation mechanism, so a token that left the device before the clear stays
valid until its window closes. What the clear bounds is the ordinary case — an
account switch, a sign-out, a shared or lost handset — where no copy was taken
and the device's own store is the only remaining exposure.
Anti-replay in the strict sense is unavailable on this path. It would require
either a server-side one-time-use store, which Endorser does not offer, or
per-request signing, which would put the private key in native code. Day-scoped
windows narrow the exposure instead of eliminating it.
## 7. Constants
All in `src/constants/backgroundJwt.ts`.
| Constant | Meaning |
|----------|---------|
| `BACKGROUND_JWT_POOL_SIZE` | Consecutive UTC days the pool covers, one token each. The whole forward grant a user authorizes per mint. |
| `BACKGROUND_JWT_WINDOW_SLACK_SECONDS` | Padding on each end of a day window, for clock skew. |
| `BACKGROUND_JWT_SECONDS_PER_DAY` | The day frame each slot is cut from. |
| `BACKGROUND_JWT_EXPIRY_DAYS` / `_SECONDS` | Lifetime for the single-token background path. |
Past the last covered day the pool carries no credential and prefetch stops
until the app opens again.
## 8. A different credential
The notify-api's delegated alertSearch batch
(`src/services/notifications/alertAuthorizationBatch.ts`) is a separate
credential with a separate inventory. It authorizes the notification service to
run a user's daily alertSearch server-side; this pool authorizes the user's own
device to prefetch. They share the day-window shape and nothing else. See
`notification-wakeup-service/README.md`.
## 9. Rejected
- **A unique `jti` per slot, with one shared long `exp`.** A `jti` is an
identifier, not a replay defense: it does nothing unless the server keeps a
seen-set, and Endorser's behavior here was never confirmed. Day windows make
the tokens distinct for the same cost while also bounding each one.
- **One long-lived token instead of a pool.** Fails if Endorser rejects
duplicate JWT strings across days. That policy question is open (§10), so the
design does not depend on the answer.
- **Sizing the pool as `expiryDays + buffer`.** The rationale was headroom for
duplicate-token rules. With one token per day, the pool size is the grant
length in days and needs no separate buffer term.
- **Per-request signing in native code (DPoP-style).** The only true anti-replay
option, rejected to keep one signing implementation in TypeScript rather than
forking crypto into Java and Swift.
- **Routing all New Activity through the notification service and deleting this
path.** Rejected: it would force every user onto server-side delegation, and
the service is single-replica, so the direct device-to-Endorser path has no
equivalent.
## 10. Open questions
- **Endorser duplicate-JWT policy.** Whether Endorser rejects a Bearer JWT
string it has already seen is unconfirmed. The pool is correct either way; the
answer would determine whether a pool is required at all.
- **Maximum `exp` Endorser accepts.** Day-scoped windows are well inside any
plausible limit, so this gates only the single-token path.
- **Plugin behavior on an empty pool.** `clearNativeFetcherPool` passes empty
credentials to `configureNativeFetcher`. A plugin build that rejects them
leaves the previous pool in place; the failure is logged rather than reported
as a successful clear.
+308
View File
@@ -0,0 +1,308 @@
# Deep Link Debugging Guide for TimeSafari
This guide helps you debug and fix deep link issues in the TimeSafari Capacitor application.
## Quick Start
1. **Check logs**: Use browser dev tools or device console to see detailed logging
2. **Test manually**: Use the testing script or browser console commands
3. **Verify configuration**: Ensure all platform configurations are correct
## Common Issues and Solutions
### Issue 1: App opens but doesn't navigate to the correct page
**Symptoms:**
- Deep link opens the app
- App stays on home screen or current page
- No error messages visible
**Debugging Steps:**
1. **Check console logs** in browser dev tools or device console:
```bash
# Android
adb logcat | grep -E "(TimeSafari|DeepLink|appUrlOpen)"
# iOS Simulator
xcrun simctl spawn booted log stream --predicate 'process == "TimeSafari"'
```
2. **Verify listener registration**:
Look for these log messages:
```
[DeepLink] Registering appUrlOpen listener...
[DeepLink] Listener registered successfully
```
3. **Check for event reception**:
Look for:
```
[DeepLink] ========== DEEP LINK EVENT RECEIVED ==========
[DeepLink] URL: timesafari://your/url/here
```
4. **Verify URL parsing**:
Check if URL components are parsed correctly:
```
[DeepLinkHandler.parseDeepLink] Parse result: {"path":"claim","params":{"id":"123"},"query":{}}
```
### Issue 2: Listener not receiving events
**Symptoms:**
- No deep link logs appear
- App opens but no event processing
**Solutions:**
1. **Rebuild and reinstall** the app completely:
```bash
npm run build:capacitor
npx cap sync
npx cap run android # or ios
```
2. **Check capacitor.config.json**:
```json
{
"plugins": {
"App": {
"appUrlOpen": {
"handlers": [
{
"url": "timesafari://*",
"autoVerify": true
}
]
}
}
}
}
```
3. **Verify native configuration**:
- **Android**: Check `android/app/src/main/AndroidManifest.xml`
- **iOS**: Check `ios/App/App/Info.plist`
### Issue 3: URL scheme not recognized by OS
**Symptoms:**
- "No app found to handle this link" error
- OS doesn't open your app
**Solutions:**
1. **Android**: Verify intent filter in AndroidManifest.xml:
```xml
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="timesafari" />
</intent-filter>
```
2. **iOS**: Verify URL types in Info.plist:
```xml
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>
```
## Testing Tools
### 1. Automated Testing Script
Use the provided testing script:
```bash
# Test all URLs on Android
./scripts/test-deep-links.sh android
# Test specific URL on iOS
./scripts/test-deep-links.sh ios "timesafari://claim/test123"
# Monitor logs
./scripts/test-deep-links.sh android-logs
```
### 2. Manual Testing Commands
**Android Emulator:**
```bash
adb shell am start -W -a android.intent.action.VIEW -d "timesafari://claim/test123" app.timesafari
```
**iOS Simulator:**
```bash
xcrun simctl openurl booted "timesafari://claim/test123"
```
### 3. Browser Console Testing
For web/PWA testing, use the browser console:
```javascript
// Test deep link processing directly
window.testSingleDeepLink("timesafari://claim/test123");
// Run all test URLs
window.testDeepLinks();
```
## Debugging Steps Checklist
### Pre-Testing Setup
- [ ] App is installed on device/emulator
- [ ] App has been launched at least once
- [ ] Device/emulator is properly connected
- [ ] Debugging tools are accessible
### During Testing
- [ ] Check console for initialization logs
- [ ] Verify listener registration
- [ ] Test with simple URL first (e.g., `timesafari://claim/test`)
- [ ] Monitor URL parsing logs
- [ ] Check router navigation logs
### Post-Testing Analysis
- [ ] Review complete log sequence
- [ ] Identify where process fails
- [ ] Check error messages for clues
- [ ] Test with different URL formats
## Common Log Patterns
### Successful Deep Link Flow
```
[DeepLink] Registering appUrlOpen listener...
[DeepLink] Listener registered successfully
[DeepLink] ========== DEEP LINK EVENT RECEIVED ==========
[DeepLink] URL: timesafari://claim/test123
[DeepLinkHandler] Starting handleDeepLink with URL: timesafari://claim/test123
[DeepLinkHandler.parseDeepLink] Parse result: {"path":"claim","params":{"id":"test123"},"query":{}}
[DeepLinkHandler.validateAndRoute] Route validation passed. Route name: claim
[DeepLinkHandler.validateAndRoute] Router navigation completed successfully
[DeepLink] Deep link handled successfully
```
### Failed URL Parsing
```
[DeepLinkHandler.parseDeepLink] Route not found: invalid-route
[DeepLinkHandler.parseDeepLink] Available routes: ["claim","project","contact-import",...]
[DeepLinkHandler.validateAndRoute] Redirecting to deep-link-error page
```
### Router Navigation Issues
```
[DeepLinkHandler.validateAndRoute] Error routing to route name claim
[DeepLinkHandler.validateAndRoute] Navigation params: {"name":"claim","params":{"id":"test123"}}
```
## Platform-Specific Issues
### Android
**Issue**: Deep links work in development but not in production build
- **Solution**: Ensure `android:exported="true"` in MainActivity
**Issue**: App doesn't respond to links when running in background
- **Solution**: Check `android:launchMode="singleTask"` in AndroidManifest.xml
### iOS
**Issue**: Deep links don't work in iOS simulator
- **Solution**: Use `xcrun simctl openurl` instead of opening URLs in Safari
**Issue**: App launches but doesn't process URL
- **Solution**: Check for Associated Domains if using universal links
## Advanced Debugging
### Enable Capacitor Native Logging
Add to `capacitor.config.json`:
```json
{
"ios": {
"loggingBehavior": "debug"
},
"android": {
"loggingBehavior": "debug"
}
}
```
### Add Custom Debug Points
Insert additional logging in your deep link handler:
```typescript
// Add at strategic points in DeepLinkHandler
console.log('[DEBUG] Custom checkpoint:', { data: yourData });
```
### Network Debugging
If deep links involve network requests:
```bash
# Monitor network traffic (Android)
adb shell dumpsys connectivity
# Monitor network traffic (iOS)
# Use Xcode Network Debugger
```
## Recovery Strategies
### If deep links stop working completely:
1. **Clean rebuild**:
```bash
rm -rf node_modules
npm install
npm run build:capacitor
npx cap sync
```
2. **Reset device/emulator**:
- Clear app data
- Uninstall and reinstall
- Restart emulator
3. **Verify basic functionality**:
- Test simple navigation within app
- Test URL schemes with minimal URLs
- Gradually increase complexity
## Support Resources
- [Capacitor Deep Links Documentation](https://capacitorjs.com/docs/guides/deep-links)
- [Android Intent Filter Guide](https://developer.android.com/guide/components/intents-filters)
- [iOS URL Scheme Guide](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app)
## Contact and Feedback
If you encounter issues not covered in this guide:
1. Check the project's issue tracker
2. Review recent commits for deep link changes
3. Test with minimal reproduction case
4. Document exact steps and environment details
+2 -2
View File
@@ -2,9 +2,9 @@
**Created:** 2026-06-02
**Source document:** [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md)
**Purpose:** Plan a future **Android** counterpart guide by mapping what can be reused from the iOS ngrok workflow and what must be written for Android-specific push, permissions, and OS behavior.
**Status:** Planning snapshot from 2026-06-02. Several iOS-guide assumptions (app **Refresh Notifications**, Simulate WAKEUP, `applyNotificationRefreshPayload`, WAKEUP_PING → `/notifications/refresh` → `api_*`) are **retired**. For current Android procedure see [local-android-testing-ngrok.md](./local-android-testing-ngrok.md). Do not treat the reuse tables below as the live app path.
**Status:** Planning only — does not replace or modify the iOS guide.
**Purpose:** Plan a future **Android** counterpart guide by mapping what can be reused from the iOS ngrok workflow and what must be written for Android-specific push, permissions, and OS behavior.
---
+100 -160
View File
@@ -1,8 +1,10 @@
# Local Android Testing with ngrok (notification-wakeup-service)
**Last updated:** 2026-06-02 (verification checklist, end-to-end test)
**Last updated:** 2026-09-24 (retired WAKEUP_PING → refresh → `api_*` consumption)
**Audience:** Developers on **crowd-funder-for-time-pwa**, **daily-notification-plugin**, and **notification-wakeup-service**
**Goal:** Exercise FCM wakeup (`WAKEUP_PING`), FCM token registration, and notification refresh against a Mac-hosted backend reachable from a physical Android device.
**Goal:** Exercise FCM token registration and FCM wakeup **delivery** (`WAKEUP_PING` via `/debug/send-wakeup`) against a Mac-hosted backend reachable from a physical Android device.
> **Retired (do not expect this in the app):** `WAKEUP_PING` → `POST /notifications/refresh` → `applyNotificationRefreshPayload()` → `api_*` local schedules. The app logs ignored push types and does not refresh or schedule from wakeup. `/debug/send-wakeup` remains an FCM diagnostic. Mac `curl` of `/notifications/refresh` only tests the backend. Local schedules come from Static Daily Reminder, New Activity / dual, and the native fetcher.
**See also:** [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md) for the iOS workflow (APNs, Xcode). [android-physical-device-guide.md](./android-physical-device-guide.md) for USB debugging and build/run commands.
@@ -10,28 +12,28 @@
## Architecture overview
End-to-end flow when testing New Activity / silent wake on a physical Android phone:
End-to-end flow when testing FCM registration and wakeup **delivery** on a physical Android phone:
```text
┌─────────────────────┐ HTTPS ┌──────────────────────┐
│ Mac (localhost) │ ◄───────────── │ ngrok edge │
│ notification- │ tunnel │ (public HTTPS URL) │
│ Mac (localhost) │ ◄───────────── │ ngrok edge │
│ notification- │ tunnel │ (public HTTPS URL) │
│ wakeup-service │ └──────────┬───────────┘
└──────────┬──────────┘ │
│ │ fetch
│ POST /notifications/refresh │ POST /notifications/register
│ ▼
│ │ fetch
│ │ POST /notifications/register
│ ▼
│ ┌──────────────────────┐
│ │ crowd-funder-for- │
│ │ time-pwa (Capacitor │
│ │ Android on device) │
│ │ crowd-funder-for- │
│ │ time-pwa (Capacitor │
│ │ Android on device) │
│ └──────────┬───────────┘
│ │
│ FCM data message (WAKEUP_PING) │ daily-notification-plugin
▼ ▼ (local schedule replace)
▼ ▼ (Daily Reminder / dual / fetcher)
┌─────────────────────┐ ┌──────────────────────┐
│ Firebase Cloud │ ──FCM────────► │ Android device │
│ Messaging │ direct │ app.timesafari.app │
│ Firebase Cloud │ ──FCM────────► │ Android device │
│ Messaging │ direct │ app.timesafari.app │
└─────────────────────┘ └──────────────────────┘
```
@@ -41,18 +43,16 @@ Unlike iOS, Android does **not** use APNs. FCM delivers directly to the app via
| Repo | Role |
|------|------|
| **notification-wakeup-service** | HTTP API: device registration, refresh payload (`nextNotifications`), health, debug wakeup send |
| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register` & `/refresh`, handles `WAKEUP_PING` push |
| **daily-notification-plugin** | Native Android: clear + reschedule local notifications from refresh timestamps |
| **notification-wakeup-service** | HTTP API: device registration, health, debug wakeup send; may still expose `/notifications/refresh` for the backend |
| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register`; logs `WAKEUP_PING` without refresh/`api_*` scheduling |
| **daily-notification-plugin** | Native Android: Daily Reminder, New Activity / dual, native fetcher; Phase 4 `clearApiNotifications()` |
### Android wakeup flow (production path)
### Android wakeup flow (current)
1. **notification-wakeup-service** (or `/debug/send-wakeup`) sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
1. **notification-wakeup-service** `/debug/send-wakeup` sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
2. FCM delivers to the device (best-effort; see [Android Platform Notes](#8-android-platform-notes) and [Battery Optimization Caveats](#9-battery-optimization-caveats)).
3. Capacitor `pushNotificationReceived` fires → `handleCapacitorPushNotificationReceived()` in `NativeNotificationService.ts`.
4. Handler calls `refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING" })`, which `POST`s `{backend}/notifications/refresh` with `testMode` from the debug config.
5. Backend returns `nextNotifications: [{ timestamp }, ...]`.
6. App calls `applyNotificationRefreshPayload()` → **daily-notification-plugin** clears existing local alarms and schedules new ones.
4. Handler logs `[Notifications] push handler ignored type=WAKEUP_PING`. It does **not** POST `/notifications/refresh` or schedule `api_*` notifications.
Console and debug panel lines are prefixed with **`[Notifications]`** (see `NotificationDebugEvents.ts`).
@@ -275,9 +275,9 @@ Before ngrok end-to-end testing, confirm:
## 6. Configure the Notification Debug Panel backend override
The app normally calls `APP_SERVER` (from `VITE_APP_SERVER`). For local wakeup testing, override the notification API base URL without rebuilding.
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, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
### Open the panel
@@ -290,12 +290,11 @@ For a full panel reference (configuration, authentication, and troubleshooting),
| Control | Purpose |
|---------|---------|
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) |
| **Test Mode** | Sends `testMode: true/false` in register/refresh JSON bodies (default on when unset in storage) |
| **Test Mode** | Sends `testMode: true/false` in register / send-wakeup JSON bodies (default on when unset in storage) |
| **Skip JWT Authentication (Local Development Only)** | When on, omits `Authorization` headers for local servers that accept unauthenticated requests (default **off**) |
| **Register Token Now** | `POST /notifications/register` with current FCM token and `platform: "android"` |
| **Refresh Notifications** | `POST /notifications/refresh` (same as post-wakeup flow) |
| **Simulate WAKEUP_PING (Local)** | Calls refresh API directly (no FCM) — quick backend + ngrok test |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup` on the backend override URL; server sends a real FCM `WAKEUP_PING` to the panel’s current FCM token (see below) |
| **Upload AlertSearch Authorization** | Uploads AlertSearch delegated JWTs (`/notifications/alert-authorization`) |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; FCM delivery diagnostic only (no app-side refresh/`api_*` scheduling) |
| **Event Log** | Shared `[Notifications]` panel log (100 entries) |
Persistence: `localStorage` keys `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, and `notificationDebug.bypassAuth` (`NotificationDebugConfig.ts`).
@@ -314,19 +313,13 @@ For **local ngrok / localhost**: set the backend URL to your tunnel or `http://1
### testMode
When **Test Mode** is on (default if never saved), register and refresh requests include `"testMode": true`. The backend can route dev traffic separately from production. Turn it off in the panel only if you intentionally want production-mode API behavior against your tunnel.
When **Test Mode** is on (default if never saved), register and send-wakeup requests include `"testMode": true`. The backend can route dev traffic separately from production. Turn it off in the panel only if you intentionally want production-mode API behavior against your tunnel.
### WAKEUP_PING debug controls
Three panel actions exercise different segments of the wakeup pipeline. Use them to bisect failures (see [Troubleshooting §14](#fcm-message-not-received)).
**Send Real WAKEUP_PING** is the remaining panel control for wakeup. Mock refresh, Simulate WAKEUP_PING, Wakeup Ping Simulator, and Flood Test were removed with the retired refresh pipeline.
| Button | Behavior |
|--------|----------|
| **Backend Testing → Simulate WAKEUP_PING (Local)** | Skips FCM; calls refresh API only (ngrok path test) |
| **Backend Testing → Send Real WAKEUP_PING** | Full pipeline: backend `/debug/send-wakeup` → FCM → Capacitor listener → refresh (see below) |
| **Wakeup Ping Simulator** (lower on panel) | Runs production handler with synthetic `WAKEUP_PING` payload (no FCM, no backend wakeup call) |
Use **Simulate WAKEUP_PING (Local)** to verify ngrok + refresh; use **Wakeup Ping Simulator** to verify handler + refresh chaining without FCM; use **Send Real WAKEUP_PING** for end-to-end FCM delivery on device.
Use **Send Real WAKEUP_PING** to confirm backend → FCM → Capacitor listener delivery. Do not expect `/notifications/refresh` or `api_*` rescheduling.
### Send Real WAKEUP_PING
@@ -337,9 +330,9 @@ Use **Simulate WAKEUP_PING (Local)** to verify ngrok + refresh; use **Wakeup Pin
1. Reads the **Current FCM Token** shown in the panel (same token used by **Register Token Now**). If no token is available, the action fails with a panel status message.
2. `POST`s to `{backend override}/debug/send-wakeup` with `deviceId`, `fcmToken`, `platform: "android"`, and `testMode` from the panel config (`NotificationDebugService.sendRealWakeupPing()`).
3. On HTTP success, the **notification-wakeup-service** enqueues a real FCM **data** message with `data.type = "WAKEUP_PING"` to that device.
4. When FCM delivers the message to the app, Capacitor fires `pushNotificationReceived` → `handleCapacitorPushNotificationReceived()` → `refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING" })` → `POST /notifications/refresh` → `applyNotificationRefreshPayload()` (clear + reschedule via **daily-notification-plugin**).
4. When FCM delivers the message to the app, Capacitor fires `pushNotificationReceived` → `handleCapacitorPushNotificationReceived()`, which logs `push handler ignored type=WAKEUP_PING`. There is no `refreshNotificationsWithDiagnostics` and no `applyNotificationRefreshPayload`.
That exercises the full **backend → FCM → Capacitor push listener → refresh request → notification rescheduling** path on Android without manual `curl` on the Mac.
That exercises **backend → FCM → Capacitor push listener** on Android without manual `curl` on the Mac. It does **not** reschedule local notifications.
**Prerequisites:** ngrok backend override saved, **Test Mode** as needed, notification permission granted, **Register Token Now** succeeded, app **backgrounded** (Home — not force-stop) before expecting FCM delivery ([§8](#8-android-platform-notes)).
@@ -348,27 +341,24 @@ That exercises the full **backend → FCM → Capacitor push listener → refres
Filter logcat (prefix is always `[Notifications]`):
```bash
adb logcat | grep -E '\[Notifications\].*(Real WAKEUP_PING|pushNotificationReceived|WAKEUP_PING|Refresh started|Refresh completed)'
adb logcat | grep -E '\[Notifications\].*(Real WAKEUP_PING|push handler ignored)'
```
On a **successful end-to-end** run (HTTP success from the panel, then FCM delivery within ~30–120s), expect these key lines in order:
On a **successful FCM delivery** run (HTTP success from the panel, then FCM delivery within ~30–120s), expect these key lines in order:
```
[Notifications] Real WAKEUP_PING requested
[Notifications] Real WAKEUP_PING success
[Notifications] pushNotificationReceived type=WAKEUP_PING
[Notifications] WAKEUP_PING handler — invoking refresh
[Notifications] Refresh started (WAKEUP_PING)
[Notifications] Refresh completed (WAKEUP_PING) in …ms (scheduled N)
[Notifications] push handler ignored type=WAKEUP_PING
```
Intermediate lines (e.g. `WAKEUP_PING received — will trigger refresh`, `Schedule replacement: …`, `Using authenticated notification request` or auth bypass messages when **Skip JWT Authentication** is on) are normal. ngrok should also show a new `POST /notifications/refresh` after the push is handled.
Auth bypass messages when **Skip JWT Authentication** is on are normal. ngrok should **not** show a new `POST /notifications/refresh` from the app after the push.
#### Send Real WAKEUP_PING vs end-to-end success
#### Send Real WAKEUP_PING vs delivery success
The panel status **“Real WAKEUP_PING sent via backend.”** and the Event Log line **`Real WAKEUP_PING success`** only confirm that the backend **accepted the request and attempted FCM delivery**. They do **not** prove the device received the push or ran refresh.
The panel status **“Real WAKEUP_PING sent via backend.”** and the Event Log line **`Real WAKEUP_PING success`** only confirm that the backend **accepted the request and attempted FCM delivery**. They do **not** prove the device received the push.
**Successful end-to-end delivery** is confirmed only when the subsequent **`pushNotificationReceived`**, **`WAKEUP_PING handler`**, and **`Refresh started (WAKEUP_PING)`** / **`Refresh completed (WAKEUP_PING)`** lines appear in logcat (or Event Log) within the delivery window. If you see `Real WAKEUP_PING success` but not those lines, treat it as an FCM delivery problem ([FCM message not received](#fcm-message-not-received)), not a backend enqueue failure.
**Successful delivery** is confirmed when **`push handler ignored type=WAKEUP_PING`** appears in logcat (or Event Log) within the delivery window. If you see `Real WAKEUP_PING success` but not that line, treat it as an FCM delivery problem ([FCM message not received](#fcm-message-not-received)), not a backend enqueue failure.
### Programmatic override (optional)
@@ -455,15 +445,11 @@ On **API 33+**, `POST_NOTIFICATIONS` is a runtime permission ([section 7](#7-and
### Network connectivity
FCM and your ngrok-backed refresh both need network:
- Device must reach **Google’s FCM endpoints** (mobile data or Wi‑Fi).
- After wake, the app must reach the **ngrok HTTPS URL** for `POST /notifications/refresh`.
- Airplane mode, captive portals, VPNs, or flaky Wi‑Fi cause “server sent wakeup but app never refreshed” symptoms even when FCM eventually arrives.
FCM needs network to Google’s endpoints. Registration needs the ngrok HTTPS URL for `POST /notifications/register`. Airplane mode, captive portals, VPNs, or flaky Wi‑Fi cause “server sent wakeup but app never logged ignored type” symptoms even when FCM eventually arrives.
### Physical device vs emulator
Doze, App Standby, and OEM battery menus are weak or absent on many emulators. Use a **physical device** for wakeup SLA testing; emulators are fine for panel-only register/refresh smoke tests.
Doze, App Standby, and OEM battery menus are weak or absent on many emulators. Use a **physical device** for wakeup SLA testing; emulators are fine for panel-only register smoke tests.
### Logcat
@@ -471,7 +457,7 @@ Doze, App Standby, and OEM battery menus are weak or absent on many emulators. U
adb logcat | grep -E '\[Notifications\]|\[FirebaseMessaging\]|\[NativeNotificationService\]'
```
Expect `pushNotificationReceived type=WAKEUP_PING` and `WAKEUP_PING handler — invoking refresh` after a successful wake. For a panel-driven test, use **Send Real WAKEUP_PING** ([§6](#send-real-wakeup_ping)) instead of manual `curl`.
Expect `push handler ignored type=WAKEUP_PING` after a successful wake. For a panel-driven test, use **Send Real WAKEUP_PING** ([§6](#send-real-wakeup_ping)) instead of manual `curl`.
---
@@ -499,7 +485,7 @@ Apps used infrequently move to **standby** buckets with reduced background netwo
### Manufacturer-specific restrictions
OEMs add layers on top of AOSP. Aggressive battery management can delay **WAKEUP_PING** delivery or prevent the refresh `fetch` from running promptly.
OEMs add layers on top of AOSP. Aggressive battery management can delay **WAKEUP_PING** delivery.
| Vendor | Where to look (names vary by OS version) |
|--------|------------------------------------------|
@@ -515,8 +501,8 @@ If wakeup works on a **Pixel** but fails on an OEM phone, assume battery policy
1. Server accepts `/debug/send-wakeup` → FCM enqueue succeeds.
2. Device may **hold** the message until Doze maintenance or OEM policy allows delivery.
3. `pushNotificationReceived` runs → `refreshNotificationsWithDiagnostics()` → ngrok `POST /notifications/refresh`.
4. Any step can lag under battery savers; use **Simulate WAKEUP_PING (Local)** in the debug panel to separate FCM delay from refresh/API issues; use **Send Real WAKEUP_PING** for the full FCM path ([§6](#send-real-wakeup_ping)).
3. `pushNotificationReceived` runs → `push handler ignored type=WAKEUP_PING`.
4. Any step can lag under battery savers; use **Send Real WAKEUP_PING** for the FCM path ([§6](#send-real-wakeup_ping)).
---
@@ -528,12 +514,10 @@ If wakeup works on a **Pixel** but fails on an OEM phone, assume battery policy
4. Grant notification permission when prompted (or enable in Settings).
5. Open **Notification Debug Panel** → paste ngrok URL → **Save Backend URL**; confirm **Test Mode** is on; enable **Skip JWT Authentication** only if your local backend accepts unauthenticated requests.
6. Tap **Register Token Now** → confirm ngrok `POST /notifications/register` and `[Notifications] Token registration success` in Event Log / logcat.
7. Tap **Refresh Notifications** → confirm `POST /notifications/refresh` and `Refresh completed in Nms (scheduled X)` in Event Log.
8. Optional: tap **Simulate WAKEUP_PING (Local)** to verify ngrok + refresh without FCM.
9. Background the app (Home), then tap **Send Real WAKEUP_PING** (or from the Mac, call **`/debug/send-wakeup`** — see [curl examples](#13-sample-curl-commands)) with the registered `deviceId` / token as required by **notification-wakeup-service**.
10. Watch logcat for `WAKEUP_PING` and `Refresh completed (WAKEUP_PING)` lines ([expected output](#expected-logcat-output)).
11. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate HTTP traffic.
12. Use **Pending Notification Inspector** on the panel to confirm locally scheduled fires after refresh.
7. Background the app (Home), then tap **Send Real WAKEUP_PING** (or from the Mac, call **`/debug/send-wakeup`** — see [curl examples](#13-sample-curl-commands)) with the registered `deviceId` / token as required by **notification-wakeup-service**.
8. Watch logcat for `push handler ignored type=WAKEUP_PING` ([expected output](#expected-logcat-output)).
9. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate HTTP traffic (register and send-wakeup; not app-initiated refresh).
10. Use **Pending Notification Inspector** for Daily Reminder / New Activity / dual schedules — not for retired `api_*` refresh.
For a formal pass/fail sequence, use the [Verification Checklist](#11-verification-checklist) below.
@@ -549,12 +533,12 @@ Use this checklist during development or QA sign-off. Each step lists **actions*
| 2 | Device → backend override | §2 |
| 3 | FCM token registered | §3 |
| 4 | Device record in wakeup service | §4 |
| 5 | Refresh returns schedule data | §5 |
| 6 | Locals scheduled | §6 |
| 5 | Optional backend `/notifications/refresh` curl | §5 (backend only; app does not call this) |
| 6 | Locals scheduled | §6 (Daily Reminder / dual / fetcher — not refresh) |
| 7 | Manual wakeup sends FCM | §7 |
| 8 | WAKEUP_PING → refresh | §8 |
| 9 | Replace after refresh | §9 |
| 10 | Test mode frequent refreshes | §10 |
| 8 | WAKEUP_PING delivered | §8 |
| 9 | _(retired)_ Replace after refresh | skipped |
| 10 | _(retired)_ Test mode frequent refreshes | skipped |
### 1. Backend reachable through ngrok
@@ -578,7 +562,7 @@ curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
3. Confirm **Backend Status → URL** matches the saved ngrok host.
4. Enable **Test Mode** if using dev backend behavior.
**Expected outcome:** **Active** URL in the panel equals your ngrok `https://…` host. Subsequent app requests use that base (not production `APP_SERVER`) for `/notifications/register` and `/notifications/refresh`.
**Expected outcome:** **Active** URL in the panel equals your ngrok `https://…` host. Subsequent app requests use that base (not the default `DEFAULT_NOTIFY_API_SERVER`) for `/notifications/register` and `/debug/send-wakeup`.
---
@@ -611,12 +595,11 @@ curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
---
### 5. Refresh endpoint returns schedule data
### 5. Refresh endpoint (backend only; app does not call this)
**Actions:**
1. Tap **Refresh Notifications** in the panel (or curl below).
2. Inspect ngrok response body.
Mac-side curl against **notification-wakeup-service** if you need to confirm the backend still answers. The app has no **Refresh Notifications** button and does not schedule from this payload.
```bash
curl -sS -X POST "$BASE/notifications/refresh" \
@@ -624,18 +607,18 @@ curl -sS -X POST "$BASE/notifications/refresh" \
-d '{"platform":"android","testMode":true}'
```
**Expected outcome:** HTTP **200**; JSON includes `nextNotifications` array with at least one `{ "timestamp": <number> }` in the **future** (epoch ms). Event Log: `Refresh completed in …ms (scheduled N)` with **N ≥ 1**. If `scheduled 0` or empty array, fix backend auth, DID/native fetcher, or `testMode` handling before scheduling tests.
**Expected outcome:** HTTP **200** from the **service** if that route is still deployed. Do **not** expect Event Log `Refresh completed` or `api_*` schedules in the app.
---
### 6. Local notifications are scheduled
### 6. Local notifications are scheduled (not from refresh)
**Actions:**
1. After a successful refresh (step 5), open **Pending Notification Inspector** → **Refresh** (list button).
2. Optionally cross-check logcat for `Schedule replacement applied (N timestamp(s))`.
1. Configure Static Daily Reminder and/or New Activity (dual) in the app, or rely on the native fetcher.
2. Open **Pending Notification Inspector** → **Refresh** (list button).
**Expected outcome:** Inspector lists **N** pending item(s) matching refresh count; each row has a **future** `nextTriggerDate` / wall-clock time. No `Schedule replacement aborted` or `skipped (no valid timestamps)` in Event Log.
**Expected outcome:** Inspector lists pending Daily Reminder / dual / fetcher items. Do not expect `api_*` identifiers after Phase 4 startup cleanup. There is no `Schedule replacement applied` from WAKEUP_PING.
---
@@ -656,11 +639,11 @@ curl -sS -X POST "$BASE/debug/send-wakeup" \
3. Check **notification-wakeup-service** logs for FCM send success (no Admin SDK / token errors).
**Expected outcome:** HTTP **200** (or documented success code) from `/debug/send-wakeup`; Event Log / logcat: `Real WAKEUP_PING success` when using the panel button; server logs indicate FCM message enqueued/sent with `data.type = "WAKEUP_PING"`. This step alone does not prove device delivery (see step 8 and [Send Real WAKEUP_PING vs end-to-end success](#send-real-wakeup_ping-vs-end-to-end-success)).
**Expected outcome:** HTTP **200** (or documented success code) from `/debug/send-wakeup`; Event Log / logcat: `Real WAKEUP_PING success` when using the panel button; server logs indicate FCM message enqueued/sent with `data.type = "WAKEUP_PING"`. This step alone does not prove device delivery (see step 8 and [Send Real WAKEUP_PING vs delivery success](#send-real-wakeup_ping-vs-delivery-success)).
---
### 8. WAKEUP_PING triggers refreshNotifications()
### 8. WAKEUP_PING is delivered (no refresh)
**Actions:**
@@ -669,42 +652,28 @@ curl -sS -X POST "$BASE/debug/send-wakeup" \
3. Filter logcat:
```bash
adb logcat | grep -E 'WAKEUP_PING|pushNotificationReceived|Refresh completed'
adb logcat | grep -E 'WAKEUP_PING|push handler ignored'
```
**Expected outcome (within ~30–120s, longer under Doze/OEM):**
1. `pushNotificationReceived type=WAKEUP_PING` / `WAKEUP_PING received`
2. `WAKEUP_PING handler — invoking refresh`
3. `Refresh started (WAKEUP_PING)`
4. ngrok: new `POST /notifications/refresh`
5. `Refresh completed (WAKEUP_PING) in …ms (scheduled N)`
1. `Real WAKEUP_PING success` (panel path)
2. `push handler ignored type=WAKEUP_PING`
3. ngrok: **no** app-initiated `POST /notifications/refresh`
**Isolation:** **Wakeup Ping Simulator** on the panel should produce lines 2–5 without FCM. **Simulate WAKEUP_PING (Local)** proves refresh only (no handler, source `WAKEUP_PING simulation`). Full expected logcat for **Send Real WAKEUP_PING**: [§6](#expected-logcat-output).
Full expected logcat for **Send Real WAKEUP_PING**: [§6](#expected-logcat-output).
---
### 9. Existing notifications are replaced after refresh
**Actions:**
1. Note pending count and identifiers in **Pending Notification Inspector**.
2. Tap **Refresh Notifications** again (or trigger step 8).
3. Refresh inspector; compare IDs/times to step 1.
**Expected outcome:** Event Log shows `clearAllNotifications` or `cancelAllNotifications` then `Schedule replacement applied`; pending list reflects **new** timestamps (old alarms not accumulated). Total pending count should match latest refresh `N`, not double from duplicate refreshes unless backend returned more slots.
**Retired.** The app no longer replaces local schedules from `/notifications/refresh`. Pending list changes come from Daily Reminder / dual / native fetcher, or from Phase 4 `clearApiNotifications()`.
---
### 10. Test mode produces frequent notification refreshes
**Actions:**
1. Confirm **Test Mode** checked; **Backend Status → testMode: true**.
2. Call refresh twice (panel or curl) with `testMode: true`.
3. Compare `nextNotifications` timestamps in ngrok responses.
**Expected outcome:** With **testMode: true**, **notification-wakeup-service** returns **dev-friendly** schedule data—typically **sooner** fire times than production mode (shorter horizons). Pending Inspector updates to nearer triggers after each refresh. With Test Mode **off**, timestamps should be farther out (production-like); use that contrast to confirm the flag is wired end-to-end.
**Retired.** `testMode` still goes on register and send-wakeup bodies. It does not drive app-side `api_*` refresh cadences.
---
@@ -739,31 +708,23 @@ npm run dev
8. Paste `BASE` → **Save Backend URL**; enable **Test Mode**.
### Phase C — Register and schedule
### Phase C — Register
9. **Register Token Now** → Event Log success; ngrok `POST /notifications/register` **200**; copy `deviceId` from ngrok request body.
10. Confirm device in **notification-wakeup-service** (logs/DB per that repo).
11. **Refresh Notifications** → Event Log `scheduled N`, N ≥ 1; ngrok refresh **200** with `nextNotifications`.
### Phase D — FCM wakeup delivery
12. **Pending Notification Inspector** → **Refresh** → future alarm(s) listed.
11. Background app (Home).
### Phase D — FCM wakeup path
12. Tap **Send Real WAKEUP_PING** in the panel (or Mac: `curl -X POST "$BASE/debug/send-wakeup" -H "Content-Type: application/json" -d '{"deviceId":"…","testMode":true}'`) → panel `Real WAKEUP_PING success` and server FCM enqueue success.
13. Background app (Home).
13. Within 30–120s (longer if unplugged/Doze): logcat shows `push handler ignored type=WAKEUP_PING`; ngrok does **not** show an app `POST /notifications/refresh`.
14. Tap **Send Real WAKEUP_PING** in the panel (or Mac: `curl -X POST "$BASE/debug/send-wakeup" -H "Content-Type: application/json" -d '{"deviceId":"…","testMode":true}'`) → panel `Real WAKEUP_PING success` and server FCM enqueue success.
### Phase E — Optional local notification proof
15. Within 30–120s (longer if unplugged/Doze): logcat shows `pushNotificationReceived` → `Refresh completed (WAKEUP_PING)`; ngrok shows second `POST /notifications/refresh`.
16. Pending Inspector **Refresh** → timestamps updated (replacement, not duplicate stack).
### Phase E — Optional delivery proof
17. If `testMode` returned a trigger within a few minutes, wait for wall-clock fire with app backgrounded; confirm notification appears (permission + channel + exact alarm rules).
18. If FCM step 15 failed but step 11 passed: run **Simulate WAKEUP_PING (Local)** then **Wakeup Ping Simulator** to bisect FCM vs handler; see [Troubleshooting](#14-troubleshooting) and [Send Real WAKEUP_PING vs end-to-end success](#send-real-wakeup_ping-vs-end-to-end-success).
14. Enable Daily Reminder or New Activity; wait for wall-clock fire with app backgrounded; confirm the OS notification is **not** the retired generic `api_*` “Reminder” copy.
### End-to-end pass criteria
@@ -771,9 +732,9 @@ npm run dev
|-------|------|
| A | Health OK local + ngrok |
| B | Override URL + testMode active |
| C | Register + refresh + pending list populated |
| D | Wakeup curl OK → logcat refresh chain → pending updated |
| E | Optional visible notification at scheduled time |
| C | Register succeeded |
| D | Wakeup curl/panel OK → logcat ignored-type line |
| E | Optional visible Daily Reminder / New Activity notification |
---
@@ -804,7 +765,7 @@ curl -sS -X POST "$BASE/notifications/register" \
}'
```
### Refresh (mirror app payload)
### Refresh (backend only; app does not consume)
```bash
curl -sS -X POST "$BASE/notifications/refresh" \
@@ -815,19 +776,7 @@ curl -sS -X POST "$BASE/notifications/refresh" \
}'
```
Example success body shape (actual fields may vary by service version):
```json
{
"shouldNotify": true,
"nextNotifications": [
{ "timestamp": 1710000000000 },
{ "timestamp": 1710003600000 }
]
}
```
The app schedules those timestamps via **daily-notification-plugin** (`applyNotificationRefreshPayload` in `NativeNotificationService.ts`).
This exercises **notification-wakeup-service** only. The app does **not** call `applyNotificationRefreshPayload` or schedule `api_*` from the response.
### Send wakeup push (debug)
@@ -842,7 +791,7 @@ curl -sS -X POST "$BASE/debug/send-wakeup" \
}'
```
Confirm parameters (token vs `deviceId`, auth headers) in that repo’s README or OpenAPI spec. The server must send FCM data including `type: "WAKEUP_PING"` to match `handleCapacitorPushNotificationReceived`.
Confirm parameters (token vs `deviceId`, auth headers) in that repo’s README or OpenAPI spec. The server may still send FCM data including `type: "WAKEUP_PING"`; the app logs it as ignored and does not refresh.
---
@@ -893,41 +842,34 @@ Compare with panel **Backend Status** and Event Log error text.
**Verification:** Compare panel URL to current `ngrok http` **Forwarding** line; `curl -sS "$NEW_URL/health"`.
**Fixes:** Copy new HTTPS URL → **Save Backend URL** in panel; or clear override only if intentionally returning to `APP_SERVER`.
**Fixes:** Copy new HTTPS URL → **Save Backend URL** in panel; or clear override only if intentionally returning to `DEFAULT_NOTIFY_API_SERVER`.
---
### Refresh endpoint failures
**Symptoms:** **Refresh Notifications** fails; Event Log HTTP error; no `scheduled X` line; ngrok missing `POST /notifications/refresh`.
**Symptoms:** Mac `curl` of `/notifications/refresh` fails; ngrok missing that path. This is a **backend** issue. The app does not POST refresh.
**Likely causes:** Stale ngrok URL; backend down; 404/wrong path; JWT/native fetcher not configured; refresh auth failure.
**Verification:** `curl` health and refresh from the Mac; confirm the service still ships that route.
**Verification:**
1. **Simulate WAKEUP_PING (Local)** — if this fails, problem is ngrok/refresh API, not FCM.
2. ngrok inspect for refresh status code and response body.
3. Logcat: `refreshNotifications failed` or JWT errors from `configureNativeFetcherIfReady()`.
**Fixes:** Fix backend URL and health; ensure active DID and endorser settings ([notification-from-api-call.md](./notification-from-api-call.md)); confirm `testMode` if backend requires it.
**Fixes:** Fix backend URL and health. App scheduling uses Daily Reminder / dual / native fetcher, not this payload.
---
### FCM message not received
**Symptoms:** `/debug/send-wakeup` or panel **Send Real WAKEUP_PING** shows success (`Real WAKEUP_PING success` in Event Log); no `pushNotificationReceived` / `WAKEUP_PING` in logcat within 2 minutes; refresh never triggered.
**Symptoms:** `/debug/send-wakeup` or panel **Send Real WAKEUP_PING** shows success (`Real WAKEUP_PING success` in Event Log); no `push handler ignored type=WAKEUP_PING` in logcat within 2 minutes.
**Likely causes:** Force-stopped app; wrong FCM token on server; Doze/OEM delay; no Google Play services; payload missing `data.type = "WAKEUP_PING"`; device offline.
**Note:** `Real WAKEUP_PING success` only means the backend accepted and sent the FCM request. Missing downstream refresh logs indicates a **delivery** failure, not a failed wakeup API call ([§6](#send-real-wakeup_ping-vs-end-to-end-success)).
**Note:** `Real WAKEUP_PING success` only means the backend accepted and sent the FCM request. Missing the ignored-type log indicates a **delivery** failure, not a failed wakeup API call ([§6](#send-real-wakeup_ping-vs-delivery-success)).
**Verification:**
1. App **backgrounded** (Home), not force-stopped.
2. Panel FCM token matches token used by server/register.
3. **Simulate WAKEUP_PING (Local)** works → isolates FCM path from refresh/API.
4. Wait 30–120s (longer on Doze/OEM).
5. Battery **Unrestricted** and OEM autostart enabled for test device.
3. Wait 30–120s (longer on Doze/OEM).
4. Battery **Unrestricted** and OEM autostart enabled for test device.
**Fixes:** Re-register token; relaunch app; relax battery settings ([section 9](#9-battery-optimization-caveats)); confirm **notification-wakeup-service** message format; test on Pixel vs suspect OEM policy.
@@ -947,30 +889,28 @@ Compare with panel **Backend Status** and Event Log error text.
### Notifications not appearing
**Symptoms:** Refresh succeeds (`scheduled X` in log) but no visible notification at fire time; Pending Inspector empty or stale.
**Symptoms:** Daily Reminder or New Activity configured but no visible notification at fire time; Pending Inspector empty or stale.
**Likely causes:** Permission denied (display blocked); exact alarm permission on Android 12+; timestamps in past; plugin schedule error; DND/channel settings.
**Verification:**
1. Permission granted ([section 7](#7-android-notification-permissions)).
2. Pending Notification Inspector after refresh.
3. Logcat: `Schedule replacement applied` vs aborted messages.
4. Confirm `nextNotifications` timestamps are in the future (curl refresh response).
2. Pending Notification Inspector for Daily Reminder / dual identifiers (not `api_*` after Phase 4 cleanup).
**Fixes:** Grant `POST_NOTIFICATIONS`; check `SCHEDULE_EXACT_ALARM` / alarm permission per plugin docs; fix refresh payload; test with nearer timestamps via backend `testMode`.
**Fixes:** Grant `POST_NOTIFICATIONS`; check `SCHEDULE_EXACT_ALARM` / alarm permission per plugin docs. Do not expect wakeup refresh payloads to populate the inspector.
---
### Duplicate notifications
**Symptoms:** Multiple identical local notifications; Event Log shows repeated refresh lines.
**Symptoms:** Multiple identical local notifications.
**Likely causes:** Multiple `WAKEUP_PING` deliveries; repeated manual **Refresh**; flood test; separate Daily Reminder vs New Activity schedules.
**Likely causes:** Separate Daily Reminder vs New Activity schedules; duplicate dual config; leftover `api_*` before Phase 4 cleanup.
**Verification:** Event Log count of refresh completions; ngrok inspect for duplicate `POST /notifications/refresh`.
**Verification:** Pending Inspector identifiers; see [notification-new-activity-lay-of-the-land.md](./notification-new-activity-lay-of-the-land.md) for product-level double-schedule issues.
**Fixes:** Each refresh should **replace** schedule (clear + schedule)—if duplicates persist, check plugin logs; see [notification-new-activity-lay-of-the-land.md](./notification-new-activity-lay-of-the-land.md) for product-level double-schedule issues.
**Fixes:** Confirm Daily Reminder vs dual settings. WAKEUP_PING no longer stacks `api_*` refreshes.
---
@@ -1006,7 +946,7 @@ Compare with panel **Backend Status** and Event Log error text.
| `src/services/notifications/NotificationDebugEvents.ts` | Panel event log + `logNotification()` |
| `src/services/notifications/notificationLog.ts` | Structured log helpers |
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
| `src/services/notifications/NativeNotificationService.ts` | `refreshNotifications`, `WAKEUP_PING`, `applyNotificationRefreshPayload` |
| `src/services/notifications/NativeNotificationService.ts` | Push delivery hook (logs ignored types; no refresh) |
| `src/services/notifications/firebaseMessagingClient.ts` | Capacitor push listeners, permission, token registration |
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
| `src/main.capacitor.ts` | Native push init at startup |
+36 -54
View File
@@ -1,14 +1,16 @@
# Local iOS Testing with ngrok (notification-wakeup-service)
**Last updated:** 2026-05-18
**Last updated:** 2026-09-24 (retired WAKEUP_PING → refresh → `api_*` consumption)
**Audience:** Developers on **crowd-funder-for-time-pwa**, **daily-notification-plugin**, and **notification-wakeup-service**
**Goal:** Exercise silent push wake (`WAKEUP_PING`), FCM token registration, and notification refresh against a Mac-hosted backend reachable from a physical iPhone.
**Goal:** Exercise FCM token registration and silent-push **delivery** (`WAKEUP_PING` via `/debug/send-wakeup`) against a Mac-hosted backend reachable from a physical iPhone.
> **Retired (do not expect this in the app):** `WAKEUP_PING` → `POST /notifications/refresh` → `applyNotificationRefreshPayload()` → `api_*` local schedules. The app logs ignored push types and does not refresh or schedule from wakeup. `/debug/send-wakeup` remains an FCM/APNs diagnostic. Mac `curl` of `/notifications/refresh` only tests the backend.
---
## Architecture overview
End-to-end flow when testing New Activity / silent wake on a physical iPhone:
End-to-end flow when testing FCM registration and wakeup **delivery** on a physical iPhone:
```text
┌─────────────────────┐ HTTPS ┌──────────────────────┐
@@ -17,7 +19,7 @@ End-to-end flow when testing New Activity / silent wake on a physical iPhone:
│ wakeup-service │ └──────────┬───────────┘
└──────────┬──────────┘ │
│ │ fetch
│ POST /notifications/refresh │ POST /notifications/register
│ │ POST /notifications/register
│ ▼
│ ┌──────────────────────┐
│ │ crowd-funder-for- │
@@ -26,7 +28,7 @@ End-to-end flow when testing New Activity / silent wake on a physical iPhone:
│ └──────────┬───────────┘
│ │
│ FCM data message (WAKEUP_PING) │ daily-notification-plugin
▼ ▼ (local schedule replace)
▼ ▼ (Daily Reminder / dual / fetcher)
┌─────────────────────┐ ┌──────────────────────┐
│ Firebase Cloud │ ──APNs──────► │ iPhone (physical) │
│ Messaging │ silent push │ app.timesafari │
@@ -37,18 +39,16 @@ End-to-end flow when testing New Activity / silent wake on a physical iPhone:
| Repo | Role |
|------|------|
| **notification-wakeup-service** | HTTP API: device registration, refresh payload (`nextNotifications`), health, debug wakeup send |
| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register` & `/refresh`, handles `WAKEUP_PING` push |
| **daily-notification-plugin** | Native iOS/Android: clear + reschedule local notifications from refresh timestamps |
| **notification-wakeup-service** | HTTP API: device registration, health, debug wakeup send; may still expose `/notifications/refresh` |
| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register`; logs `WAKEUP_PING` without refresh/`api_*` scheduling |
| **daily-notification-plugin** | Native iOS/Android: Daily Reminder, New Activity / dual, native fetcher; Phase 4 `clearApiNotifications()` |
### Silent wake sequence (production path)
### Silent wake sequence (current)
1. Backend (or `/debug/send-wakeup`) sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
1. Backend `/debug/send-wakeup` sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
2. APNs delivers to the device (best-effort; see iOS caveats below).
3. Capacitor `pushNotificationReceived` fires → `handleCapacitorPushNotificationReceived()`.
4. App calls `POST {backend}/notifications/refresh` with `testMode` (from debug config).
5. Backend returns `nextNotifications: [{ timestamp }, ...]`.
6. App calls `applyNotificationRefreshPayload()` → plugin clears and schedules new local alarms.
4. Handler logs `[Notifications] push handler ignored type=WAKEUP_PING`. It does **not** POST `/notifications/refresh` or schedule `api_*` notifications.
Console and debug panel lines are prefixed with **`[Notifications]`** (see `NotificationDebugEvents.ts`).
@@ -281,9 +281,10 @@ Before ngrok end-to-end testing, confirm:
## 6. Configure the Notification Debug Panel backend override
The app normally calls `APP_SERVER` (from `VITE_APP_SERVER`). For local wakeup testing, override the notification API base URL without rebuilding.
For a full panel reference (configuration, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
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
@@ -296,12 +297,11 @@ For a full panel reference (configuration, authentication, and troubleshooting),
| Control | Purpose |
|---------|---------|
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) |
| **Test Mode** | Sends `testMode: true/false` in register/refresh JSON bodies (default on when unset in storage) |
| **Test Mode** | Sends `testMode: true/false` in register / send-wakeup JSON bodies (default on when unset in storage) |
| **Skip JWT Authentication (Local Development Only)** | When on, omits `Authorization` headers for local servers that accept unauthenticated requests (default **off**) |
| **Register Token Now** | `POST /notifications/register` with current FCM token |
| **Refresh Notifications** | `POST /notifications/refresh` (same as post-wakeup flow) |
| **Simulate WAKEUP_PING (Local)** | Calls refresh API directly (no FCM) — quick backend test |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; server sends real FCM `WAKEUP_PING` (Android doc has full flow) |
| **Upload AlertSearch Authorization** | Uploads AlertSearch delegated JWTs |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; FCM/APNs delivery diagnostic only |
| **Event Log** | Shared `[Notifications]` panel log (100 entries) |
Persistence: `localStorage` keys `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, and `notificationDebug.bypassAuth` (`NotificationDebugConfig.ts`).
@@ -351,7 +351,7 @@ This section is a quick verification checklist for the detailed Firebase/APNs se
| **GoogleService-Info.plist** | Present in the iOS target if using Firebase iOS SDK paths in your build |
| **FCM token** | Confirm **Register Token Now** succeeds in the debug panel and ngrok shows `POST /notifications/register` |
Silent/data pushes used for wake typically use a **content-available** style payload; confirm **notification-wakeup-service** and Firebase message format match what `handleCapacitorPushNotificationReceived` expects (`data.type === "WAKEUP_PING"`).
Silent/data pushes used for wake typically use a **content-available** style payload; confirm **notification-wakeup-service** and Firebase message format. The app logs `WAKEUP_PING` as ignored and does not refresh.
---
@@ -377,14 +377,9 @@ Silent/data pushes used for wake typically use a **content-available** style pay
- **Low Power Mode** can reduce background execution.
- **Focus / Do Not Disturb** may affect notification presentation (separate from silent data wake, but confusing during tests).
### Two “Simulate WAKEUP_PING” buttons
### Send Real WAKEUP_PING (FCM/APNs diagnostic)
| Button | Behavior |
|--------|----------|
| **Backend Testing → Simulate WAKEUP_PING** | Skips FCM; calls refresh API only (ngrok path test) |
| **Wakeup Ping Simulator** (lower on panel) | Runs production handler with synthetic `WAKEUP_PING` payload |
Use the backend button to verify ngrok + refresh; use the simulator to verify handler + refresh chaining.
**Send Real WAKEUP_PING** posts `/debug/send-wakeup`. Delivery is confirmed by `push handler ignored type=WAKEUP_PING`. Mock refresh, Simulate WAKEUP_PING, and Wakeup Ping Simulator were removed.
---
@@ -394,11 +389,10 @@ Use the backend button to verify ngrok + refresh; use the simulator to verify ha
2. Start **ngrok** and copy the HTTPS URL.
3. Set URL + **Test Mode** in the Notification Debug Panel; confirm **Backend Status**.
4. Tap **Register Token Now** → confirm ngrok request and `[Notifications] Token registration success`.
5. Tap **Refresh Notifications** → confirm `Refresh completed in Nms (scheduled X)` in Event Log and ngrok `POST /notifications/refresh`.
6. From the backend, call **`/debug/send-wakeup`** (see curl below) with the registered `deviceId` / FCM token as required by that service.
7. Watch **Xcode console** for `[Notifications] pushNotificationReceived type=WAKEUP_PING` and refresh timing lines.
8. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate requests.
9. Use **Pending Notification Inspector** on the panel to see locally scheduled fires after refresh.
5. From the backend, call **`/debug/send-wakeup`** (see curl below) with the registered `deviceId` / FCM token as required by that service, or tap **Send Real WAKEUP_PING**.
6. Watch **Xcode console** for `[Notifications] push handler ignored type=WAKEUP_PING`.
7. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate requests (register and send-wakeup; not app-initiated refresh).
8. Use **Pending Notification Inspector** for Daily Reminder / dual / fetcher schedules.
---
@@ -429,7 +423,7 @@ curl -sS -X POST "$BASE/notifications/register" \
}'
```
### Refresh (mirror app payload)
### Refresh (backend only; app does not consume)
```bash
curl -sS -X POST "$BASE/notifications/refresh" \
@@ -440,19 +434,7 @@ curl -sS -X POST "$BASE/notifications/refresh" \
}'
```
Example success body shape (actual fields may vary by service version):
```json
{
"shouldNotify": true,
"nextNotifications": [
{ "timestamp": 1710000000000 },
{ "timestamp": 1710003600000 }
]
}
```
The app schedules those timestamps via **daily-notification-plugin** (`applyNotificationRefreshPayload` in `NativeNotificationService.ts`).
This exercises **notification-wakeup-service** only. The app does **not** call `applyNotificationRefreshPayload` or schedule `api_*` from the response.
### Send wakeup push (debug)
@@ -477,8 +459,8 @@ Confirm parameters (token vs deviceId, auth headers) in that repo’s README or
| Symptom | Checks |
|---------|--------|
| Network error in Event Log | ngrok running? URL saved without typo/trailing slash? |
| HTTP 404 | Tunnel port matches backend `PORT`; path is `/notifications/refresh` |
| Mac `curl` of `/notifications/refresh` fails | ngrok running? URL saved without typo/trailing slash? |
| HTTP 404 | Tunnel port matches backend `PORT`; path is `/notifications/refresh` (backend route; the app does not call it) |
| CORS (web only) | Native Capacitor fetch usually avoids browser CORS; if testing in Safari PWA, configure CORS on the service |
| ngrok browser warning | Free tier may show an interstitial for browser clients; native `fetch` from the app is usually unaffected |
@@ -494,20 +476,20 @@ Confirm parameters (token vs deviceId, auth headers) in that repo’s README or
- App **backgrounded**, not force-quit
- Physical device, correct provisioning profile
- APNs key uploaded to Firebase; bundle ID matches
- FCM message includes `data.type = "WAKEUP_PING"` (see `NativeNotificationService.ts`)
- FCM message includes `data.type = "WAKEUP_PING"` (logged as ignored in `NativeNotificationService.ts`)
- Server actually sent to the **same** FCM token shown in the debug panel
- Wait 30–120s — delivery is not instant
- Try **Simulate WAKEUP_PING** (refresh API) to isolate app/plugin from FCM/APNs
- Confirm Xcode shows `push handler ignored type=WAKEUP_PING` (there is no Simulate WAKEUP_PING / refresh API in the app)
### Notifications duplicating
- Multiple refresh calls (flood test, repeated wakeups) each **replace** schedule via clear + schedule — check Event Log for repeated refreshes
- Separate issue: Daily Reminder vs New Activity both scheduling — see `doc/notification-new-activity-lay-of-the-land.md`
- Daily Reminder vs New Activity both scheduling — see `doc/notification-new-activity-lay-of-the-land.md`
- Leftover `api_*` before Phase 4 cleanup (startup `clearApiNotifications()`)
### Stale ngrok URL
- 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 `APP_SERVER` again
- Or clear override (empty field + Save) only if you intend to hit `DEFAULT_NOTIFY_API_SERVER` again
### Plugin / JWT errors after refresh
@@ -524,7 +506,7 @@ Confirm parameters (token vs deviceId, auth headers) in that repo’s README or
| `src/services/notifications/NotificationDebugEvents.ts` | Panel event log + `logNotification()` |
| `src/services/notifications/notificationLog.ts` | Structured log helpers |
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
| `src/services/notifications/NativeNotificationService.ts` | Refresh, `WAKEUP_PING`, schedule replace |
| `src/services/notifications/NativeNotificationService.ts` | Push delivery hook (logs ignored types; no refresh) |
| `src/services/notifications/firebaseMessagingClient.ts` | Capacitor push listeners |
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
| `src/main.capacitor.ts` | Native push init at startup |
+57 -21
View File
@@ -1,9 +1,47 @@
# Notification Debug Panel
**Created:** 2026-07-07
**Audience:** Developers testing notification registration, refresh, and WAKEUP_PING flows on native (iOS/Android) dev builds.
**Updated:** 2026-09-24
**Audience:** Developers testing notification registration, AlertSearch authorization upload, and FCM delivery diagnostics on native (iOS/Android) dev builds.
The **Notification Debug Panel** is a dev-only UI for exercising the same notification orchestration paths the production app uses: FCM token registration, backend refresh, wakeup handling, and local schedule inspection. It does not duplicate scheduling logic.
The **Notification Debug Panel** is a dev-only UI for FCM token registration, AlertSearch authorization upload, FCM wakeup delivery diagnostics (`/debug/send-wakeup`), and local schedule inspection. It does not schedule notifications from `WAKEUP_PING`. The production `WAKEUP_PING` → `/notifications/refresh` → `api_*` path was retired; leftover `api_*` schedules are cleared once at startup (Phase 4).
---
## Notification API base URL
Notification HTTP calls (`/notifications/register`, `/notifications/alert-authorization`, `/debug/send-wakeup`, etc.) do **not** use `APP_SERVER`. They use a dedicated Notification API host, resolved at runtime by `getNotificationApiBaseUrl()` in `NotificationDebugConfig.ts`. The app does not call `/notifications/refresh`.
### Configuration constants
| 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.
---
@@ -23,21 +61,21 @@ Settings persist in `localStorage` via `NotificationDebugConfig.ts`:
| Key | Default | Purpose |
|-----|---------|---------|
| `notificationDebug.backendBaseUrl` | *(unset — use `APP_SERVER`)* | Override which notification server receives API calls |
| `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`.
All notification API requests (`/notifications/register`, `/notifications/alert-authorization`, `/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 built-in default (`APP_SERVER` from `VITE_APP_SERVER`).
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.
When enabled (default if never saved), register and send-wakeup requests include `"testMode": true` in the JSON body. The backend can use this to route test traffic separately from production.
Test Mode is **independent of authentication**. It does not control whether `Authorization` headers are sent.
@@ -59,9 +97,11 @@ The panel **Backend Status** section shows the active URL, `testMode`, and `bypa
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** | `https://test-notify-api.timesafari.app` |
| **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** |
@@ -84,9 +124,8 @@ Example: `https://abc123.ngrok-free.app` or `http://127.0.0.1:3000`
| Action | What it does |
|--------|----------------|
| **Register Token Now** | `POST {backend}/notifications/register` with current FCM token, `deviceId`, `platform`, and `testMode`. Forces re-registration (bypasses duplicate-token skip). |
| **Refresh Notifications** | `POST {backend}/notifications/refresh` — same path used after a real WAKEUP_PING. Applies returned schedule to the native plugin. |
| **Simulate WAKEUP_PING (Local)** | Calls the refresh API directly (no FCM). Quick test of backend URL + auth + refresh parsing without push delivery. |
| **Send Real WAKEUP_PING** | `POST {backend}/debug/send-wakeup`; server sends a real FCM data message with `data.type = "WAKEUP_PING"`. Exercises backend → FCM → Capacitor listener → refresh → reschedule. Background the app before expecting delivery. |
| **Upload AlertSearch Authorization** | Mints and uploads delegated AlertSearch JWTs (`POST /notifications/alert-authorization`). Requires an active `did:ethr` identity; Test Mode is not used. |
| **Send Real WAKEUP_PING** | `POST {backend}/debug/send-wakeup`; server sends a real FCM data message with `data.type = "WAKEUP_PING"`. FCM delivery diagnostic only — the app logs `push handler ignored type=…` and does **not** call `/notifications/refresh` or schedule `api_*` notifications. Background the app before expecting delivery. |
**Current FCM Token** displays the last token from Capacitor/Firebase registration. **Event Log** shows the last 100 `[Notifications]` messages (also visible in logcat / Xcode console on native).
@@ -96,12 +135,8 @@ Example: `https://abc123.ngrok-free.app` or `http://127.0.0.1:3000`
| Section | Purpose |
|---------|---------|
| **Mock Timing Presets** | Interval for mock refresh timestamps (30 sec – 10 min). |
| **Trigger Mock Refresh** | Applies synthetic future timestamps locally — no backend call. |
| **Wakeup Ping Simulator** | Runs the production push handler with a synthetic `WAKEUP_PING` payload (no FCM, no backend). |
| **Flood Test** | Runs 20 sequential mock refreshes (stress test). |
| **Pending Notification Inspector** | Lists locally scheduled notifications (iOS; Android may show unavailable). |
| **Clear Notifications** | Clears/cancels all plugin-scheduled notifications on native. |
| **Pending Notification Inspector** | Lists locally scheduled notifications (Daily Reminder, New Activity / dual, and any leftover `api_*` until Phase 4 cleanup). |
| **Clear Notifications** | Clears/cancels plugin-scheduled notifications on native. Does not replace the one-time `api_*` startup cleanup. |
---
@@ -158,11 +193,11 @@ The app deferred registration because JWT could not be built (no active DID or e
Same as above. Confirm the **Active** URL in the panel matches your running tunnel or local server port.
### Register succeeds but Send Real WAKEUP_PING does not trigger refresh
### Register succeeds but Send Real WAKEUP_PING does not show delivery
**Real WAKEUP_PING success** only means the backend accepted the wakeup request and attempted FCM delivery. Missing `pushNotificationReceived` / `Refresh completed (WAKEUP_PING)` indicates an FCM delivery or background execution issue — not necessarily a bad wakeup API call.
**Real WAKEUP_PING success** only means the backend accepted the wakeup request and attempted FCM delivery. It does **not** schedule local `api_*` notifications. Delivery is confirmed when logcat / Event Log shows `push handler ignored type=WAKEUP_PING` (the retired refresh chain is gone).
**Checks:** App backgrounded (not force-stopped); FCM token matches registration; **Simulate WAKEUP_PING (Local)** works (isolates FCM from refresh API).
**Checks:** App backgrounded (not force-stopped); FCM token matches registration; Firebase / Play services available.
See platform-specific guides for extended ngrok and FCM workflows:
@@ -175,13 +210,14 @@ See platform-specific guides for extended ngrok and FCM workflows:
| 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` | Backend URL, testMode, bypassAuth persistence |
| `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 |
| `src/services/notifications/NativeNotificationService.ts` | Push delivery hook (logs ignored types; no refresh) |
---
-203
View File
@@ -1,203 +0,0 @@
# Plan: Background New Activity JWT — extended expiry + token pool
**Date:** 2026-03-27 14:29 PST
**Status:** Draft for implementation
**Audience:** TimeSafari / crowd-funder developers
**Related:** `doc/endorser-jwt-background-prefetch-options.md`, `android/.../TimeSafariNativeFetcher.java`, `src/services/notifications/nativeFetcherConfig.ts`, `src/libs/crypto/index.ts`
---
## 1. Problem statement
Background prefetch for New Activity calls Endorser with a Bearer JWT configured via `configureNativeFetcher`. The token previously came from `getHeaders()` → `accessToken()`, which used **`exp` ≈ 60 seconds** (`src/libs/crypto/index.ts`). Prefetch runs **minutes later** in WorkManager **without JavaScript**, so the JWT can be **expired** before the POST (`JWT_VERIFY_FAILED`).
**Goals:**
1. Use JWTs whose **`exp`** covers the gap between **last app-side configure** and **prefetch** (and ideally days without opening the app).
2. Optionally support a **pool** of distinct JWT strings so Endorser can enforce **duplicate-JWT** / **one-time-use** rules without breaking daily prefetch. **Pool size** should follow **`expiryDays + buffer`** (one distinct token per day over the JWT lifetime, plus headroom for retries / edge cases); **implementation uses `BACKGROUND_JWT_POOL_SIZE = 100`** until policy changes.
3. Keep pool size and expiry policy **easy to change** (constants / remote config later).
---
## 2. Guiding principles
| Principle | Implication |
|-----------|-------------|
| **Background has no JS** | Token selection and HTTP must run in **native** (or plugin) code using **persisted** data. |
| **Single source of truth for signing** | Continue using **`createEndorserJwtForDid`** (same keys as today); do not fork crypto in Java/Kotlin. |
| **Configurable pool size** | One constant `BACKGROUND_JWT_POOL_SIZE`; **currently 100**. Size should satisfy **`≥ expiryDays + buffer`** (see below). |
| **Phased delivery** | Ship **extended expiry** first; add **pool** when server duplicate rules require it or in the same release if coordinated. |
### 2.1 Pool size rationale (`expiryDays + buffer`)
For **one New Activity prefetch per day**, each day should use a **distinct** JWT string if the server rejects reuse. Over the JWT lifetime (aligned with **`exp`**), you need at least **one token per day** the pool might be used without regeneration.
**Rule of thumb:**
```text
BACKGROUND_JWT_POOL_SIZE ≥ ceil(BACKGROUND_JWT_EXPIRY_DAYS) + BACKGROUND_JWT_POOL_BUFFER
```
- **`BACKGROUND_JWT_EXPIRY_DAYS`** — human-facing match to `exp` (e.g. **90**); convert to `BACKGROUND_JWT_EXPIRY_SECONDS` for the payload.
- **`BACKGROUND_JWT_POOL_BUFFER`** — extra slots for **same-day retries**, manual tests, or stricter duplicate rules (e.g. **10**).
**Example:** 90‑day `exp` + buffer 10 ⇒ **minimum 100** logical slots. **This plan keeps `BACKGROUND_JWT_POOL_SIZE = 100`** as the shipped default so it matches that example; if `expiryDays` or buffer change later, **bump the constant** so the inequality still holds.
---
## 3. Phases
### Phase A — Extended expiry only (minimum viable)
**Scope**
- Introduce a dedicated mint path for **background / native fetcher** use (name TBD, e.g. `accessTokenForBackgroundNotifications(did)`), producing **one** JWT per configure call with:
- `iss`: DID (unchanged)
- `iat`: now
- `exp`: now + **`BACKGROUND_JWT_EXPIRY_SECONDS`** (derived from **`BACKGROUND_JWT_EXPIRY_DAYS`**; see §2.1 / Phase B constants — **confirm** with Endorser policy)
- Optional: `jti` or nonce for uniqueness if needed for logging/debug
- **`configureNativeFetcherIfReady`** should pass this token (or keep using a thin wrapper) instead of reusing the **60s** `accessToken()` when configuring native fetcher **only** — **do not** change interactive `getHeaders()` / passkey caching behavior for normal API calls unless product asks for it.
**Files (likely)**
- `src/libs/crypto/index.ts` — new function or parameters; keep `accessToken()` default at 60s for existing callers.
- `src/services/notifications/nativeFetcherConfig.ts` — obtain background JWT via the new mint path, not `getHeaders()`’s generic path, **or** add a dedicated branch that calls the new mint after resolving `did`.
**Native**
- **`TimeSafariNativeFetcher`**: still one `jwtToken` field; no pool yet. Ensure `configure()` is called whenever TS refreshes (startup, resume, Account — already partially covered).
**Exit criteria**
- Logcat: prefetch POST returns **200** (or non-expired 4xx) when user has not opened the app for several **minutes** after configure.
- Endorser accepts **`exp`** far enough in the future (coordinate TTL policy).
---
### Phase B — Token pool (size 100; driven by `expiryDays + buffer`)
**Why**
- Endorser may **reject duplicate JWT strings** (same bearer used twice). One long-lived token could fail on **day 2** if the server marks each JWT as consumed.
- A **pool** of **N** distinct JWTs (different payload, e.g. unique `jti` per token) gives **N** independent strings with the same long **`exp`**. **N** should follow **§2.1** (`expiryDays + buffer`); **100** is the initial **`BACKGROUND_JWT_POOL_SIZE`** (satisfies e.g. 90 + 10).
**Scope**
1. **Constants** (single place, e.g. `src/constants/backgroundJwt.ts` or next to native fetcher config):
```text
BACKGROUND_JWT_EXPIRY_DAYS = 90 // align with Endorser; drives exp
BACKGROUND_JWT_EXPIRY_SECONDS = 90 * 24 * 60 * 60 // derived
BACKGROUND_JWT_POOL_BUFFER = 10 // retries / headroom; tune with server team
BACKGROUND_JWT_POOL_SIZE = 100 // must be >= expiryDays + buffer; adjust if policy changes
```
2. **Mint in TS** (uses `createEndorserJwtForDid`):
- Loop `i = 0 .. POOL_SIZE - 1`
- Payload: `{ iss, iat, exp, jti: `${did}#bg#${i}` or uuid }` — **confirm** `jti` format with Endorser if required.
3. **Persistence** — native code must read the pool **without JS**:
- **Option B1 (preferred):** Implement in **`@timesafari/daily-notification-plugin`** (not in the app): extend **`configureNativeFetcher`** to accept an optional JWT pool, persist it for native read. **Handoff spec:** `doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md` — copy or reference that file in the plugin repo PR.
- **Option B2 (app-only, no plugin release):** Write JSON to **Capacitor Preferences** or **encrypted storage** from TS; **TimeSafariNativeFetcher** reads the same store on Android (requires knowing Capacitor’s Android `SharedPreferences` name/key convention or a tiny **bridge** in `MainActivity`). Use only if plugin work is deferred.
4. **Selection policy in `TimeSafariNativeFetcher`** (before each POST):
- **By calendar day:** `index = (epochDay + offset) % POOL_SIZE` (stable per day).
- Or **sequential:** persist `lastUsedIndex` in prefs and increment (wrap). **Decision:** document chosen policy; day-based is easier to reason about for “one token per day.”
5. **configureNativeFetcherIfReady** (and any “reset notifications on startup” hook):
- Regenerate full pool when user opens app (per product decision), then call configure with pool + **current** `apiBaseUrl` / `did`.
6. **iOS:** When iOS native fetcher exists, mirror Android behavior.
**Exit criteria**
- Prefetch succeeds on **consecutive days** with duplicate-JWT enforcement enabled on a **staging** Endorser.
- Pool **refreshes** on startup without breaking dual schedule.
---
## 4. Detailed tasks (checklist)
### Crypto & TypeScript
- [ ] Add `BACKGROUND_JWT_EXPIRY_DAYS`, `BACKGROUND_JWT_EXPIRY_SECONDS`, `BACKGROUND_JWT_POOL_BUFFER`, and `BACKGROUND_JWT_POOL_SIZE` (exported constants), with a **comment** that `POOL_SIZE >= expiryDays + buffer` (see §2.1).
- [ ] Implement `mintBackgroundJwtPool(did: string): Promise<string[]>` (or split single + pool).
- [ ] Ensure each JWT has **unique** `jti` (or equivalent) for duplicate detection.
- [ ] **Do not** break existing `accessToken()` 60s behavior for unrelated features.
- [ ] Wire `configureNativeFetcherIfReady` to pass **single extended token** (Phase A) then **pool** (Phase B).
- [ ] On **logout / identity clear**, clear persisted pool and call plugin clear if needed.
### Android
- [ ] **Phase A:** No structural change if `configure()` still receives one string; verify non-null `jwtToken` after configure.
- [ ] **Phase B:** Parse pool from persisted JSON; implement `selectTokenForRequest()`; use selected token in `Authorization` header instead of sole `jwtToken` field (keep `configure` for `apiBaseUrl` / `did`).
- [ ] Unit or instrumentation tests optional: selection index deterministic.
### Plugin (Option B1 — **daily-notification-plugin** repo)
- [ ] Follow **`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`** (API shape, Android/iOS, versioning).
- [ ] Release new plugin version; bump dependency in this app.
### Product & server
- [ ] Endorser: confirm **max `exp`**, **duplicate JWT** semantics, recommended **`jti`** format.
- [ ] Document operational limit: if user never opens app for **longer than `exp` allows** (or longer than **pool × daily use** without refresh), prefetch may fail until next open — align with `doc/endorser-jwt-background-prefetch-options.md`.
---
## 5. Security notes
- Longer-lived JWTs and **many** tokens increase impact if device is compromised. Mitigations: **encrypted prefs** where possible, **no logging** of full JWTs, **revocation** story with Endorser (key rotation, deny list).
- Pool regeneration on **login** should replace old pools.
---
## 6. Testing plan
| Test | Expected |
|------|----------|
| Configure → wait **> 5 min** → prefetch | **200** from `plansLastUpdatedBetween` (Phase A) |
| Two consecutive **days** with duplicate-JWT staging | **200** both days (Phase B) |
| Logout | Pool cleared; no stale bearer |
| Lower `BACKGROUND_JWT_POOL_SIZE` in dev only (below `expiryDays + buffer`) | Expect possible reuse / server duplicate errors — use to reproduce failures |
---
## 7. Rollout / staging
1. Implement Phase A behind feature flag **optional** (or direct if low risk).
2. Verify on **test-api.endorser.ch** with server team.
3. Phase B behind flag or same release once server duplicate rules are understood.
---
## 8. Where plugin documentation lives
| Document | Purpose |
|----------|---------|
| **`doc/plan-background-jwt-pool-and-expiry.md`** (this file) | End-to-end app plan: crypto, pool sizing, native host, rollout. |
| **`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`** | **Plugin-only** handoff: extend `configureNativeFetcher`, persist pool, Android/iOS notes — intended for PRs in **daily-notification-plugin** (or Cursor on that repo). |
Keeping them **separate** avoids mixing consumer app tasks with plugin API contract; the plan **links** to the plugin feedback doc for Option B1.
---
## 9. References
| Topic | Location |
|--------|----------|
| Current 60s `accessToken` | `src/libs/crypto/index.ts` |
| `createEndorserJwtForDid` | `src/libs/endorserServer.ts` |
| Native configure | `src/services/notifications/nativeFetcherConfig.ts` |
| Android HTTP | `android/.../TimeSafariNativeFetcher.java` |
| Options doc (TTL, refresh, BFF) | `doc/endorser-jwt-background-prefetch-options.md` |
| Plugin: `configureNativeFetcher` + JWT pool | `doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md` |
---
*Update this plan when Phase A/B ship or when Endorser policy changes.*
@@ -3,7 +3,7 @@
**Date:** 2026-03-27 PST
**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin)
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Related app plan:** `doc/plan-background-jwt-pool-and-expiry.md` (Phase B, Option B1)
**Related app plan:** `doc/background-jwt-pool.md`
---
@@ -85,7 +85,7 @@ When `configureNativeFetcher` exists on iOS, mirror Android: accept optional poo
| Topic | Location |
|--------|----------|
| End-to-end plan (Phase A/B, pool sizing) | `doc/plan-background-jwt-pool-and-expiry.md` |
| Pool design, slot ordering, lifecycle | `doc/background-jwt-pool.md` |
| Android fetcher | `android/.../TimeSafariNativeFetcher.java` |
| Current configure call | `src/services/notifications/nativeFetcherConfig.ts` |
| JWT options (expired token context) | `doc/endorser-jwt-background-prefetch-options.md` |
+30
View File
@@ -0,0 +1,30 @@
# SMS Registration
All text providers now require 10DLC registration, which is a horrendous process. (I can refer you to others who have also found the process to be a nightmare. I just tried to look up docs on the official pages and found broken links... cool.)
The functionality here mirrors the server-push FCM functionality. We're taking this approach as well because A) iOS client-side notifications are unreliable, and B) some users prefer to get text messages.
## Details on iOS client-side problems
iOS in particular makes it impossible to guarantee that the user will get notifications,
even if we separate the data-fetch from the user-notify as designed in the daily-notification-plugin
You can see more details here: https://chatgpt.com/share/69e601ea-6434-8398-8d28-f1a3118f86ad
... which explains:
```
That implies one of these patterns:
- Polling (setInterval / timers / background fetch)
- Service worker / PWA background sync
- App wake-up logic (foreground or semi-background)
All three are fragile or outright blocked on iOS.
Unlike Android, iOS has these restrictions:
- No persistent timers when app is backgrounded
- No reliable background fetch at exact times
- No service worker push for non-installed PWAs (and even then, limited)
- No “wake up at X time and run JS”
```
+3
View File
@@ -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
```
+4 -4
View File
@@ -1,6 +1,6 @@
{
"appId": "app.timesafari",
"appName": "TimeSafari",
"appName": "Giftopia",
"webDir": "dist",
"server": {
"cleartext": true
@@ -34,12 +34,12 @@
"iosIsEncryption": false,
"iosBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"androidIsEncryption": false,
"androidBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"electronIsEncryption": false
}
@@ -72,7 +72,7 @@
},
"buildOptions": {
"appId": "app.timesafari",
"productName": "TimeSafari",
"productName": "Giftopia",
"directories": {
"output": "dist-electron-packages"
},
+2 -1
View File
@@ -17,6 +17,7 @@ App/App/config.xml
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
App/App.xcodeproj/*.xcuserstate
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md)
# Generated by capacitor-assets at build time (not in repo). Fresh clones lack these
# folders; scripts/common.sh ensure_ios_capacitor_asset_directories creates them before generate.
App/App/Assets.xcassets/AppIcon.appiconset
App/App/Assets.xcassets/Splash.imageset
+22 -20
View File
@@ -177,7 +177,7 @@
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
3FE25897CF40A571D4AC2ACE /* [CP] Copy Pods Resources */,
2B3F98670AF3508A35AC3248 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -294,19 +294,19 @@
shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" \n";
showEnvVarsInLog = 0;
};
3FE25897CF40A571D4AC2ACE /* [CP] Copy Pods Resources */ = {
2B3F98670AF3508A35AC3248 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
name = "[CP] Embed Pods Frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-resources.sh\"\n";
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
92977BEA1068CC097A57FC77 /* [CP] Check Pods Manifest.lock */ = {
@@ -455,7 +455,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -512,7 +512,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -530,17 +530,18 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.4.4;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -559,17 +560,18 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.4.4;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -587,21 +589,21 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.4.4;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
@@ -625,21 +627,21 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.4.4;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
+1 -1
View File
@@ -159,7 +159,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
// Note: Share Extension opens app with timesafari:// (empty path), which is handled by JavaScript
// Note: Share Extension opens app with timesafari://shared-photo, which is handled by JavaScript
// via the appUrlOpen listener in main.capacitor.ts
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "splash@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "splash@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "splash@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

+1 -1
View File
@@ -12,7 +12,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>TimeSafari</string>
<string>Giftopia</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
+9 -1
View File
@@ -38,7 +38,15 @@ final class TimeSafariNativeFetcher: NativeNotificationContentFetcher {
try await fetchContentWithRetry(context: context, retryCount: 0)
}
/// One pool entry per UTC day (epoch day mod pool size); else primary `jwtToken` — same as Java.
/// Picks the pool entry whose validity window covers today, falling back to the
/// primary `jwtToken` when no pool is configured. Same arithmetic as Java.
///
/// Each pooled JWT carries nbf/exp spanning exactly one UTC day, and the minter
/// (`mintBackgroundJwtTokenPool`) files the token for a given day at index
/// `epochDay % size`. That is why the index below is the raw epoch day rather than
/// a count from when the pool arrived: this side keeps no mint date, and the same
/// arithmetic on both ends is what lines the slot up with the day it covers.
/// A token read from the wrong slot is outside its window and Endorser rejects it.
private func selectBearerTokenForRequest() -> String? {
guard let pool = jwtTokenPool, !pool.isEmpty else { return jwtToken }
let epochDay = Int64(Date().timeIntervalSince1970 * 1000) / (24 * 60 * 60 * 1000)
+6 -4
View File
@@ -1,8 +1,7 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '13.0'
# Static linkage helps isolate SQLCipher from Apple's system SQLite module/headers.
use_frameworks! :linkage => :static
platform :ios, '15.5'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
# Product -> Clean Build Folder after new Cordova plugins installed
@@ -83,7 +82,10 @@ 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'
deployment_target = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f
if deployment_target > 0.0 && deployment_target < 15.5
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5'
end
config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
merge_sqlite_omit_load_extension_definition(config)
strip_system_sqlite_from_pod_config(config)
+79 -89
View File
@@ -1,89 +1,79 @@
PODS:
- Capacitor (6.2.1):
- Capacitor (7.6.4):
- CapacitorCordova
- CapacitorApp (6.0.2):
- CapacitorApp (7.1.2):
- Capacitor
- CapacitorCamera (6.1.2):
- CapacitorCamera (7.0.5):
- Capacitor
- CapacitorClipboard (6.0.2):
- CapacitorClipboard (7.0.4):
- Capacitor
- CapacitorCommunitySqlite (6.0.2):
- CapacitorCommunitySqlite (7.0.3):
- Capacitor
- SQLCipher
- ZIPFoundation
- CapacitorCordova (6.2.1)
- CapacitorFilesystem (6.0.3):
- CapacitorCordova (7.6.4)
- CapacitorFilesystem (7.1.8):
- Capacitor
- CapacitorMlkitBarcodeScanning (6.2.0):
- IONFilesystemLib (~> 1.1.1)
- CapacitorMlkitBarcodeScanning (7.5.0):
- Capacitor
- GoogleMLKit/BarcodeScanning (= 5.0.0)
- CapacitorPreferences (6.0.4):
- GoogleMLKit/BarcodeScanning (= 7.0.0)
- CapacitorPreferences (7.0.4):
- Capacitor
- CapacitorPushNotifications (6.0.5):
- CapacitorPushNotifications (7.0.7):
- Capacitor
- CapacitorShare (6.0.3):
- CapacitorShare (7.0.4):
- Capacitor
- CapacitorStatusBar (6.0.2):
- CapacitorStatusBar (7.0.6):
- Capacitor
- CapawesomeCapacitorFilePicker (6.2.0):
- CapawesomeCapacitorFilePicker (7.2.0):
- Capacitor
- GoogleDataTransport (9.4.1):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30911.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleMLKit/BarcodeScanning (5.0.0):
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleMLKit/BarcodeScanning (7.0.0):
- GoogleMLKit/MLKitCore
- MLKitBarcodeScanning (~> 4.0.0)
- GoogleMLKit/MLKitCore (5.0.0):
- MLKitCommon (~> 10.0.0)
- GoogleToolboxForMac/DebugUtils (2.3.2):
- GoogleToolboxForMac/Defines (= 2.3.2)
- GoogleToolboxForMac/Defines (2.3.2)
- GoogleToolboxForMac/Logger (2.3.2):
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSData+zlib (2.3.2)":
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSDictionary+URLArguments (2.3.2)":
- GoogleToolboxForMac/DebugUtils (= 2.3.2)
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSString+URLArguments (= 2.3.2)"
- "GoogleToolboxForMac/NSString+URLArguments (2.3.2)"
- GoogleUtilities/Environment (7.13.3):
- MLKitBarcodeScanning (~> 6.0.0)
- GoogleMLKit/MLKitCore (7.0.0):
- MLKitCommon (~> 12.0.0)
- GoogleToolboxForMac/Defines (4.2.1)
- GoogleToolboxForMac/Logger (4.2.1):
- GoogleToolboxForMac/Defines (= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
- GoogleToolboxForMac/Defines (= 4.2.1)
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.13.3):
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (7.13.3)
- GoogleUtilities/UserDefaults (7.13.3):
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilitiesComponents (1.1.0):
- GoogleUtilities/Logger
- GTMSessionFetcher/Core (3.5.0)
- MLImage (1.0.0-beta5)
- MLKitBarcodeScanning (4.0.0):
- MLKitCommon (~> 10.0)
- MLKitVision (~> 6.0)
- MLKitCommon (10.0.0):
- GoogleDataTransport (~> 9.0)
- GoogleToolboxForMac/Logger (~> 2.1)
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
- "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)"
- GoogleUtilities/UserDefaults (~> 7.0)
- GoogleUtilitiesComponents (~> 1.0)
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
- MLKitVision (6.0.0):
- GoogleToolboxForMac/Logger (~> 2.1)
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
- MLImage (= 1.0.0-beta5)
- MLKitCommon (~> 10.0)
- nanopb (2.30910.0):
- nanopb/decode (= 2.30910.0)
- nanopb/encode (= 2.30910.0)
- nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0)
- IONFilesystemLib (1.1.2)
- MLImage (1.0.0-beta6)
- MLKitBarcodeScanning (6.0.0):
- MLKitCommon (~> 12.0)
- MLKitVision (~> 8.0)
- MLKitCommon (12.0.0):
- GoogleDataTransport (~> 10.0)
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GoogleUtilities/Logger (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLKitVision (8.0.0):
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLImage (= 1.0.0-beta6)
- MLKitCommon (~> 12.0)
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- PromisesObjC (2.4.0)
- SQLCipher (4.10.0):
- SQLCipher/standard (= 4.10.0)
@@ -116,8 +106,8 @@ SPEC REPOS:
- GoogleMLKit
- GoogleToolboxForMac
- GoogleUtilities
- GoogleUtilitiesComponents
- GTMSessionFetcher
- IONFilesystemLib
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
@@ -158,35 +148,35 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@timesafari/daily-notification-plugin"
SPEC CHECKSUMS:
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79
CapacitorClipboard: 4443c3cdb7c77b1533dfe3ff0f9f7756aa8579df
CapacitorCommunitySqlite: 0299d20f4b00c2e6aa485a1d8932656753937b9b
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e
CapacitorPreferences: 5848e0691b36b4bb4acc98e481ab56d451578d30
CapacitorPushNotifications: 35abece14371c57172e8321c9ccc8b6fa35fabfe
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e
CapacitorStatusBar: b16799a26320ffa52f6c8b01737d5a95bbb8f3eb
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleMLKit: 90ba06e028795a50261f29500d238d6061538711
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
Capacitor: 69dc07ebc6bd064747c5e76922f97e4862d9cc23
CapacitorApp: f01a913211780e0718dae9750442c3e23f96e106
CapacitorCamera: 9e952270be355797f769aa835bb7643a96c871fe
CapacitorClipboard: d1f123674cf413125db816a45e8f70e8770972fc
CapacitorCommunitySqlite: 4813d82ad33001e612a39d313cb5d28066cbafda
CapacitorCordova: e343e95a672ff73e21a77a80257b52fb609b47d5
CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
CapacitorMlkitBarcodeScanning: afd6fc431b550026a2c052e11ab2b71c7ae30011
CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca
CapacitorPushNotifications: 0527809a9619ed775439d5ab2c2d996122b48319
CapacitorShare: 25f7fc5dd0e4edbde5d6801c6de5d14a8b450a41
CapacitorStatusBar: 416e9e53fd6397e668d4a181cd2131617d949bd6
CapawesomeCapacitorFilePicker: 0f4a913a00e39dd77213449f0d917e92f35a5ca9
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c
MLKitBarcodeScanning: 9cb0ec5ec65bbb5db31de4eba0a3289626beab4e
MLKitCommon: afcd11b6c0735066a0dde8b4bf2331f6197cbca2
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79
nanopb: 438bc412db1928dac798aa6fd75726007be04262
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 3a6079307b3952d27d8dbfc0ce9abb523ecce7f0
PODFILE CHECKSUM: 5736811d271d5309d3e2de8f3eefdbb6632086a3
COCOAPODS: 1.16.2
@@ -46,7 +46,7 @@ class ShareViewController: UIViewController {
if success {
// Set flag that shared photo is ready
self.setSharedPhotoReadyFlag()
// Open the main app (using minimal URL - app will detect shared data on activation)
// Open the main app at its shared-photo deep link.
self.openMainApp()
}
@@ -186,8 +186,7 @@ class ShareViewController: UIViewController {
}
private func openMainApp() {
// Open the main app with minimal URL - app will detect shared data on activation
guard let url = URL(string: "timesafari://") else {
guard let url = URL(string: "timesafari://shared-photo") else {
return
}
+3
View File
@@ -1,6 +1,9 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
// Unit tests live under src/. test-playwright/ holds Playwright specs,
// which use a different runner and fail if Jest collects them.
roots: ['<rootDir>/src'],
moduleFileExtensions: ['ts', 'js', 'json', 'vue'],
transform: {
'^.+\\.ts$': 'ts-jest'
+5030 -4673
View File
File diff suppressed because it is too large Load Diff
+25 -22
View File
@@ -1,19 +1,21 @@
{
"name": "timesafari",
"version": "1.4.1-beta",
"description": "Gift Economies Application",
"name": "giftopia",
"version": "1.4.4",
"description": "Giftopia App",
"author": {
"name": "Gift Economies Team"
},
"scripts": {
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"type-safety-check": "./scripts/type-safety-check.sh",
"type-check": "tsc --noEmit",
"type-check:vue": "vue-tsc --noEmit",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
"test:prerequisites": "node scripts/check-prerequisites.js",
"test:unit": "jest",
"check:dependencies": "./scripts/check-dependencies.sh",
"test:all": "npm run lint && tsc && npm run test:web && npm run test:mobile && ./scripts/test-safety-check.sh && echo '\n\n\nGotta add the performance tests'",
"deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
"test:all": "npm run lint && npm run type-check && npm run type-check:vue && npm run test:unit && npm run test:web && npm run test:mobile && echo '\n\n\nGotta add the performance tests'",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on",
"test:mobile": "./scripts/test-mobile.sh",
"test:android": "node scripts/test-android.js",
@@ -28,7 +30,7 @@
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && npx capacitor-assets generate",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && bash -c 'source scripts/common.sh && ensure_ios_capacitor_asset_directories' && npx capacitor-assets generate",
"assets:config": "npx tsx scripts/assets-config.ts",
"assets:validate": "npx tsx scripts/assets-validator.ts",
"assets:validate:android": "./scripts/build-android.sh --assets-only",
@@ -138,21 +140,21 @@
},
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "^6.0.2",
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
"@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0",
"@capacitor/camera": "^6.0.0",
"@capacitor/cli": "^6.2.0",
"@capacitor/clipboard": "^6.0.2",
"@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",
"@capacitor-community/sqlite": "^7.0.3",
"@capacitor-mlkit/barcode-scanning": "^7.5.0",
"@capacitor/android": "^7.6.4",
"@capacitor/app": "^7.1.0",
"@capacitor/camera": "^7.0.5",
"@capacitor/cli": "^7.6.4",
"@capacitor/clipboard": "^7.0.4",
"@capacitor/core": "^7.6.4",
"@capacitor/filesystem": "^7.1.8",
"@capacitor/ios": "^7.6.4",
"@capacitor/preferences": "^7.0.4",
"@capacitor/push-notifications": "^7.0.7",
"@capacitor/share": "^7.0.4",
"@capacitor/status-bar": "^7.0.6",
"@capawesome/capacitor-file-picker": "^7.2.0",
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0",
@@ -282,6 +284,7 @@
"ts-jest": "^29.4.0",
"tsx": "^4.20.4",
"typescript": "~5.2.2",
"vite": "^5.2.0"
"vite": "^5.2.0",
"vue-tsc": "^2.1.10"
}
}
+4 -1
View File
@@ -56,7 +56,10 @@ npx capacitor-assets generate --web
## Configuration
Asset generation is configured in `capacitor-assets.config.json` at the project root.
`resources/` is this project's canonical asset source. `@capacitor/assets`
prioritizes a top-level `assets/` directory over `resources/`, so a legacy
`assets/` directory can prevent these assets from being discovered. Remove that
directory when it is empty or obsolete.
## Version Control
Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 624 KiB

+2 -2
View File
@@ -92,7 +92,7 @@ function generateAssetConfig(): AssetConfig {
},
splash: {
source: "resources/splash.png",
darkSource: "resources/splash_dark.png",
darkSource: "resources/splash-dark.png",
android: {
scale: "cover",
target: "android/app/src/main/res"
@@ -138,7 +138,7 @@ function validateSourceFiles(): void {
const requiredFiles = [
'resources/icon.png',
'resources/splash.png',
'resources/splash_dark.png'
'resources/splash-dark.png'
];
const missingFiles = requiredFiles.filter(file => {
+19 -19
View File
@@ -230,8 +230,8 @@ validate_android_assets() {
missing_assets+=("resources/splash.png")
fi
if [ ! -f "resources/splash_dark.png" ]; then
missing_assets+=("resources/splash_dark.png")
if [ ! -f "resources/splash-dark.png" ]; then
missing_assets+=("resources/splash-dark.png")
fi
if [ ${#missing_assets[@]} -gt 0 ]; then
@@ -278,14 +278,14 @@ validate_android_assets() {
# Copy source assets to assets directory for capacitor-assets
cp resources/icon.png assets/ 2>/dev/null || log_warn "Could not copy icon.png"
cp resources/splash.png assets/ 2>/dev/null || log_warn "Could not copy splash.png"
cp resources/splash_dark.png assets/ 2>/dev/null || log_warn "Could not copy splash_dark.png"
cp resources/splash-dark.png assets/ 2>/dev/null || log_warn "Could not copy splash-dark.png"
# Generate assets
if npx @capacitor/assets generate >/dev/null 2>&1; then
log_success "Android assets regenerated successfully"
# Clean up temporary assets
rm -f assets/icon.png assets/splash.png assets/splash_dark.png
rm -f assets/icon.png assets/splash.png assets/splash-dark.png
# Verify the resources were created
local verification_failed=false
@@ -492,21 +492,6 @@ log_info "Build type: $BUILD_TYPE"
# Setup environment for Capacitor build
setup_build_env "capacitor" "$BUILD_MODE"
# Override API servers for Android development
if [ "$BUILD_MODE" = "development" ]; then
if [ -n "$CUSTOM_API_IP" ]; then
# Use custom IP for physical device development
export VITE_DEFAULT_ENDORSER_API_SERVER="http://${CUSTOM_API_IP}:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://${CUSTOM_API_IP}:3000"
log_info "Android development mode: Using custom IP ${CUSTOM_API_IP} for physical device"
else
# Use Android emulator IP (10.0.2.2) for Android development
export VITE_DEFAULT_ENDORSER_API_SERVER="http://10.0.2.2:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://10.0.2.2:3000"
log_debug "Android development mode: Using 10.0.2.2 for emulator"
fi
fi
# Setup application directories
setup_app_directories
@@ -523,6 +508,21 @@ if [ -f ".env" ]; then
load_env_file ".env"
fi
# Override API servers for Android development
if [ "$BUILD_MODE" = "development" ]; then
if [ -n "$CUSTOM_API_IP" ]; then
# Use custom IP for physical device development
export VITE_DEFAULT_ENDORSER_API_SERVER="http://${CUSTOM_API_IP}:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://${CUSTOM_API_IP}:3000"
log_info "Android development mode: Using custom IP ${CUSTOM_API_IP} for physical device"
else
# Use Android emulator IP (10.0.2.2) for Android development
export VITE_DEFAULT_ENDORSER_API_SERVER="http://10.0.2.2:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://10.0.2.2:3000"
log_debug "Android development mode: Using 10.0.2.2 for emulator"
fi
fi
# Handle clean-only mode
if [ "$CLEAN_ONLY" = true ]; then
log_info "Clean-only mode: cleaning build artifacts"
+143 -19
View File
@@ -172,12 +172,12 @@ check_ios_resources() {
log_info "Checking iOS resources..."
# Check for required assets
if [ ! -f "assets/icon.png" ]; then
log_warn "App icon not found at assets/icon.png"
if [ ! -f "resources/icon.png" ]; then
log_warn "App icon not found at resources/icon.png"
fi
if [ ! -f "assets/splash.png" ]; then
log_warn "Splash screen not found at assets/splash.png"
if [ ! -f "resources/splash.png" ]; then
log_warn "Splash screen not found at resources/splash.png"
fi
# Check for iOS-specific files
@@ -192,6 +192,118 @@ check_ios_resources() {
log_success "iOS resource check completed"
}
# iOS app icon appearance variants (Dark, Tinted, …).
# Luminosity appearance values match Apple's asset catalog format (iOS 18+).
readonly _IOS_APP_ICON_APPEARANCE_LUMINOSITY="luminosity"
readonly _IOS_APP_ICON_APPEARANCE_DARK="dark"
readonly _IOS_APP_ICON_APPEARANCE_TINTED="tinted"
# Each row: source_file|luminosity_value|dest_filename
# To add a variant: append one row and place the source PNG under resources/ios/.
readonly _IOS_APP_ICON_APPEARANCE_VARIANTS=(
"resources/ios/icon/icon-dark.png|${_IOS_APP_ICON_APPEARANCE_DARK}|AppIcon-Dark.png"
"resources/ios/icon/icon-tinted.png|${_IOS_APP_ICON_APPEARANCE_TINTED}|AppIcon-Tinted.png"
)
# Update AppIcon.appiconset/Contents.json with one 1024×1024 appearance entry.
# Preserves all other images (including capacitor-assets output); replaces any
# existing entry for the same luminosity appearance.
_update_appicon_contents_for_appearance() {
local contents_json="$1"
local dest_filename="$2"
local luminosity_value="$3"
local tmp_file
tmp_file="$(mktemp)"
if ! jq --arg filename "$dest_filename" \
--arg appearance "$_IOS_APP_ICON_APPEARANCE_LUMINOSITY" \
--arg value "$luminosity_value" \
'
.images |= map(select((.appearances[0].value // "") != $value))
| .images += [{
"appearances": [{
"appearance": $appearance,
"value": $value
}],
"filename": $filename,
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}]
' "$contents_json" > "$tmp_file"; then
rm -f "$tmp_file"
log_error "Failed to update AppIcon Contents.json for appearance: $luminosity_value"
return 1
fi
mv "$tmp_file" "$contents_json"
}
# Install one appearance variant when its source PNG exists.
_apply_ios_app_icon_appearance_variant() {
local appiconset_dir="$1"
local contents_json="$2"
local source_file="$3"
local luminosity_value="$4"
local dest_filename="$5"
if [ ! -f "$source_file" ]; then
log_info "iOS app icon appearance variant not found ($source_file) — skipping"
return 0
fi
if [ ! -d "$appiconset_dir" ]; then
log_warn "AppIcon.appiconset not found — skipping appearance variant ($luminosity_value)"
return 0
fi
if ! cp "$source_file" "$appiconset_dir/$dest_filename"; then
log_error "Failed to copy $source_file to $appiconset_dir/$dest_filename"
return 1
fi
log_info "Installed iOS app icon appearance variant: $luminosity_value ($dest_filename)"
_update_appicon_contents_for_appearance "$contents_json" "$dest_filename" "$luminosity_value"
}
# Post-process capacitor-assets iOS icons: copy optional Dark/Tinted sources and
# register them in AppIcon.appiconset/Contents.json.
apply_ios_app_icon_appearances() {
local appiconset_dir="ios/App/App/Assets.xcassets/AppIcon.appiconset"
local contents_json="$appiconset_dir/Contents.json"
if [ ! -f "$contents_json" ]; then
log_info "AppIcon Contents.json not found — skipping appearance variants"
return 0
fi
if ! command -v jq &> /dev/null; then
log_error "jq is required to install iOS app icon appearance variants (install with: brew install jq)"
return 1
fi
local variant source_file luminosity_value dest_filename
for variant in "${_IOS_APP_ICON_APPEARANCE_VARIANTS[@]}"; do
IFS='|' read -r source_file luminosity_value dest_filename <<< "$variant"
_apply_ios_app_icon_appearance_variant \
"$appiconset_dir" "$contents_json" \
"$source_file" "$luminosity_value" "$dest_filename"
done
}
# Generate iOS assets (capacitor-assets), then apply optional appearance variants.
generate_ios_assets() {
ensure_ios_capacitor_asset_directories
if [ -d "assets" ]; then
log_warn "@capacitor/assets prioritizes the top-level assets/ directory over resources/."
log_warn "This project intentionally uses resources/ as the canonical asset source."
log_warn "Remove the legacy assets/ directory if it is empty or obsolete."
fi
npx capacitor-assets generate --ios
apply_ios_app_icon_appearances
}
# Function to clean iOS build
clean_ios_build() {
log_info "Cleaning iOS build artifacts..."
@@ -222,8 +334,9 @@ build_ios_app() {
if [ "$BUILD_TYPE" = "debug" ]; then
build_config="Debug"
# 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"
# Use device SDK — prebuilt MLKit frameworks (MLImage, MLKitBarcodeScanning) ship
# iOS device slices only and cannot link against the iOS Simulator SDK.
destination="generic/platform=iOS"
else
build_config="Release"
destination="platform=iOS,id=auto"
@@ -233,10 +346,17 @@ build_ios_app() {
cd ios/App
# Prevent pkgx-managed libs (e.g. zlib) from leaking into the iOS SDK linker.
unset LIBRARY_PATH
unset DYLD_LIBRARY_PATH
unset DYLD_FALLBACK_LIBRARY_PATH
# 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.
local build_exit=0
xcodebuild -quiet \
-workspace App.xcworkspace \
-scheme "$scheme" \
@@ -247,10 +367,14 @@ build_ios_app() {
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
SWIFT_SUPPRESS_WARNINGS=YES \
GCC_WARN_INHIBIT_ALL_WARNINGS=YES
GCC_WARN_INHIBIT_ALL_WARNINGS=YES || build_exit=$?
cd ../..
if [ $build_exit -ne 0 ]; then
return $build_exit
fi
log_success "iOS app built successfully"
}
@@ -320,14 +444,6 @@ log_info "Build type: $BUILD_TYPE"
# Setup environment for Capacitor build
setup_build_env "capacitor" "$BUILD_MODE"
# Override API servers for iOS development when custom IP is specified
if [ "$BUILD_MODE" = "development" ] && [ -n "$CUSTOM_API_IP" ]; then
# Use custom IP for physical device development
export VITE_DEFAULT_ENDORSER_API_SERVER="http://${CUSTOM_API_IP}:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://${CUSTOM_API_IP}:3000"
log_info "iOS development mode: Using custom IP ${CUSTOM_API_IP} for physical device"
fi
# Setup application directories
setup_app_directories
@@ -344,6 +460,14 @@ if [ -f ".env" ]; then
load_env_file ".env"
fi
# Override API servers for iOS development when custom IP is specified
if [ "$BUILD_MODE" = "development" ] && [ -n "$CUSTOM_API_IP" ]; then
# Use custom IP for physical device development
export VITE_DEFAULT_ENDORSER_API_SERVER="http://${CUSTOM_API_IP}:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://${CUSTOM_API_IP}:3000"
log_info "iOS development mode: Using custom IP ${CUSTOM_API_IP} for physical device"
fi
# Validate iOS environment
validate_ios_environment
@@ -413,7 +537,7 @@ fi
# Handle assets-only mode
if [ "$ASSETS_ONLY" = true ]; then
log_info "Assets-only mode: generating assets"
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
safe_execute "Generating assets" "generate_ios_assets" || exit 7
log_success "Assets generation completed successfully!"
exit 0
fi
@@ -562,7 +686,7 @@ safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaroun
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
# Step 7: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
safe_execute "Generating assets" "generate_ios_assets" || exit 7
# Step 8: Build iOS app
safe_execute "Building iOS app" "build_ios_app" || exit 5
+33 -1
View File
@@ -183,6 +183,16 @@ setup_build_env() {
export VITE_GIT_HASH="$git_hash"
log_debug "Set VITE_GIT_HASH=$git_hash"
# Vite derives import.meta.env.DEV/PROD from NODE_ENV, not from --mode.
# Without this, every native build reports DEV=false and loads the
# production .env file in vite.config.common.mts.
case "$build_mode" in
"production") export NODE_ENV=production ;;
"test") export NODE_ENV=test ;;
*) export NODE_ENV=development ;;
esac
log_debug "Set NODE_ENV=$NODE_ENV"
case $build_type in
"capacitor")
export VITE_PLATFORM=capacitor
@@ -337,6 +347,27 @@ parse_args() {
fi
}
# iOS: capacitor-assets writes into AppIcon.appiconset and Splash.imageset under
# Assets.xcassets. Those paths are gitignored (generated). On a fresh clone the
# folders and Contents.json are missing; the tool opens Contents.json before writing
# PNGs, so we create minimal asset-catalog stubs when absent.
ensure_ios_capacitor_asset_directories() {
local base="ios/App/App/Assets.xcassets"
if [ ! -d "$base" ]; then
log_warn "Missing $base — cannot prepare iOS asset directories"
return 0
fi
mkdir -p "$base/AppIcon.appiconset" "$base/Splash.imageset"
local minimal_contents='{"images":[],"info":{"author":"xcode","version":1}}'
if [ ! -f "$base/AppIcon.appiconset/Contents.json" ]; then
printf '%s\n' "$minimal_contents" > "$base/AppIcon.appiconset/Contents.json"
fi
if [ ! -f "$base/Splash.imageset/Contents.json" ]; then
printf '%s\n' "$minimal_contents" > "$base/Splash.imageset/Contents.json"
fi
log_debug "Ensured iOS capacitor-assets output directories exist"
}
# Export functions for use in child scripts
export -f log_info log_success log_warn log_error log_debug log_step
export -f measure_time print_header print_footer
@@ -344,4 +375,5 @@ export -f check_command check_directory check_file
export -f safe_execute check_venv get_git_hash
export -f clean_build_artifacts validate_env_vars
export -f setup_build_env setup_app_directories load_env_file print_env_vars
export -f print_usage parse_args
export -f print_usage parse_args
export -f ensure_ios_capacitor_asset_directories
-103
View File
@@ -1,103 +0,0 @@
#!/bin/bash
# Type Safety Pre-commit Check Script
# This script ensures type safety before commits by running linting and type checking
set -e
echo "🔍 Running Type Safety Pre-commit Checks..."
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}✅ $1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_error() {
echo -e "${RED}❌ $1${NC}"
}
# Check if we're in the right directory
if [ ! -f "package.json" ]; then
print_error "Must run from project root directory"
exit 1
fi
# Step 1: Run ESLint with TypeScript rules
print_status "Running ESLint TypeScript checks..."
if npm run lint > /dev/null 2>&1; then
print_status "ESLint passed - no type safety issues found"
else
print_error "ESLint failed - type safety issues detected"
echo ""
echo "Running lint with details..."
npm run lint
echo ""
print_error "Please fix the above type safety issues before committing"
exit 1
fi
# Step 2: Run TypeScript type checking
print_status "Running TypeScript type checking..."
if npm run type-check > /dev/null 2>&1; then
print_status "TypeScript compilation passed"
else
print_error "TypeScript compilation failed"
echo ""
echo "Running type check with details..."
npm run type-check
echo ""
print_error "Please fix the above TypeScript errors before committing"
exit 1
fi
# Step 3: Check for any remaining 'any' types
print_status "Scanning for any remaining 'any' types..."
ANY_COUNT=$(grep -r "any" src/ --include="*.ts" --include="*.vue" | grep -v "// eslint-disable" | grep -v "eslint-disable-next-line" | wc -l)
if [ "$ANY_COUNT" -eq 0 ]; then
print_status "No 'any' types found in source code"
else
print_warning "Found $ANY_COUNT instances of 'any' type usage"
echo ""
echo "Instances found:"
grep -r "any" src/ --include="*.ts" --include="*.vue" | grep -v "// eslint-disable" | grep -v "eslint-disable-next-line" || true
echo ""
print_error "Please replace 'any' types with proper TypeScript types before committing"
exit 1
fi
# Step 4: Verify database migration status
print_status "Checking database migration status..."
if grep -r "databaseUtil" src/ --include="*.ts" --include="*.vue" > /dev/null 2>&1; then
print_warning "Found databaseUtil imports - ensure migration is complete"
echo ""
echo "Files with databaseUtil imports:"
grep -r "databaseUtil" src/ --include="*.ts" --include="*.vue" | head -5 || true
echo ""
print_warning "Consider completing database migration to PlatformServiceMixin"
else
print_status "No databaseUtil imports found - migration appears complete"
fi
# All checks passed
echo ""
print_status "All type safety checks passed! 🎉"
print_status "Your code is ready for commit"
echo ""
echo "📚 Remember to follow the Type Safety Guidelines:"
echo " - doc/typescript-type-safety-guidelines.md"
echo " - Use proper error handling patterns"
echo " - Leverage existing type definitions"
echo " - Run 'npm run lint-fix' for automatic fixes"
exit 0
+2 -7
View File
@@ -130,6 +130,7 @@
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { SOMEONE_UNNAMED } from "@/constants/entities";
@@ -149,10 +150,7 @@ export default class BulkMembersDialog extends Vue {
@Prop({ required: true }) isOrganizer!: boolean;
// Vue notification system
$notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
// Notification system
notify!: ReturnType<typeof createNotifyHelpers>;
@@ -381,9 +379,6 @@ export default class BulkMembersDialog extends Vue {
contact,
);
if (result.success) {
if (result.embeddedRecordError) {
throw new Error(result.embeddedRecordError);
}
await this.$updateContact(member.did, { registered: true });
} else {
throw result;
+1
View File
@@ -106,6 +106,7 @@ import { Router } from "vue-router";
import * as R from "ramda";
import { NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import { logger } from "../utils/logger";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
+3 -3
View File
@@ -8,14 +8,14 @@ notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-1">
<label class="block font-semibold text-lg capitalize text-center">
{{ stepLabel }}
</label>
<!-- Toggle link for entity type selection -->
<div class="text-right mb-4">
<div class="text-center mb-4">
<button
type="button"
class="text-sm text-blue-600 hover:text-blue-800 underline font-medium"
class="text-xs text-blue-600 hover:underline uppercase"
@click="handleToggleEntityType"
>
{{ toggleLinkText }}
+1 -1
View File
@@ -135,7 +135,7 @@ export default class EntitySummaryButton extends Vue {
}
// If the entity does not have a set name, but is not the special "Unnamed", use their DID
return this.entity?.did;
return this.entity?.did ?? "";
}
/**
+20 -1
View File
@@ -450,7 +450,26 @@ export default class GiftedDialog extends Vue {
TIMEOUTS.MODAL,
);
} else {
this.safeNotify.success("That gift was recorded.", TIMEOUTS.VERY_LONG);
if (result.embeddedRecordError) {
// The claim was stored but the server could not record part of it,
// eg. the link to the project this gift came from. Reporting a plain
// success here is how such a gift comes to sit on a project page
// that never shows it.
logger.warn(
"Give recorded but part of it was not:",
result.embeddedRecordError,
);
this.safeNotify.warning(
"That gift was recorded, but some of it was not: " +
result.embeddedRecordError,
TIMEOUTS.MODAL,
);
} else {
this.safeNotify.success(
"That gift was recorded.",
TIMEOUTS.VERY_LONG,
);
}
// Show seed phrase backup reminder if needed
try {
-3
View File
@@ -43,9 +43,6 @@ export default class InfiniteScroll extends Vue {
/** Intersection Observer instance for detecting scroll position */
private observer!: IntersectionObserver;
/** Flag to track initial render state */
private isInitialRender = true;
/** Flag to prevent multiple simultaneous loading states */
private isLoading = false;
+1 -1
View File
@@ -129,7 +129,7 @@
@click="disabled ? notifyLocked() : addGroup()"
>
<font-awesome icon="plus" class="text-sm" />
New Group
New Do-Not-Pair Group
</button>
</div>
</template>
+1 -1
View File
@@ -251,7 +251,7 @@ export default class PhotoDialog extends Vue {
* Provides cached access to platform capabilities
*/
get platformCapabilities() {
return this.$platformService.getCapabilities();
return this.platformService.getCapabilities();
}
// =================================================
@@ -39,7 +39,7 @@ import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
/**
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
* ProjectSelectionDialog - Dialog for selecting a project
*
* Features:
* - EntityGrid integration for project selection
@@ -52,7 +52,7 @@ import { NotificationIface } from "../constants/app";
EntityGrid,
},
})
export default class MeetingProjectDialog extends Vue {
export default class ProjectSelectionDialog extends Vue {
/** Whether the dialog is visible */
visible = false;
+31 -5
View File
@@ -159,6 +159,13 @@ export default class PushNotificationPermission extends Vue {
pushType = "";
/** When true, dialog only returns time/message to parent; parent does cancel+schedule (avoids double schedule on edit). */
skipScheduleForOpen = false;
/**
* When true, the dialog is a time picker and nothing else: no permission
* request, no web-push subscription, no settings write. Used by delivery
* channels that carry their own schedule -- SMS sends its hour to the
* notify-api rather than arming anything on this device.
*/
timeOnlyForOpen = false;
/** When set (e.g. 10), passed to plugin for dev/test fast rollover. */
rolloverIntervalMinutesForSchedule: number | undefined = undefined;
serviceWorkerReady = false;
@@ -174,14 +181,27 @@ export default class PushNotificationPermission extends Vue {
async open(
pushType: string,
callback?: (success: boolean, time: string, message?: string) => void,
options?: { skipSchedule?: boolean; rolloverIntervalMinutes?: number },
options?: {
skipSchedule?: boolean;
rolloverIntervalMinutes?: number;
timeOnly?: boolean;
},
) {
this.callback = callback || this.callback;
this.isVisible = true;
this.pushType = pushType;
this.skipScheduleForOpen = options?.skipSchedule ?? false;
this.timeOnlyForOpen = options?.timeOnly ?? false;
this.rolloverIntervalMinutesForSchedule = options?.rolloverIntervalMinutes;
// Time-only callers never subscribe to anything, so the web-push
// handshake would only be a way to fail before showing a clock.
if (this.timeOnlyForOpen) {
this.serviceWorkerReady = true;
this.messageInput = "";
return;
}
// Native platforms: Skip web push initialization
if (this.isNativePlatform) {
logger.debug(
@@ -589,8 +609,8 @@ export default class PushNotificationPermission extends Vue {
* For native platforms, always returns true (no VAPID needed)
*/
get isSystemReady(): boolean {
if (this.isNativePlatform) {
return true; // Native doesn't need VAPID/service worker
if (this.isNativePlatform || this.timeOnlyForOpen) {
return true; // Neither needs VAPID/service worker
}
return this.serviceWorkerReady && !!this.vapidKey;
}
@@ -601,8 +621,8 @@ export default class PushNotificationPermission extends Vue {
* For native platforms, always returns true (no VAPID needed)
*/
get canShowNotificationForm(): boolean {
if (this.isNativePlatform) {
return true; // Native doesn't need VAPID/service worker
if (this.isNativePlatform || this.timeOnlyForOpen) {
return true; // Neither needs VAPID/service worker
}
return this.serviceWorkerReady && !!this.vapidKey;
}
@@ -672,6 +692,12 @@ export default class PushNotificationPermission extends Vue {
* Close only after async flow completes so success/error $notify runs while component is mounted (fixes Android).
*/
async handleTurnOnNotifications() {
if (this.timeOnlyForOpen) {
// Nothing to arm on this device; the caller owns whatever the time means.
this.callback(true, this.notificationTimeText, this.messageInput);
this.close();
return;
}
if (this.isNativePlatform) {
await this.turnOnNativeNotifications();
} else {
+72 -51
View File
@@ -121,22 +121,29 @@ import { Vue, Component } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { logger } from "@/utils/logger";
import {
normalizePhoneNumber,
registerSmsPhone,
SmsApiError,
smsErrorMessage,
verifySmsPhone,
} from "@/services/notifications/smsNotificationApi";
/**
* SmsVerificationDialog Component
*
* A two-step modal that walks the user through verifying a mobile number so
* they can opt in to SMS "New Activity" notifications:
* 1. Enter phone number -> service texts a one-time code
* 2. Enter the code -> service confirms the number belongs to them
* 1. Enter phone number -> `POST /notify-sms/phone` texts a one-time code
* 2. Enter the code -> `PUT /notify-sms/phone` confirms possession
*
* On successful verification the callback fires with (true, phoneNumber) so the
* caller can then let the user turn the SMS notification on. Cancelling fires
* the callback with (false).
* On successful verification the callback fires with (true, phoneNumber), the
* number in the E.164 form the service stores, so the caller can then let the
* user turn the SMS notification on. Cancelling fires the callback with
* (false).
*
* MOCK-UP NOTE: every back-end interaction here is stubbed. The send-code and
* verify-code steps only simulate the round-trips locally so the screens can be
* exercised end-to-end. See the TODO markers for where the notify-api calls go.
* A number this identity has already verified comes back from the POST with
* `verified: true` and no text sent, which finishes the dialog in one step.
*/
@Component({
mixins: [PlatformServiceMixin],
@@ -151,17 +158,23 @@ export default class SmsVerificationDialog extends Vue {
verifying = false;
errorMessage = "";
/** Identity the registration is scoped to; every route is per-DID. */
activeDid = "";
callback: (success: boolean, phoneNumber?: string) => void = () => {};
/**
* Opens the dialog at the phone-entry step.
* @param activeDid - identity the number is registered under
* @param aCallback - fired with (true, phoneNumber) on success, (false) on cancel
* @param prefillPhone - optional number to pre-populate (e.g. re-verifying)
*/
open(
activeDid: string,
aCallback?: (success: boolean, phoneNumber?: string) => void,
prefillPhone = "",
) {
this.activeDid = activeDid;
this.callback = aCallback || this.callback;
this.step = "phone";
this.phoneNumber = prefillPhone;
@@ -174,54 +187,76 @@ export default class SmsVerificationDialog extends Vue {
}
/**
* Validate the phone number and (STUB) ask the service to text a code.
* Validate the phone number and ask the service to text a code.
*
* The masked number the service echoes back is what the user sees; the
* normalized one is what later calls send, since the mask is not a number.
*/
async sendCode(): Promise<void> {
this.errorMessage = "";
const normalized = this.normalizePhone(this.phoneNumber);
if (!normalized) {
const normalized = normalizePhoneNumber(this.phoneNumber);
if (!normalized || normalized.replace(/\D/g, "").length < 7) {
this.errorMessage = "Please enter a valid mobile number.";
return;
}
this.sending = true;
try {
// TODO(notify-api): POST /api/sms/register { phoneNumber } -> triggers
// the verification SMS. Handle rate-limit / invalid-number responses.
await this.stubBackendCall();
const result = await registerSmsPhone(this.activeDid, normalized);
this.phoneNumber = normalized;
this.sentTo = normalized;
this.code = "";
if (result.verified) {
// Already verified under this identity: nothing was texted and there
// is no code to enter.
this.visible = false;
this.callback(true, normalized);
return;
}
this.sentTo = result.phoneNumber || normalized;
// Only a dev server with SMS_DEV_ECHO_CODE returns this; it saves a
// developer with no carrier coverage from a dead-end flow.
this.code = result.devCode || "";
this.step = "code";
} catch (error) {
logger.error("[SmsVerificationDialog] sendCode failed:", error);
this.errorMessage = "Could not send the code. Please try again.";
this.errorMessage = smsErrorMessage(
error,
"Could not send the code. Please try again.",
);
} finally {
this.sending = false;
}
}
/**
* (STUB) Ask the service to send a fresh code to the same number.
* Ask the service to send a fresh code to the same number. Sends are
* throttled to three per number per hour, so this can be refused.
*/
async resendCode(): Promise<void> {
this.errorMessage = "";
this.sending = true;
try {
// TODO(notify-api): POST /api/sms/register again to re-send the code.
await this.stubBackendCall();
this.code = "";
const result = await registerSmsPhone(this.activeDid, this.phoneNumber);
this.sentTo = result.phoneNumber || this.sentTo;
// A number already verified comes back with no code, dev or otherwise.
this.code = result.verified ? "" : result.devCode || "";
} catch (error) {
logger.error("[SmsVerificationDialog] resendCode failed:", error);
this.errorMessage = "Could not resend the code. Please try again.";
this.errorMessage = smsErrorMessage(
error,
"Could not resend the code. Please try again.",
);
} finally {
this.sending = false;
}
}
/**
* Validate the entered code and (STUB) confirm it with the service.
* Confirm the entered code with the service.
*
* A wrong code says how many tries are left; running out clears the code
* server-side, so the only way forward is a fresh send from step one.
*/
async verifyCode(): Promise<void> {
this.errorMessage = "";
@@ -232,16 +267,23 @@ export default class SmsVerificationDialog extends Vue {
this.verifying = true;
try {
// TODO(notify-api): POST /api/sms/verify { phoneNumber, code }. On a
// mismatch, show an "incorrect code" error instead of succeeding. For
// this mock any 6-digit code is accepted.
await this.stubBackendCall();
await verifySmsPhone(this.activeDid, this.phoneNumber, this.code);
this.visible = false;
this.callback(true, this.phoneNumber);
} catch (error) {
logger.error("[SmsVerificationDialog] verifyCode failed:", error);
this.errorMessage = "Could not verify the code. Please try again.";
this.errorMessage = smsErrorMessage(
error,
"Could not verify the code. Please try again.",
);
if (
error instanceof SmsApiError &&
error.code === "SMS_CODE_ATTEMPTS_EXHAUSTED"
) {
// The stored code is gone; recovery is another send, not another guess.
this.step = "phone";
this.code = "";
}
} finally {
this.verifying = false;
}
@@ -252,27 +294,6 @@ export default class SmsVerificationDialog extends Vue {
this.callback(false);
}
/**
* Loosely normalize a phone number for display. Real E.164 validation and
* formatting will happen server-side once the notify-api is wired in.
*/
private normalizePhone(raw: string): string {
const trimmed = (raw || "").trim();
const digits = trimmed.replace(/[^\d]/g, "");
if (digits.length < 7) {
return "";
}
return trimmed;
}
/**
* MOCK-UP helper standing in for a notify-api round-trip. Resolves on the
* next tick so the loading states are exercised without a real network call.
*/
private stubBackendCall(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 400));
}
get primaryButtonClasses(): string {
return "block w-full text-center text-lg font-bold uppercase 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-2 py-3 rounded-md disabled:opacity-50";
}
+19
View File
@@ -0,0 +1,19 @@
import type { Camera, Scene } from "three";
/**
* Type declarations for World.js, which is plain JavaScript.
*
* Only the members used outside the module are declared; its internals stay
* untyped.
*/
export declare class World {
camera: Camera;
scene: Scene;
constructor(container: Element | null, vue: unknown);
update(time: number): void;
render(): void;
start(): void;
stop(): void;
setExposedWorldProperties(key: string, value: unknown): void;
}
+52 -132
View File
@@ -11,7 +11,7 @@
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 (APP_SERVER)"
placeholder="Leave empty for default (DEFAULT_NOTIFY_API_SERVER)"
:disabled="busy"
@keydown.enter="onSaveBackendUrl"
/>
@@ -65,23 +65,28 @@
Register Token Now
</button>
<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"
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="onBackendRefresh"
@click="onUploadAlertAuthorization"
>
Refresh Notifications
Upload AlertSearch Authorization
</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"
<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"
>
Simulate WAKEUP_PING (Local)
</button>
<p class="text-xs text-slate-500">
Local simulation only — calls the refresh API directly (no FCM push).
{{ 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-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"
@@ -104,8 +109,8 @@
{{ realWakeupStatus.message }}
</p>
<p v-else class="text-xs text-slate-500">
Full pipeline — backend `/debug/send-wakeup` → FCM → WAKEUP_PING
handler.
FCM delivery diagnostic only — backend `/debug/send-wakeup`. The app
no longer schedules api_* notifications from WAKEUP_PING.
</p>
</div>
@@ -153,70 +158,6 @@
</div>
</div>
<!-- SECTION F: Mock Timing Presets -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Mock Timing Presets</h2>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presets"
:key="preset.ms"
class="px-3 py-2 rounded border border-slate-300 bg-white text-sm"
:class="{
'border-blue-500 ring-1 ring-blue-300': intervalMs === preset.ms,
}"
@click="intervalMs = preset.ms"
>
{{ preset.label }}
</button>
</div>
<div class="text-xs text-slate-500 mt-2">
Selected interval: <b>{{ intervalLabel }}</b>
</div>
</div>
<!-- SECTION A: Mock Refresh Controls -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Mock Refresh Controls</h2>
<button
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onMockRefresh"
>
Trigger Mock Refresh
</button>
</div>
<!-- SECTION B: Wakeup Ping Simulator -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Wakeup Ping Simulator</h2>
<p class="text-xs text-slate-500 mb-2">
Exercises the production push handler (not the refresh API shortcut
above).
</p>
<button
class="w-full text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onWakeupPing"
>
Simulate WAKEUP_PING
</button>
</div>
<!-- SECTION C: Flood Test -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Flood Test</h2>
<button
class="w-full text-md bg-gradient-to-b from-rose-400 to-rose-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onFloodTest"
>
Run 20 Refreshes
</button>
</div>
<!-- SECTION D: Pending Notification Inspector -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
@@ -334,14 +275,6 @@ type PendingInfo = {
wallClockSource?: string | null;
};
const presets = [
{ label: "30 sec", ms: 30_000 },
{ label: "1 min", ms: 60_000 },
{ label: "5 min", ms: 5 * 60_000 },
{ label: "10 min", ms: 10 * 60_000 },
];
const intervalMs = ref<number>(60_000);
const busy = ref(false);
const pending = ref<PendingInfo[]>([]);
const pendingInspectorMessage = ref<string | null>(null);
@@ -352,6 +285,10 @@ 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() ?? "";
@@ -367,11 +304,6 @@ const truncatedFcmToken = computed(() => {
const eventLog = ref<string[]>([]);
let unsubscribeEventLog: (() => void) | undefined;
const intervalLabel = computed(() => {
const preset = presets.find((p) => p.ms === intervalMs.value);
return preset?.label ?? `${intervalMs.value}ms`;
});
function formatIsoMs(ms: number | null | undefined): string {
if (ms == null || !Number.isFinite(ms)) {
return "";
@@ -395,27 +327,6 @@ async function refreshPending(): Promise<void> {
pendingInspectorMessage.value = result.inspectorUnavailableMessage ?? null;
}
async function onMockRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.triggerMockRefresh(intervalMs.value);
await refreshPending();
});
}
async function onWakeupPing(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.simulateWakeupPing();
await refreshPending();
});
}
async function onFloodTest(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.runFloodTest(intervalMs.value);
await refreshPending();
});
}
async function onClearNotifications(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.clearNotifications();
@@ -457,17 +368,32 @@ async function onRegisterToken(): Promise<void> {
});
}
async function onBackendRefresh(): Promise<void> {
async function onUploadAlertAuthorization(): Promise<void> {
alertAuthorizationStatus.value = null;
await withBusy(async () => {
await NotificationDebugService.triggerBackendRefresh();
await refreshPending();
});
}
async function onSimulateWakeupRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.simulateWakeupViaRefresh();
await refreshPending();
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}.`,
]
.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(" "),
};
});
}
@@ -477,16 +403,10 @@ function formatRealWakeupStatusMessage(
>,
): 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()}`);
const suffix = result.responseBody?.fcmTokenSuffix?.trim();
if (suffix) {
parts.push(`token …${suffix}`);
}
return parts.join(" ");
}
-134
View File
@@ -1,134 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { inject, onBeforeUnmount, onMounted } from "vue";
import { NotificationIface } from "../constants/app";
import { registerToken } from "@/services/notifications/NotificationService";
import { refreshNotifications } from "@/services/notifications/NativeNotificationService";
/**
* Vue 3 composable for notifications
* Provides a concise API for common notification patterns
*/
export const NOTIFICATION_TIMEOUTS = {
BRIEF: 1000, // Very brief toasts ("Sent..." messages)
SHORT: 2000, // Short notifications (clipboard copies, quick confirmations)
STANDARD: 3000, // Standard notifications (success messages, general info)
LONG: 5000, // Longer notifications (errors, warnings, important info)
VERY_LONG: 7000, // Very long notifications (complex operations)
MODAL: -1, // Modal confirmations (no auto-dismiss)
} as const;
export function useNotifications() {
// Inject the notify function from the app
const notify =
inject<(notification: NotificationIface, timeout?: number) => void>(
"notify",
);
if (!notify) {
throw new Error(
"useNotifications must be used within a component that has $notify available",
);
}
let refreshTimer: number | undefined = undefined;
let refreshInFlight: Promise<void> | null = null;
async function refreshNotificationsDebounced(): Promise<void> {
if (refreshTimer != null) {
window.clearTimeout(refreshTimer);
}
refreshTimer = window.setTimeout(() => {
if (!refreshInFlight) {
refreshInFlight = refreshNotifications().finally(() => {
refreshInFlight = null;
});
}
}, 300);
}
const onResume = () => {
void refreshNotificationsDebounced();
};
onMounted(() => {
void refreshNotificationsDebounced();
document.addEventListener("resume", onResume);
});
onBeforeUnmount(() => {
document.removeEventListener("resume", onResume);
if (refreshTimer != null) {
window.clearTimeout(refreshTimer);
}
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function success(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function error(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function warning(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function info(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function toast(_title: string, _text?: string, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function copied(_item: string, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function sent(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function confirm(
_text: string,
_onYes: () => Promise<void>,
_timeout?: number,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function confirmationSubmitted(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function genericError(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function genericSuccess(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function alreadyConfirmed(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function cannotConfirmIssuer(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function cannotConfirmHidden(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function notRegistered(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function notAGive(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function notificationOff(
_title: string,
_callback: (success: boolean) => Promise<void>,
_timeout?: number,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function downloadStarted(_format: string = "Dexie", _timeout?: number) {}
return {
success,
error,
warning,
info,
toast,
copied,
sent,
confirm,
confirmationSubmitted,
genericError,
genericSuccess,
alreadyConfirmed,
cannotConfirmIssuer,
cannotConfirmHidden,
notRegistered,
notAGive,
notificationOff,
downloadStarted,
/** POST FCM token to `/notifications/register` (same as startup native hook). */
registerFcmToken: registerToken,
refreshNotifications: refreshNotificationsDebounced,
};
}
+2
View File
@@ -40,6 +40,8 @@ export const ACCOUNT_VIEW_CONSTANTS = {
NO_PROFILE_LOCATION: "No profile location is saved.",
RELOAD_VAPID:
"Now reload the app to get a new VAPID to use with this push server.",
NOTIFY_SERVER_INFO:
"The notify server URL can be modified on the Notification Debug screen.",
},
// Warning messages
+11 -2
View File
@@ -6,8 +6,8 @@
export enum AppString {
// This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Gift Economies",
APP_NAME_NO_SPACES = "GiftEconomies",
APP_NAME = "Giftopia",
APP_NAME_NO_SPACES = APP_NAME,
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
@@ -26,6 +26,10 @@ 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",
LOCAL_NOTIFY_API_SERVER = "http://127.0.0.1:3003",
NO_CONTACT_NAME = "(no name)",
}
@@ -47,6 +51,11 @@ export const DEFAULT_PARTNER_API_SERVER =
export const DEFAULT_PUSH_SERVER =
import.meta.env.VITE_DEFAULT_PUSH_SERVER || AppString.PROD_PUSH_SERVER;
/** Base URL of the notify-api (FCM wakeup registration and SMS notifications). */
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";
/**
+19 -7
View File
@@ -1,15 +1,27 @@
/**
* JWT lifetime for native New Activity background prefetch (`configureNativeFetcher`).
* See doc/plan-background-jwt-pool-and-expiry.md. Confirm max `exp` with Endorser before raising.
* JWT lifetime for the single-token native background prefetch path
* (`accessTokenForBackgroundNotifications`). Confirm the maximum `exp` Endorser
* accepts before raising this.
*/
export const BACKGROUND_JWT_EXPIRY_DAYS = 90;
export const BACKGROUND_JWT_EXPIRY_SECONDS =
BACKGROUND_JWT_EXPIRY_DAYS * 24 * 60 * 60;
/** Headroom for retries / tests; pool size should be ≥ expiryDays + buffer. */
export const BACKGROUND_JWT_POOL_BUFFER = 10;
/** Seconds in a UTC day; the frame every pool slot is cut from. */
export const BACKGROUND_JWT_SECONDS_PER_DAY = 24 * 60 * 60;
/** Distinct JWT strings minted per configure (duplicate-JWT / daily slot). */
export const BACKGROUND_JWT_POOL_SIZE =
BACKGROUND_JWT_EXPIRY_DAYS + BACKGROUND_JWT_POOL_BUFFER;
/**
* Consecutive UTC days the native background prefetch pool covers, one JWT per
* day. This is the whole forward grant the user authorizes in a single mint:
* past the last day the pool carries no credential and prefetch stops until the
* app opens again. See `doc/background-jwt-pool.md`.
*/
export const BACKGROUND_JWT_POOL_SIZE = 100;
/**
* Padding on each end of a day's validity window, for clock skew between the
* device and Endorser. Widening a window is safe; narrowing it can leave a
* prefetch inside the day with no usable token.
*/
export const BACKGROUND_JWT_WINDOW_SLACK_SECONDS = 5 * 60;
-5
View File
@@ -1175,11 +1175,6 @@ export const NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM = {
message: "",
};
export const NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR = {
title: "Error",
message: "There was a problem deleting the image.",
};
export const NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER = {
title: "Missing Identifier",
message: "You must select an identifier before you can record a give.",
+11
View File
@@ -276,6 +276,17 @@ const MIGRATIONS = [
ALTER TABLE settings ADD COLUMN reminderFastRolloverForTesting BOOLEAN DEFAULT FALSE;
`,
},
{
name: "010_add_sms_notification_settings",
sql: `
-- Verified mobile number this identity registered with the notify-api
ALTER TABLE settings ADD COLUMN notifyingNewActivitySmsPhone TEXT;
-- Local time of day the notify-api was told to text, blank when off
ALTER TABLE settings ADD COLUMN notifyingNewActivitySmsTime TEXT;
-- Master switch for the SMS channel; a paused channel keeps its number
ALTER TABLE settings ADD COLUMN smsNotificationsEnabled BOOLEAN DEFAULT FALSE;
`,
},
];
/**
+4
View File
@@ -50,11 +50,15 @@ export type Settings = {
lastViewedClaimId?: string;
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server
notifyingNewActivitySmsPhone?: string; // verified mobile number registered with the notify-api for text alerts
notifyingNewActivitySmsTime?: string; // set to their chosen time if they have turned on new-activity texts
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
/** Dev/test only: use 10-minute rollover interval for daily reminder (plugin rolloverIntervalMinutes) */
reminderFastRolloverForTesting?: boolean;
smsNotificationsEnabled?: boolean; // master switch for the text-message channel
partnerApiServer?: string; // partner server API URL
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
+9
View File
@@ -31,6 +31,9 @@ export interface AccountSettings {
bbox: BoundingBox;
}>;
notifyingNewActivityTime?: string;
notifyingNewActivitySmsPhone?: string;
notifyingNewActivitySmsTime?: string;
smsNotificationsEnabled?: boolean;
notifyingReminderMessage?: string;
notifyingReminderTime?: string;
starredPlanHandleIds?: string[];
@@ -80,6 +83,12 @@ export interface ProfileState {
export interface NotificationState {
notifyingNewActivity: boolean;
notifyingNewActivityTime: string;
/** Master switch for the text-message channel. */
smsEnabled: boolean;
/** Verified number, kept while the channel is paused so re-enabling is free. */
notifyingNewActivitySmsPhone: string;
notifyingNewActivitySms: boolean;
notifyingNewActivitySmsTime: string;
notifyingReminder: boolean;
notifyingReminderMessage: string;
notifyingReminderTime: string;
+125
View 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 {}
+1
View File
@@ -80,6 +80,7 @@ export interface PlanActionClaim extends ClaimObject {
agent?: { identifier: string };
description?: string;
endTime?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string };
identifier?: string;
image?: string;
lastClaimId?: string;
+17
View File
@@ -15,6 +15,23 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
publicUrls?: Record<string, string>;
}
/**
* The fields the certificate canvases read off a claim.
*
* GenericVerifiableCredential carries an index signature of `unknown`, which is
* correct for a claim of any type but leaves each field unusable without a
* cast. This names the handful the drawing code actually touches. An `agent` is
* either a bare DID string or an object carrying one, depending on claim type.
*/
export interface CertifiableClaim extends GenericVerifiableCredential {
agent?: string | { identifier?: string };
description?: string;
includesObject?: { amountOfThisGood?: number; unitCode?: string };
itemOffered?: { description?: string };
name?: string;
object?: { amountOfThisGood?: number; unitCode?: string };
}
export interface ErrorResponse {
error?: {
message?: string;
+32
View File
@@ -29,6 +29,9 @@ import { z } from "zod";
// Parameter validation schemas for each route type
export const deepLinkPathSchemas = {
// A bare "timesafari://" with no path: open the app at the default view.
"": z.object({}),
account: z.object({}),
claim: z.object({
id: z.string(),
}),
@@ -48,6 +51,11 @@ export const deepLinkPathSchemas = {
"contact-import": z.object({
jwt: z.string(),
}),
// Both paths reach the same destination: the page that shows your QR code,
// scans someone else's, and prompts for your name if you have not set one.
// See ROUTE_NAME_RESOLVERS in src/services/deepLinks.ts for which view that is.
"contact-qr": z.object({}),
"contact-qr-scan-full": z.object({}),
contacts: z.object({
contactJwt: z.string().optional(),
inviteJwt: z.string().optional(),
@@ -55,22 +63,46 @@ export const deepLinkPathSchemas = {
did: z.object({
did: z.string().optional(),
}),
discover: z.object({}),
help: z.object({}),
"help-notification-types": z.object({}),
"help-notifications": z.object({}),
"help-onboarding": z.object({}),
"help-terms": z.object({}),
"invite-one": z.object({}),
"invite-one-accept": z.object({
// optional because A) it could be a query param, and B) the page displays an input if things go wrong
jwt: z.string().optional(),
}),
"new-activity": z.object({}),
"onboard-meeting-list": z.object({}),
"onboard-meeting-members": z.object({
groupId: z.string(),
}),
project: z.object({
id: z.string(),
}),
projects: z.object({}),
"recent-offers-to-user": z.object({}),
"recent-offers-to-user-projects": z.object({}),
"search-area": z.object({}),
"share-my-contact-info": z.object({}),
// Internal handoff used by the native image-share targets.
"shared-photo": z.object({}),
statistics: z.object({}),
"user-profile": z.object({
id: z.string(),
}),
};
export const deepLinkQuerySchemas = {
// All optional: the view defaults searchText to "" and the two flags to
// false, so a bare "timesafari://discover" is valid.
discover: z.object({
searchText: z.string().optional(),
searchPeople: z.string().optional(),
hideOnboarding: z.string().optional(),
}),
"onboard-meeting-members": z.object({
password: z.string(),
}),

Some files were not shown because too many files have changed in this diff Show More