Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c84cce54af | ||
|
|
4f339eba32 | ||
|
|
eb2240b901 | ||
|
|
724131d9a8 | ||
|
|
e77d08e3a8 | ||
|
|
ef476bbb57 | ||
|
|
2bbf230312 | ||
|
|
45503f568a | ||
|
|
e350feb132 | ||
|
|
7e3f2aa004 | ||
|
|
de951543c7 | ||
|
|
7604a92f60 | ||
|
|
4bf2fc1009 | ||
|
|
97f4f59eff | ||
|
|
6baf2a98ac | ||
|
|
44695a51cc | ||
|
|
044344026c | ||
|
|
fe9a2f0cbb | ||
|
|
240c3c5a76 | ||
|
|
4fcc9a40d0 | ||
|
|
2afe748292 | ||
|
|
34a8c51d2f | ||
|
|
0c400b8797 | ||
|
|
d5bcdbae3f | ||
|
|
46a2e0aaf5 | ||
|
|
6b65ad8554 | ||
|
|
03658340f8 | ||
|
|
d5c357b291 | ||
|
|
86611fe50d | ||
|
|
4152012838 | ||
|
|
25110e3eea | ||
|
|
0ecd4c6dd7 | ||
|
|
821d3b7d05 | ||
|
|
8e6c83021f | ||
|
|
cfe90fd04e | ||
|
|
fb9da10fd2 | ||
|
|
6d221ee1ca | ||
|
|
823db447ca | ||
|
|
87ffa025e8 | ||
|
|
15c9088736 | ||
|
|
ec41dd52d5 | ||
|
|
463db39a6b | ||
|
|
fe97dff752 | ||
|
|
903047f13b | ||
|
|
48be234af4 | ||
|
|
6c0907d905 | ||
|
|
8d8bcf2a7e | ||
|
|
a4b47904c8 | ||
|
|
bb890baacf | ||
|
|
dae23300fe | ||
|
|
9e401febea | ||
|
|
cd4b279703 | ||
|
|
a3a2d97b9a | ||
|
|
802050259c | ||
|
|
efd7d50a84 | ||
|
|
39c389cda8 | ||
|
|
93fdcaf7ff | ||
|
|
ad419efa0d | ||
|
|
ce45ddb2bd | ||
|
|
7d306bd204 | ||
|
|
9713313a40 | ||
|
|
ffa7bac319 | ||
|
|
e0e0a0a183 | ||
|
|
ea662f4430 | ||
|
|
81647e1f3c | ||
|
|
bf1ee78025 | ||
|
|
66b7d0f46e | ||
|
|
63dcf44125 | ||
|
|
cf1ecdfb4c | ||
|
|
e9ad61b780 | ||
|
|
ad8df3eb93 | ||
|
|
05d346edce | ||
|
|
e259e60fa7 | ||
|
|
821de3f006 | ||
|
|
43f83031d4 | ||
|
|
688a48a332 | ||
|
|
8938c242ee | ||
|
|
358af42afd | ||
|
|
59c00241b8 | ||
|
|
33ec90e571 |
@@ -18,4 +18,6 @@ VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
|
||||
#VITE_DEFAULT_PUSH_SERVER... can't be set up with localhost domain
|
||||
# Using shared test notify API (no local notify server by default).
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_PASSKEYS_ENABLED=true
|
||||
|
||||
@@ -11,3 +11,4 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
|
||||
VITE_DEFAULT_PUSH_SERVER=https://timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
|
||||
|
||||
@@ -15,4 +15,5 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch
|
||||
VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_PASSKEYS_ENABLED=true
|
||||
|
||||
@@ -1 +1 @@
|
||||
18.19.0
|
||||
20.18.1
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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()) {
|
||||
|
||||
@@ -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 = [:]
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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”
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 156 KiB |
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 624 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 624 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 624 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 624 KiB |
@@ -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 => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||