Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9442ea716d | ||
|
|
b23cec410f | ||
|
|
6f2ce234d9 | ||
|
|
1e50c1897a | ||
|
|
db43827513 | ||
|
|
f4e34f2720 | ||
|
|
7fffdd3bff | ||
|
|
f162a2619c | ||
|
|
875d5679b3 | ||
|
|
d2cdd5785e | ||
|
|
5beace2822 | ||
|
|
11dbb115e3 | ||
|
|
4ef0a51c6a | ||
|
|
59f503d90a | ||
|
|
7133c4d574 | ||
|
|
d14843622d | ||
|
|
6fb4314b3a | ||
|
|
cbbf9d7bc0 | ||
|
|
150c69a025 | ||
|
|
a271cd1434 | ||
|
|
7bf36b0845 | ||
|
|
beda33d60c | ||
|
|
ba83111d84 | ||
|
|
ba712468a0 | ||
|
|
8f3da91f21 | ||
|
|
f41a807ab3 | ||
|
|
aa4103a233 | ||
|
|
d6ccf0e4ae | ||
|
|
d7f915d7ad | ||
|
|
e96f501ec0 | ||
|
|
7af00aff6f | ||
|
|
adffef6a50 | ||
|
|
824d77e267 | ||
|
|
1d1801e9b5 | ||
|
|
07cb1feddf | ||
|
|
b0292d3f04 | ||
|
|
70a4a8c9fe | ||
|
|
a03ed849c4 | ||
|
|
6183afc29c | ||
|
|
1afe5461b2 | ||
|
|
4bb0e4d4f9 | ||
|
|
2fd5549098 | ||
|
|
28ee81b9d3 | ||
|
|
38af28154e | ||
|
|
70372a113a | ||
|
|
9e88cf056c | ||
|
|
db13332dd6 | ||
|
|
c84cce54af | ||
|
|
4f339eba32 | ||
|
|
eb2240b901 | ||
|
|
724131d9a8 | ||
|
|
e77d08e3a8 | ||
|
|
ef476bbb57 | ||
|
|
2bbf230312 | ||
|
|
45503f568a | ||
|
|
e350feb132 | ||
|
|
7e3f2aa004 | ||
|
|
de951543c7 | ||
|
|
7604a92f60 | ||
|
|
4bf2fc1009 | ||
|
|
97f4f59eff | ||
|
|
6baf2a98ac | ||
|
|
44695a51cc | ||
|
|
044344026c | ||
|
|
fe9a2f0cbb | ||
|
|
240c3c5a76 | ||
|
|
4fcc9a40d0 | ||
|
|
2afe748292 | ||
|
|
34a8c51d2f | ||
|
|
59993cb96a | ||
|
|
940e851d3c | ||
|
|
d5bcdbae3f | ||
|
|
46a2e0aaf5 | ||
|
|
b02a1b29c5 | ||
|
|
3f291b0d63 | ||
|
|
6b65ad8554 | ||
|
|
03658340f8 | ||
|
|
821d3b7d05 | ||
|
|
ee8fb80cfe | ||
|
|
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 |
@@ -11,7 +11,7 @@
|
||||
|
||||
# Web (development)
|
||||
|
||||
npm run build:web
|
||||
npm run dev
|
||||
|
||||
# Mobile
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
- **File System**: Limited browser APIs
|
||||
|
||||
- **Build**: `npm run build:web` (development build)
|
||||
- **Build**: `npm run dev` (development server), `npm run build:web` (production build into `dist/`)
|
||||
|
||||
### Mobile (Capacitor)
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
# Web (development)
|
||||
|
||||
npm run build:web
|
||||
npm run dev
|
||||
|
||||
# Mobile
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ follow proper review, testing, and documentation procedures.
|
||||
|
||||
- **CI/CD Pipeline**: GitHub Actions, build validation scripts
|
||||
|
||||
- **Service Worker Assembly**: `sw_scripts/`, `sw_combine.js`, WASM copy steps
|
||||
- **WASM Assembly**: WASM copy steps
|
||||
|
||||
## Change Authorization Requirements
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
### Rollback Verification
|
||||
|
||||
- **Web**: `npm run build:web:dev` and `npm run build:web:prod`
|
||||
- **Web**: `npm run dev` and `npm run build:web:prod`
|
||||
|
||||
- **Mobile**: `npm run build:android:test` and `npm run build:ios:test`
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
### After Build System Change
|
||||
|
||||
- [ ] **Web Platform**: Does `npm run build:web:dev` work?
|
||||
- [ ] **Web Platform**: Does `npm run dev` work?
|
||||
|
||||
- [ ] **Mobile Platforms**: Do iOS/Android builds succeed?
|
||||
|
||||
@@ -33,9 +33,9 @@
|
||||
|
||||
### Web Platform
|
||||
|
||||
- **Development**: `npm run build:web:dev` - serve and load app
|
||||
- **Development**: `npm run dev` - serve and load app
|
||||
|
||||
- **Production**: `npm run build:web:prod` - verify SW and WASM present
|
||||
- **Production**: `npm run build:web:prod` - verify WASM present
|
||||
|
||||
### Mobile Platforms
|
||||
|
||||
@@ -98,13 +98,11 @@
|
||||
verify single-instance,
|
||||
icons, signing
|
||||
|
||||
### Service Worker / WASM
|
||||
### WASM
|
||||
|
||||
- **Trigger**: `sw_combine.js`, WASM copy path
|
||||
- **Trigger**: WASM copy path
|
||||
|
||||
- **Validation**: Verify combined SW exists and is injected; page loads offline;
|
||||
|
||||
WASM present
|
||||
- **Validation**: WASM present
|
||||
|
||||
### Docker
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ VITE_LOG_LEVEL=info
|
||||
|
||||
# iOS doesn't like spaces in the app title.
|
||||
TIME_SAFARI_APP_TITLE="TimeSafari_Test"
|
||||
VITE_APP_SERVER=https://test.timesafari.app
|
||||
VITE_APP_SERVER=https://test.`
|
||||
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not
|
||||
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not production).
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ dist-electon-build
|
||||
node_modules
|
||||
dist
|
||||
signature.bin
|
||||
# generated during `npm run build`
|
||||
sw_scripts-combined.js
|
||||
*.pem
|
||||
verified.txt
|
||||
myenv
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Husky Commit Message Hook
|
||||
# Validates commit message format using commitlint
|
||||
#
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
# Run commitlint but don't fail the commit (|| true)
|
||||
# This provides helpful feedback without blocking commits
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Husky Pre-commit Hook
|
||||
# Runs lint-fix and Build Architecture Guard on staged files
|
||||
#
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
echo "🔍 Running pre-commit hooks..."
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Husky Pre-push Hook
|
||||
# Runs Build Architecture Guard to check commits being pushed
|
||||
#
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
echo "🔍 Running Build Architecture Guard (pre-push)..."
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
18.19.0
|
||||
20.18.1
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Agent Instructions for crowd-funder-for-time-pwa
|
||||
|
||||
## Android Build — Google Play Services / FOSS Compatibility
|
||||
|
||||
**Firebase is on by default for Android and iOS builds.** Do not turn it off unless the build is an exceptional FOSS/F-Droid artifact.
|
||||
|
||||
`android/app/google-services.json` is required when Firebase is enabled. It does not decide the switch: a missing file fails the build, and a file on disk is ignored when Firebase is explicitly disabled.
|
||||
|
||||
- **Default (local, test, production, Play Store)**: `./gradlew assembleRelease` or `./gradlew bundleRelease` — do not pass `-PfirebaseEnabled`.
|
||||
- **Opt out (F-Droid / FOSS)**: `./gradlew assembleRelease -PfirebaseEnabled=false`, or an npm `:nostore` script such as `npm run build:android:test:nostore:run`. Those set `FIREBASE_ENABLED=false`, which forwards `-PfirebaseEnabled=false`. `:nostore` disables Firebase only; it does not remove every Play Services dependency.
|
||||
|
||||
iOS builds bundle `ios/App/App/GoogleService-Info.plist` by default (`ios/App/scripts/bundle-firebase-config.sh`, run from `scripts/build-ios.sh` and as an Xcode build phase). Opt out with `FIREBASE_ENABLED=false` (the build script forwards that to both the build and archive `xcodebuild` invocations), `npm run build:ios:prod:nostore`, or the Xcode build setting `FIREBASE_ENABLED=NO`. A missing plist fails an enabled build; the file's presence does not enable Firebase on its own.
|
||||
|
||||
The guard is in `android/app/build.gradle`. Do NOT activate Firebase from file presence alone — that was the bug that broke FOSS distribution in June 2026.
|
||||
|
||||
Never commit `google-services.json`.
|
||||
|
||||
Full details, incident history, and F-Droid notes: `doc/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.
|
||||
@@ -9,9 +9,9 @@ This guide explains how to build TimeSafari for different platforms using the co
|
||||
```bash
|
||||
# 🖥️ Web Development
|
||||
npm install # setup -- and pkgx.dev `dev` command before this will set environment with npm, etc
|
||||
npm run build:web:serve -- --test # Start with test endorser server
|
||||
npm run build:web:dev # Start development server with hot reload with local endorser server
|
||||
npm run build:web:prod # Production build
|
||||
npm run dev # Start development server with hot reload with local endorser server
|
||||
npm run build:web:serve:test # Test build served from dist/ (test endorser server)
|
||||
npm run build:web # Production build into dist/
|
||||
|
||||
# 📱 Mobile Development
|
||||
npm run build:ios # iOS build (opens Xcode)
|
||||
@@ -44,7 +44,7 @@ npm run test:web # Run web tests to verify setup
|
||||
|
||||
```bash
|
||||
# Start web development server
|
||||
npm run build:web:dev # Opens http://localhost:8080
|
||||
npm run dev # Opens http://localhost:8080
|
||||
|
||||
# For mobile development
|
||||
npm run build:ios # Opens Xcode with iOS project
|
||||
@@ -219,36 +219,41 @@ Install dependencies:
|
||||
npm install
|
||||
```
|
||||
|
||||
## Web Development
|
||||
## Web Development & Deployment
|
||||
|
||||
### Web Development Commands
|
||||
|
||||
```bash
|
||||
# Local development
|
||||
npm run build:web:dev # Start development server with hot reload
|
||||
npm run dev # Start development server with hot reload (writes nothing to dist/)
|
||||
|
||||
# Web builds
|
||||
npm run build:web # Development build (starts dev server with hot reload)
|
||||
# Web builds (each one writes dist/)
|
||||
npm run build:web # Production build (same as build:web:prod)
|
||||
npm run build:web:test # Test environment build (optimized for testing)
|
||||
npm run build:web:prod # Production build (optimized for production)
|
||||
npm run build:web:serve # Build and serve locally for production testing
|
||||
|
||||
# Build, then serve dist/ locally
|
||||
npm run build:web:serve # Production build, then serve
|
||||
npm run build:web:serve:test # Test build, then serve
|
||||
|
||||
# Docker builds
|
||||
npm run build:web:docker # Development build with Docker containerization
|
||||
npm run build:web:docker # Production build with Docker containerization
|
||||
npm run build:web:docker:test # Test build with Docker containerization
|
||||
npm run build:web:docker:prod # Production build with Docker containerization
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
Start the development server using `npm run build:web:dev` or `npm run build:web` (both start the development server with hot reload).
|
||||
Start the development server with hot reload using `npm run dev`. Open it at `http://localhost:8080/`, not at a wildcard address such as `http://[::]:8080/` or `http://0.0.0.0:8080/`: browsers only allow the cryptography the app needs on HTTPS, `localhost`, `127.0.0.1`, or `[::1]`, so any other plain-HTTP address shows a "Cannot Start Here" page.
|
||||
|
||||
### Web Build for Server
|
||||
|
||||
1. Run the production build using `npm run build:web:prod`
|
||||
1. Run the production build using `npm run build:web` (or `npm run build:web:test` for the test server)
|
||||
2. The built files will be in the `dist` directory
|
||||
3. To test the production build locally, use `npm run build:web:serve` (builds then serves)
|
||||
|
||||
`dist/` is shared with the Android, iOS, Electron, and Capacitor builds, which overwrite it with non-web output. Run the web build immediately before deploying, and confirm that the browser console reports `Loading TimeSafari for platform: web`.
|
||||
|
||||
**Why Use `serve`?**
|
||||
- **Production Testing**: Test your optimized production build locally before deployment
|
||||
- **SPA Routing Validation**: Verify deep linking and navigation work correctly (handles routes like `/discover`, `/account`)
|
||||
@@ -334,11 +339,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).
|
||||
|
||||
@@ -347,7 +352,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:
|
||||
|
||||
@@ -1069,6 +1074,9 @@ npm run build:ios:prod # Production build (builds for production en
|
||||
npm run build:ios:test:run # Test build with auto-run (builds then runs on simulator)
|
||||
npm run build:ios:prod:run # Production build with auto-run (builds then runs on simulator)
|
||||
|
||||
# Firebase off (`:nostore` sets FIREBASE_ENABLED=false, forwarded to xcodebuild as FIREBASE_ENABLED=NO)
|
||||
npm run build:ios:prod:nostore # Same as prod, without Firebase
|
||||
|
||||
# Debug and release builds
|
||||
npm run build:ios:debug # Debug build (builds debug app bundle)
|
||||
npm run build:ios:debug:run # Debug build with auto-run (builds then runs on simulator)
|
||||
@@ -1084,6 +1092,11 @@ npm run build:ios:assets # Generate assets only
|
||||
npm run build:ios:deploy # Build and deploy to connected device
|
||||
```
|
||||
|
||||
The iOS asset command reads source artwork from `resources/` explicitly. It
|
||||
generates the default 1024×1024 App Store icon, from which Xcode derives the
|
||||
required device sizes, and installs any available appearance variants under
|
||||
`ios/App/App/Assets.xcassets/AppIcon.appiconset/`.
|
||||
|
||||
#### Automated Build Script
|
||||
|
||||
The recommended way to build for iOS is using the automated build script:
|
||||
@@ -1117,17 +1130,17 @@ The script handles all the necessary steps including:
|
||||
|
||||
If you need to build manually or want to understand the individual steps:
|
||||
|
||||
#### First-time iOS Configuration
|
||||
##### First-time iOS Configuration
|
||||
|
||||
- Generate certificates inside XCode.
|
||||
- Right-click on App and under Signing & Capabilities set the Team.
|
||||
- In the App Developer setup (eg. https://developer.apple.com/account), under Identifiers and/or "Certificates, Identifiers & Profiles"
|
||||
|
||||
#### Each Release
|
||||
##### Each Release
|
||||
|
||||
##### 0. First time (or if dependencies change)
|
||||
###### 0. First time (or if dependencies change)
|
||||
|
||||
- `pkgx +rubygems.org +pod zsh`
|
||||
- `pkgx +rubygems.org +pod /bin/zsh`
|
||||
|
||||
- ... and you may have to fix these, especially with pkgx:
|
||||
|
||||
@@ -1138,15 +1151,15 @@ export GEM_HOME=$shortened_path
|
||||
export GEM_PATH=$shortened_path
|
||||
```
|
||||
|
||||
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
||||
###### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
||||
|
||||
```bash
|
||||
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 76 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.10;/g" App.xcodeproj/project.pbxproj && cd -
|
||||
# Unfortunately this edits Info.plist directly.
|
||||
#xcrun agvtool new-marketing-version 0.4.5
|
||||
```
|
||||
|
||||
##### 2. Build
|
||||
###### 2. Build
|
||||
|
||||
Here's prod. Also available: test, dev
|
||||
|
||||
@@ -1162,14 +1175,20 @@ npm run build:ios:prod
|
||||
|
||||
3.2. Use Xcode to release.
|
||||
|
||||
- Someday: Under "General" we want to rename a bunch of things to "Time Safari"
|
||||
- Someday: Under "General" we want to rename a bunch of things to "Giftopia"
|
||||
- Choose Product -> Destination -> Any iOS Device
|
||||
- Choose Product -> Archive
|
||||
- This will trigger a build and take time, needing user's "login" keychain
|
||||
password (user's login password), repeatedly.
|
||||
- Required password entry 26 times.
|
||||
- It can get stuck on uploading SPI; try canceling and restarting.
|
||||
- If it fails with `building for 'iOS', but linking in dylib
|
||||
(.../.pkgx/zlib.net/v1.3.0/lib/libz.1.3.dylib) built for 'macOS'` then run
|
||||
XCode outside that terminal (ie. not with `npx cap open ios`).
|
||||
- Before distributing, confirm the archive carries production server
|
||||
defaults. Xcode packages whatever web bundle is in `ios/App/App/public`,
|
||||
even one from a `--test` build:
|
||||
`./scripts/check-prod-bundle.sh ios/App/App/public`
|
||||
- Click Distribute -> App Store Connect
|
||||
- In AppStoreConnect, add the build to the distribution. You may have to remove
|
||||
the current build with the "-" when you hover over it, then "Add Build" with the
|
||||
@@ -1319,6 +1338,11 @@ npm run build:android:prod # Production build (builds for production e
|
||||
# Auto-run builds
|
||||
npm run build:android:test:run # Test build with auto-run (builds then runs on emulator)
|
||||
npm run build:android:prod:run # Production build with auto-run (builds then runs on emulator)
|
||||
# Auto-run targets the one connected device; with none or several it prompts, or exits 9 without a terminal
|
||||
|
||||
# Firebase off (`:nostore` sets FIREBASE_ENABLED=false, which passes -PfirebaseEnabled=false)
|
||||
npm run build:android:test:nostore:run # Same as test:run, without Firebase
|
||||
npm run build:android:prod:nostore # Same as prod, without Firebase
|
||||
|
||||
# Debug and release builds
|
||||
npm run build:android:debug # Debug build (builds debug APK)
|
||||
@@ -1363,7 +1387,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
|
||||
@@ -1422,8 +1446,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 76/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionName .*/versionName "1.4.10"/g' android/app/build.gradle
|
||||
```
|
||||
|
||||
##### 2. Build
|
||||
@@ -1434,43 +1458,47 @@ perl -p -i -e 's/versionName .*/versionName "1.4.1"/g' android/app/build.gradle
|
||||
npm run build:android:prod
|
||||
```
|
||||
|
||||
##### 3. Open the project in Android Studio
|
||||
##### 2.1. (Optional) Open the project in Android Studio
|
||||
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
|
||||
##### 4. Use Android Studio to build and run on emulator or device
|
||||
##### 3. Use Android Studio to build and run on emulator or device
|
||||
|
||||
## Android Build from the console
|
||||
#### Android Build from the console
|
||||
|
||||
`cd android`
|
||||
|
||||
* Note that this currently fails: `./gradlew build`
|
||||
|
||||
To create a signed `aab` release:
|
||||
|
||||
- Setup by adding the app/gradle.properties.secrets file (see properties at top
|
||||
of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
|
||||
- Then `bundleRelease` or `bundleDebug`:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew clean
|
||||
./gradlew build -Dlint.baselines.continue=true
|
||||
cd -
|
||||
# if you use pkgx: pkgx +openjdk.org /bin/zsh
|
||||
./gradlew bundleRelease -Dlint.baselines.continue=true
|
||||
```
|
||||
|
||||
... or, to create the `aab` file, `bundle` instead of `build`:
|
||||
Firebase is on by default (`firebaseEnabled=true` in `android/gradle.properties`). Do not pass `-PfirebaseEnabled` for a normal release. F-Droid and other builds that must omit Firebase use `-PfirebaseEnabled=false`, or an npm `:nostore` script (`FIREBASE_ENABLED=false`). `:nostore` only disables Firebase. It does not remove other store-linked dependencies, such as the ML Kit barcode scanner's Play Services library, and it does not by itself make the app run on every device outside the Play Store.
|
||||
|
||||
... and find your `aab` file at app/build/outputs/bundle/release
|
||||
|
||||
To create an unsigned debugging bundle (not usable in Play Store):
|
||||
|
||||
```bash
|
||||
./gradlew bundleDebug -Dlint.baselines.continue=true
|
||||
```
|
||||
|
||||
... or, to create a signed release:
|
||||
|
||||
- Setup by adding the app/gradle.properties.secrets file (see properties at top
|
||||
of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
|
||||
- In app/build.gradle, bump the versionCode and maybe the versionName
|
||||
- Then `bundleRelease`:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew bundleRelease -Dlint.baselines.continue=true
|
||||
cd -
|
||||
```
|
||||
|
||||
... and find your `aab` file at app/build/outputs/bundle/release
|
||||
* Gradle packages whatever web bundle was last synced into
|
||||
`android/app/src/main/assets/public`, even one from a `--test` build. Before
|
||||
uploading, confirm it has production server defaults:
|
||||
`./scripts/check-prod-bundle.sh android/app/build/outputs/bundle/release/app-release.aab`
|
||||
* `build-android.sh` runs `./gradlew clean`, which deletes any earlier `aab`, so
|
||||
run `bundleRelease` after the build script finishes.
|
||||
|
||||
At play.google.com/console:
|
||||
|
||||
@@ -1587,8 +1615,7 @@ Use the commands above to check and fix code quality issues.
|
||||
|
||||
1. **Environment Setup**: Load platform-specific environment variables
|
||||
2. **Asset Building**: Vite builds optimized web assets
|
||||
3. **PWA Generation**: Service worker and manifest creation
|
||||
4. **Output**: Production-ready files in `dist/` directory
|
||||
3. **Output**: Production-ready files in `dist/` directory
|
||||
|
||||
### Electron Build Process
|
||||
|
||||
@@ -1606,6 +1633,14 @@ 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. Build
|
||||
scripts select it explicitly with `--assetPath resources`. Use the same option
|
||||
when invoking the generator directly:
|
||||
|
||||
```bash
|
||||
npx capacitor-assets generate --assetPath resources
|
||||
```
|
||||
|
||||
## Architecture Environment Configuration
|
||||
|
||||
### Environment Files
|
||||
@@ -1721,20 +1756,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"
|
||||
@@ -1746,6 +1771,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
|
||||
@@ -1814,7 +1851,7 @@ npm run build:android:assets
|
||||
- Configure signing keys
|
||||
- Check device/emulator setup
|
||||
|
||||
## Additional Resources
|
||||
## Additional Documentation
|
||||
|
||||
- [Electron Build Patterns](doc/electron-build-patterns.md)
|
||||
- [iOS Build Scripts](doc/ios-build-scripts.md)
|
||||
@@ -1830,38 +1867,21 @@ npm run build:android:assets
|
||||
|
||||
### Build Process Overview
|
||||
|
||||
TimeSafari's build system follows a multi-stage process that prepares assets,
|
||||
combines scripts, and generates platform-specific outputs.
|
||||
TimeSafari's build system follows a multi-stage process that prepares assets
|
||||
and generates platform-specific outputs.
|
||||
|
||||
#### Pre-Build Preparation
|
||||
|
||||
##### 1. Service Worker Script Preparation
|
||||
|
||||
```bash
|
||||
# Optional: Format third-party service worker scripts
|
||||
npx prettier --write ./sw_scripts/
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
|
||||
- Formats cryptographic libraries (`nacl.js`, `noble-curves.js`,
|
||||
`noble-hashes.js`, etc.)
|
||||
- These scripts are automatically combined during the build process
|
||||
- Improves readability and makes version control diffs cleaner
|
||||
- **Note**: This is optional and only needed when updating third-party scripts
|
||||
|
||||
**2. Automatic Pre-Build Steps**
|
||||
The `prebuild` script automatically runs before any build:
|
||||
#### Pre-Build Script
|
||||
|
||||
```json
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js"
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node scripts/copy-wasm.js"
|
||||
```
|
||||
|
||||
**What happens automatically:**
|
||||
npm runs `prebuild` only before a script named `build`. This project defines
|
||||
no `build` script, so none of the `build:*` commands trigger it.
|
||||
|
||||
- **ESLint**: Checks and fixes code formatting in `src/`
|
||||
- **Script Combination**: `sw_combine.js` combines all `sw_scripts/*.js` files
|
||||
into `sw_scripts-combined.js`
|
||||
**What it does when run:**
|
||||
|
||||
- **ESLint**: Checks code formatting in `src/`
|
||||
- **WASM Copy**: `copy-wasm.js` copies SQLite WASM files to `public/wasm/`
|
||||
|
||||
#### Build Process Architecture
|
||||
@@ -1869,11 +1889,10 @@ The `prebuild` script automatically runs before any build:
|
||||
**Web Build Process:**
|
||||
|
||||
```text
|
||||
1. Pre-Build: ESLint + Script Combination + WASM Copy
|
||||
2. Environment Setup: Load .env files, set NODE_ENV
|
||||
3. Vite Build: Bundle web assets with PWA support
|
||||
4. Service Worker: Inject combined scripts into PWA
|
||||
5. Output: Production-ready files in dist/
|
||||
1. Environment Setup: Load .env files, set NODE_ENV
|
||||
2. Type Check: tsc --noEmit (test and production modes)
|
||||
3. Vite Build: Bundle web assets
|
||||
4. Output: Production-ready files in dist/
|
||||
```
|
||||
|
||||
**Electron Build Process:**
|
||||
@@ -1896,34 +1915,6 @@ The `prebuild` script automatically runs before any build:
|
||||
5. Package Creation: APK/IPA generation
|
||||
```
|
||||
|
||||
#### Service Worker Architecture
|
||||
|
||||
**Script Organization:**
|
||||
|
||||
- `sw_scripts/` - Individual third-party scripts
|
||||
- `sw_combine.js` - Combines scripts into single file
|
||||
- `sw_scripts-combined.js` - Combined service worker (317KB, 10K+ lines)
|
||||
- `vite.config.utils.mts` - PWA configuration using combined script
|
||||
|
||||
**PWA Integration:**
|
||||
|
||||
```typescript
|
||||
// vite.config.utils.mts
|
||||
pwaConfig: {
|
||||
strategies: "injectManifest",
|
||||
filename: "sw_scripts-combined.js", // Uses our combined script
|
||||
// ... manifest configuration
|
||||
}
|
||||
```
|
||||
|
||||
**What Gets Combined:**
|
||||
|
||||
- `nacl.js` - NaCl cryptographic library
|
||||
- `noble-curves.js` - Elliptic curve cryptography (177KB)
|
||||
- `noble-hashes.js` - Cryptographic hash functions (91KB)
|
||||
- `safari-notifications.js` - Safari-specific notifications
|
||||
- `additional-scripts.js` - Additional service worker functionality
|
||||
|
||||
#### Process Environment Configuration
|
||||
|
||||
**Environment Files:**
|
||||
@@ -1976,10 +1967,13 @@ device)
|
||||
```folders
|
||||
dist/
|
||||
├── index.html # Main HTML file
|
||||
├── assets/ # Bundled JavaScript/CSS
|
||||
├── sw.js # Service worker (injected)
|
||||
├── manifest.webmanifest # PWA manifest
|
||||
└── wasm/ # SQLite WASM files
|
||||
├── assets/ # Bundled JavaScript, CSS, and SQLite WASM
|
||||
├── img/ # Static images (copied from public/)
|
||||
├── models/ # Static model files (copied from public/)
|
||||
├── favicon.ico
|
||||
├── manifest.webmanifest # Web app manifest, incl. share_target (copied from public/)
|
||||
├── robots.txt
|
||||
└── sw.js # Service worker for the Web Share Target (copied from public/)
|
||||
```
|
||||
|
||||
**Electron Build:**
|
||||
@@ -2001,17 +1995,14 @@ electron/
|
||||
**Manual Steps (Developer Responsibility):**
|
||||
|
||||
- Database migration testing
|
||||
- Service worker script formatting (optional)
|
||||
- Version updates and changelog
|
||||
- Environment-specific builds
|
||||
|
||||
**Automatic Steps (Build System):**
|
||||
|
||||
- Code linting and formatting
|
||||
- Script combination
|
||||
- Type checking (test and production builds)
|
||||
- Asset optimization
|
||||
- Package creation
|
||||
- Service worker injection
|
||||
|
||||
This architecture ensures consistent builds across all platforms while providing
|
||||
flexibility for platform-specific optimizations and manual quality assurance steps.
|
||||
@@ -2036,31 +2027,34 @@ environment setup, Vite build, and optional Docker containerization.
|
||||
|
||||
```bash
|
||||
# Direct script usage
|
||||
./scripts/build-web.sh # Development build
|
||||
./scripts/build-web.sh --dev # Development build (explicit)
|
||||
./scripts/build-web.sh # Production build into dist/
|
||||
./scripts/build-web.sh --prod # Production build (explicit)
|
||||
./scripts/build-web.sh --test # Test environment build
|
||||
./scripts/build-web.sh --prod # Production environment build
|
||||
./scripts/build-web.sh --docker # Build with Docker containerization
|
||||
./scripts/build-web.sh --dev # Start Vite dev server (no dist/)
|
||||
./scripts/build-web.sh --docker # Production build + Docker image
|
||||
./scripts/build-web.sh --docker:test # Test environment + Docker
|
||||
./scripts/build-web.sh --docker:prod # Production environment + Docker
|
||||
./scripts/build-web.sh --serve # Build and serve locally
|
||||
./scripts/build-web.sh --serve # Production build, then serve dist/
|
||||
./scripts/build-web.sh --help # Show help
|
||||
./scripts/build-web.sh --verbose # Enable verbose logging
|
||||
|
||||
# NPM Script Equivalents
|
||||
npm run build:web # Development build
|
||||
npm run dev # Vite dev server
|
||||
npm run build:web # Production build
|
||||
npm run build:web:test # Test environment build
|
||||
npm run build:web:prod # Production environment build
|
||||
npm run build:web:docker # Docker build
|
||||
npm run build:web:docker # Production Docker build
|
||||
npm run build:web:docker:test # Test Docker build
|
||||
npm run build:web:docker:prod # Production Docker build
|
||||
npm run build:web:serve # Production build, then serve
|
||||
npm run build:web:serve:test # Test build, then serve
|
||||
```
|
||||
|
||||
**Build Modes**:
|
||||
|
||||
- **Development**: Starts Vite dev server with hot reload (default)
|
||||
- **Production**: Optimized for production with full minification (default)
|
||||
- **Test**: Optimized for testing with minimal minification
|
||||
- **Production**: Optimized for production with full minification
|
||||
- **Development**: Starts Vite dev server with hot reload (`--dev` alone); with `--serve` or `--docker`, builds `dist/` in development mode
|
||||
|
||||
**Script Features**:
|
||||
|
||||
@@ -2204,6 +2198,8 @@ command chaining, following DRY principles.
|
||||
- `7` - Asset generation failed
|
||||
- `8` - Android Studio launch failed
|
||||
- `9` - Resource check failed
|
||||
- `10` - Production bundle has test/dev server defaults (`--prod` only; see
|
||||
`scripts/check-prod-bundle.sh`)
|
||||
|
||||
### A.4 build-ios.sh
|
||||
|
||||
@@ -2264,6 +2260,8 @@ command chaining, following DRY principles.
|
||||
- **Clean Build**: Removes Xcode build artifacts and DerivedData
|
||||
- **Asset Generation**: Creates platform-specific assets
|
||||
- **Simulator Support**: Launches iOS Simulator for testing
|
||||
- **Production Bundle Check**: With `--prod`, exits with code `10` if the synced
|
||||
web bundle has test/dev server defaults (`scripts/check-prod-bundle.sh`)
|
||||
|
||||
### A.5 common.sh
|
||||
|
||||
@@ -2377,7 +2375,7 @@ npm run clean:electron # Electron only
|
||||
# Clean and rebuild
|
||||
npm run clean:all
|
||||
npm install
|
||||
npm run build:web:dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Appendix B: Vite Configuration Files Reference
|
||||
@@ -2409,9 +2407,7 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
||||
const appConfig = await loadAppConfig();
|
||||
const isCapacitor = platform === "capacitor";
|
||||
const isElectron = platform === "electron";
|
||||
const isNative = isCapacitor || isElectron;
|
||||
|
||||
// Set platform - PWA is always enabled for web platforms
|
||||
process.env.VITE_PLATFORM = platform;
|
||||
|
||||
return {
|
||||
@@ -2427,7 +2423,6 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
||||
assetsDir: 'assets',
|
||||
chunkSizeWarningLimit: 1000,
|
||||
rollupOptions: {
|
||||
external: isNative ? ['@capacitor/app'] : [],
|
||||
output: {
|
||||
format: 'esm',
|
||||
generatedCode: { preset: 'es2015' },
|
||||
@@ -2459,12 +2454,7 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
||||
'@jlongster/sql.js',
|
||||
'absurd-sql',
|
||||
// ... additional dependencies
|
||||
],
|
||||
exclude: isNative ? [
|
||||
'register-service-worker',
|
||||
'workbox-window',
|
||||
// ... native exclusions
|
||||
] : []
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2487,98 +2477,23 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
||||
|
||||
**File**: `vite.config.web.mts`
|
||||
**Author**: Matthew Raymer
|
||||
**Description**: Vite configuration for web platform with PWA support
|
||||
**Description**: Vite configuration for the web platform
|
||||
|
||||
**Purpose**: Configures Vite for web builds with environment-specific
|
||||
optimizations and PWA features.
|
||||
**Purpose**: Configures Vite for web builds by delegating to the common
|
||||
configuration with the `web` platform.
|
||||
|
||||
**Configuration Structure**:
|
||||
|
||||
```typescript
|
||||
export default defineConfig(async () => createBuildConfig('web'));
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
|
||||
- **Environment-Specific Configuration**: Different settings for dev/test/prod
|
||||
- **PWA Integration**: Progressive Web App support with service worker
|
||||
- **Build Optimization**: Manual chunk splitting for better caching
|
||||
- **Source Maps**: Environment-specific source map configuration
|
||||
- **Caching Strategies**: Production-specific API caching
|
||||
|
||||
**Environment Configurations**:
|
||||
|
||||
**Development Mode**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
build: {
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
rollupOptions: { output: { manualChunks: undefined } }
|
||||
},
|
||||
define: {
|
||||
__DEV__: true,
|
||||
__TEST__: false,
|
||||
__PROD__: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Test Mode**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
build: {
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
rollupOptions: { output: { manualChunks: undefined } }
|
||||
},
|
||||
define: {
|
||||
__DEV__: false,
|
||||
__TEST__: true,
|
||||
__PROD__: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Production Mode**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
build: {
|
||||
minify: 'terser',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['vue', 'vue-router', 'pinia'],
|
||||
utils: ['luxon', 'ramda', 'zod'],
|
||||
crypto: ['@ethersproject/wallet', '@ethersproject/hdnode'],
|
||||
sql: ['@jlongster/sql.js', 'absurd-sql']
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
define: {
|
||||
__DEV__: false,
|
||||
__TEST__: false,
|
||||
__PROD__: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**PWA Configuration**:
|
||||
|
||||
```typescript
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: appConfig.pwaConfig?.manifest,
|
||||
devOptions: { enabled: true, type: 'classic' },
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
sourcemap: mode !== 'production',
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||
runtimeCaching: mode === 'production' ? [/* API caching */] : []
|
||||
}
|
||||
})
|
||||
```
|
||||
- **Minimal Configuration**: Inherits all settings from `vite.config.common.mts`
|
||||
- **Mode Selection**: `--mode test` or `--mode production` (passed by
|
||||
`scripts/build-web.sh`) selects the matching `.env.*` file
|
||||
- **Output**: Writes to `dist/`
|
||||
|
||||
### B.3 vite.config.electron.mts
|
||||
|
||||
@@ -2669,7 +2584,6 @@ export default defineConfig(async () => createBuildConfig('capacitor'));
|
||||
|
||||
- **Minimal Configuration**: Inherits all settings from common config
|
||||
- **Mobile Platform**: Automatically configures for mobile-specific settings
|
||||
- **PWA Disabled**: Progressive Web App features disabled for native apps
|
||||
- **Native Dependencies**: Handles Capacitor-specific dependencies
|
||||
|
||||
### B.5 vite.config.utils.mts
|
||||
@@ -2678,51 +2592,15 @@ export default defineConfig(async () => createBuildConfig('capacitor'));
|
||||
**Author**: Matthew Raymer
|
||||
**Description**: Utility functions for Vite configuration
|
||||
|
||||
**Purpose**: Provides shared configuration loading and PWA manifest generation.
|
||||
**Purpose**: Provides the shared alias configuration and strict ESBuild settings.
|
||||
|
||||
**Key Functions**:
|
||||
|
||||
**loadPackageJson()**: Loads and parses package.json for app configuration
|
||||
**loadAppConfig()**: Returns the alias configuration used by the common and
|
||||
optimized configs
|
||||
|
||||
**loadAppConfig()**: Loads complete application configuration including PWA settings
|
||||
|
||||
**PWA Configuration**:
|
||||
|
||||
```typescript
|
||||
interface PWAConfig {
|
||||
registerType: string;
|
||||
strategies: string;
|
||||
srcDir: string;
|
||||
filename: string;
|
||||
manifest: {
|
||||
name: string;
|
||||
short_name: string;
|
||||
theme_color: string;
|
||||
background_color: string;
|
||||
icons: ManifestIcon[];
|
||||
share_target: ShareTarget;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Manifest Icons**:
|
||||
|
||||
- Android Chrome 192x192 PNG
|
||||
- Android Chrome 512x512 PNG
|
||||
- Maskable icons for adaptive UI
|
||||
|
||||
**Share Target Configuration**:
|
||||
|
||||
```typescript
|
||||
share_target: {
|
||||
action: "/share-target",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data",
|
||||
params: {
|
||||
files: [{ name: "photo", accept: ["image/*"] }]
|
||||
}
|
||||
}
|
||||
```
|
||||
**getStrictESBuildConfig()**: Returns ESBuild settings that fail the build on
|
||||
syntax and duplicate-declaration errors
|
||||
|
||||
**Alias Configuration**:
|
||||
|
||||
@@ -2790,8 +2668,7 @@ All scripts use consistent error handling:
|
||||
|
||||
**Web Platform**:
|
||||
|
||||
- PWA features enabled
|
||||
- Service worker injection
|
||||
- Requires a secure context (HTTPS, `localhost`, `127.0.0.1`, or `[::1]`)
|
||||
- Browser-specific optimizations
|
||||
|
||||
**Electron Platform**:
|
||||
|
||||
@@ -6,9 +6,86 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [1.3.8] - 2026
|
||||
## [1.4.10] - 2026.10.17
|
||||
### Changed
|
||||
- New Activities were heavily refactored and enhanced to match alertSearch endpoints with more results, cursors, etc.
|
||||
- `npm run build:web` builds production into `dist/` instead of starting the dev server.
|
||||
- `npm run build:web:serve` builds production (instead of development) before serving.
|
||||
- `npm run build:web:docker` builds production (instead of development) before creating the image.
|
||||
- `npm run build:native` builds with the Capacitor Vite config instead of a bare `vite build`.
|
||||
- The claim and profile pages show "Who Can Introduce You" as a collapsed section, loading the next-closer people only when opened.
|
||||
- Messages about hidden people point to who can introduce you, instead of saying nobody can.
|
||||
- "Who Can Introduce You" labels a person whom Endorser hides in its results as outside your view.
|
||||
### Removed
|
||||
- `npm run build:web:dev` is removed; use `npm run dev` for the dev server.
|
||||
- Leftover service-worker build code (`sw_scripts/`, the unused PWA config, and the `register-service-worker` package) is removed.
|
||||
### Fixed
|
||||
- The daily starred-project check (Android and iOS) remembers the newest change it found, so it no longer reports the same changes again the next day.
|
||||
- Sharing a photo into the installed web app (Web Share Target) works again.
|
||||
- `npm run docker:up:test` and `npm run docker:up:prod` run, instead of failing on a missing `build:web:build` script.
|
||||
- Affirm Delivery on an offer to a project waits for the project to load, so a quick tap no longer makes the offerer both giver and recipient.
|
||||
- When the browser cannot run WebAssembly ("compiler"), the start-up error screen explains it and points to the settings that block it.
|
||||
- An unregistered user no longer sees every person on a claim described as unknown to this server.
|
||||
|
||||
|
||||
## [1.4.9] - 2026.10.11
|
||||
### Fixed
|
||||
- Startup DB error for new installations
|
||||
### Changed
|
||||
- Allow in-app notifications on production (as an advanced feature)
|
||||
- Allow modification of image server URL
|
||||
- Terms & conditions and privacy policy
|
||||
|
||||
|
||||
## [1.4.6] - 2026.09.27
|
||||
### Fixed
|
||||
- Incorrect 'test' server URLs on production.
|
||||
|
||||
|
||||
## [1.4.5] - 2026.09.24
|
||||
### Added
|
||||
- Full flow for setting up SMS notifications: phone registration and code
|
||||
verification, delegated alertSearch JWT batches with a send hour, and
|
||||
revocation to stop the texts
|
||||
- Notification debug panel action to mint and upload a push-channel alertSearch
|
||||
authorization, built on the same batch minter as the SMS channel (hidden by dev flag)
|
||||
### Fixed
|
||||
- Native build scripts set NODE_ENV, so `import.meta.env.DEV` matches the build
|
||||
mode instead of always reporting a production build
|
||||
- Android and iOS development builds keep their emulator and `--api-ip` API
|
||||
addresses, which the `.env` files had been overwriting
|
||||
|
||||
|
||||
## [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]
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
# Using npm scripts (recommended):
|
||||
# Production: npm run build:web:docker:prod
|
||||
# Test: npm run build:web:docker:test
|
||||
# Development: npm run build:web:docker
|
||||
# Development: ./scripts/build-web.sh --docker --dev
|
||||
#
|
||||
# Manual workflow:
|
||||
# 1. Build web assets: npm run build:web:build -- --mode production
|
||||
# 1. Build web assets: npm run build:web:prod
|
||||
# 2. Build Docker: docker build -t timesafari:latest .
|
||||
#
|
||||
# Note: For development, use npm run build:web directly (no Docker needed)
|
||||
# Note: For development, use npm run dev directly (no Docker needed)
|
||||
#
|
||||
# Build Arguments:
|
||||
# BUILD_MODE: development, test, or production (default: production)
|
||||
|
||||
@@ -20,7 +20,7 @@ npm install
|
||||
### Web
|
||||
|
||||
```bash
|
||||
npm run build:web:dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Then go to [the test page](http://localhost:8080/test) and click "Become User 0" to take action on the platform.
|
||||
@@ -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
|
||||
@@ -89,16 +98,16 @@ TimeSafari supports configurable logging levels via the `VITE_LOG_LEVEL` environ
|
||||
|
||||
```bash
|
||||
# Show only errors
|
||||
VITE_LOG_LEVEL=error npm run build:web:dev
|
||||
VITE_LOG_LEVEL=error npm run dev
|
||||
|
||||
# Show warnings and errors
|
||||
VITE_LOG_LEVEL=warn npm run build:web:dev
|
||||
VITE_LOG_LEVEL=warn npm run dev
|
||||
|
||||
# Show info, warnings, and errors (default)
|
||||
VITE_LOG_LEVEL=info npm run build:web:dev
|
||||
VITE_LOG_LEVEL=info npm run dev
|
||||
|
||||
# Show all log levels including debug
|
||||
VITE_LOG_LEVEL=debug npm run build:web:dev
|
||||
VITE_LOG_LEVEL=debug npm run dev
|
||||
```
|
||||
|
||||
### Available Levels
|
||||
@@ -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**.
|
||||
|
||||
@@ -138,7 +147,7 @@ Platform-specific end-to-end guides:
|
||||
|
||||
# Then restart your development server
|
||||
npm run build:electron:dev # For Electron
|
||||
npm run build:web:dev # For Web
|
||||
npm run dev # For Web
|
||||
```
|
||||
|
||||
### What It Does
|
||||
@@ -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
|
||||
@@ -330,7 +339,7 @@ The application uses a platform-agnostic database layer with Vue mixins for serv
|
||||
|
||||
**Architecture Decision**: The project uses Vue mixins over Composition API composables for platform service access. See [Architecture Decisions](doc/architecture-decisions.md) for detailed rationale.
|
||||
|
||||
## 📁 Project Structure
|
||||
### 📁 Project Structure
|
||||
|
||||
```text
|
||||
timesafari/
|
||||
@@ -345,6 +354,45 @@ timesafari/
|
||||
└── 📄 doc/README-BUILD-GUARD.md # Guard system documentation
|
||||
```
|
||||
|
||||
### Building an APK for Android 6 (API 23)
|
||||
|
||||
App versions up to 1.4.6 support Android 6.0 (API 23); later versions require Android 7.0 (API 24) or newer. To run the app on an Android 6 device, build your own APK from the 1.4.6 release commit (`6183afc2`, "bump version to 1.4.6").
|
||||
|
||||
Prerequisites: git, Node.js 20 (see `.nvmrc`), Java 21, and the Android SDK with `ANDROID_HOME` set (see [Android Emulator Without Android Studio](BUILDING.md#android-emulator-without-android-studio-command-line-only) for an SDK setup that skips Android Studio). With [pkgx](https://pkgx.dev), prefix the commands with `pkgx`, e.g. `pkgx npm ci`.
|
||||
|
||||
1. Get the 1.4.6 source:
|
||||
|
||||
```bash
|
||||
git clone https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa.git
|
||||
cd crowd-funder-for-time-pwa
|
||||
git checkout 6183afc2
|
||||
```
|
||||
|
||||
2. Confirm the minimum SDK is 23:
|
||||
|
||||
```bash
|
||||
grep minSdkVersion android/variables.gradle # expect: minSdkVersion = 23
|
||||
```
|
||||
|
||||
3. Install dependencies and build a production APK:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build:android:prod -- --apk
|
||||
```
|
||||
|
||||
`npm ci` installs the exact dependency versions locked for 1.4.6. `build:android:prod -- --apk` builds the web app with production server settings, syncs it into the Android project, and runs `./gradlew assembleDebug`. The result is signed with your local Android debug key, so it installs on any device without a release keystore.
|
||||
|
||||
4. Find the APK at `android/app/build/outputs/apk/debug/app-debug.apk` and install it, either with `adb install android/app/build/outputs/apk/debug/app-debug.apk` (USB debugging enabled on the phone) or by copying the file to the phone and opening it (allow "Install unknown apps" for the file manager).
|
||||
|
||||
Notes:
|
||||
|
||||
* **Back up your identity first.** A self-built APK has a different signature from the Play Store app, so Android refuses to install it over the store version. Uninstalling the store version deletes the app's local data, including your identifier keys. Back up your seed phrase (Profile page → "Backup Identifier Seed") before uninstalling, then import it into the self-built app.
|
||||
* This build has Firebase disabled, so push notifications through Google's FCM service are off.
|
||||
* The daily notification plugin calls `java.time`, which Android provides only from 8.0 (API 26). On Android 6, scheduling a daily reminder can crash the app; leave daily reminders off. See [doc/plugin-feedback-android-6-api23-zoneid-fix.md](doc/plugin-feedback-android-6-api23-zoneid-fix.md).
|
||||
* Barcode scanning uses Google Play Services; on devices without them, scanning fails while the rest of the app works.
|
||||
* A self-built APK does not auto-update.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. **Follow the Build Architecture Guard** - Update BUILDING.md when modifying build files
|
||||
|
||||
@@ -29,16 +29,18 @@ android {
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
// Backports java.time (API 26+) to older Android; the daily notification plugin uses it.
|
||||
coreLibraryDesugaringEnabled true
|
||||
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 76
|
||||
versionName "1.4.10"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -72,13 +74,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 {
|
||||
@@ -100,6 +103,7 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5"
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
@@ -129,11 +133,32 @@ dependencies {
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
// Firebase is on for every build. Opt out only with -PfirebaseEnabled=false
|
||||
// (F-Droid / other builds that must not apply the google-services plugin).
|
||||
// The config file does not decide the switch: when Firebase is on, a missing
|
||||
// file fails the build; when it is off, a file on disk is ignored.
|
||||
def firebaseEnabledRaw = project.findProperty('firebaseEnabled')
|
||||
def firebaseEnabled = true
|
||||
if (firebaseEnabledRaw != null) {
|
||||
def normalized = firebaseEnabledRaw.toString().trim().toLowerCase()
|
||||
if (normalized == 'false' || normalized == '0' || normalized == 'no') {
|
||||
firebaseEnabled = false
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
|
||||
if (!firebaseEnabled) {
|
||||
logger.lifecycle('Firebase disabled (-PfirebaseEnabled=false): google-services plugin not applied')
|
||||
} else {
|
||||
def servicesJSON = file('google-services.json')
|
||||
def servicesBody = servicesJSON.isFile() ? servicesJSON.getText('UTF-8') : ''
|
||||
if (!servicesBody.trim() || !servicesBody.contains('mobilesdk_app_id')) {
|
||||
throw new GradleException(
|
||||
'Firebase is enabled, but android/app/google-services.json is missing, empty, or has no mobilesdk_app_id.\n' +
|
||||
'Download it from the Firebase console (Project settings → Your apps → Android app)\n' +
|
||||
'and place it at android/app/google-services.json.\n' +
|
||||
'To build without Firebase (F-Droid / FOSS), pass -PfirebaseEnabled=false.'
|
||||
)
|
||||
}
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
logger.lifecycle('Firebase enabled: google-services plugin applied')
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<application
|
||||
android:name=".TimeSafariApplication"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"appId": "app.timesafari",
|
||||
"appName": "TimeSafari",
|
||||
"appName": "Giftopia",
|
||||
"webDir": "dist",
|
||||
"server": {
|
||||
"cleartext": true
|
||||
@@ -36,17 +36,17 @@
|
||||
"splashFullScreen": true,
|
||||
"splashImmersive": true
|
||||
},
|
||||
"CapSQLite": {
|
||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
||||
"CapacitorSQLite": {
|
||||
"iosDatabaseLocation": "Documents",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -54,5 +54,9 @@
|
||||
{
|
||||
"pkg": "SharedImage",
|
||||
"classpath": "app.timesafari.sharedimage.SharedImagePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "NotificationInspector",
|
||||
"classpath": "app.timesafari.notifications.NotificationInspectorPlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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()) {
|
||||
@@ -301,23 +311,25 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
|
||||
return prefs.getString(KEY_LAST_ACKED_JWT_ID, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the newest JWT ID in the response (the highest ULID), so the next fetch
|
||||
* asks only for later changes. The server lists them newest first, so the last
|
||||
* item is the oldest.
|
||||
*/
|
||||
private void updateLastAckedJwtIdFromResponse(String responseBody) {
|
||||
try {
|
||||
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
|
||||
if (!root.has("data")) return;
|
||||
JsonArray dataArray = root.getAsJsonArray("data");
|
||||
if (dataArray == null || dataArray.size() == 0) return;
|
||||
JsonObject lastItem = dataArray.get(dataArray.size() - 1).getAsJsonObject();
|
||||
String jwtId = null;
|
||||
if (lastItem.has("jwtId")) {
|
||||
jwtId = lastItem.get("jwtId").getAsString();
|
||||
} else if (lastItem.has("plan")) {
|
||||
JsonObject plan = lastItem.getAsJsonObject("plan");
|
||||
if (plan.has("jwtId")) {
|
||||
jwtId = plan.get("jwtId").getAsString();
|
||||
for (int i = 0; i < dataArray.size(); i++) {
|
||||
String itemJwtId = extractJwtIdFromItem(dataArray.get(i).getAsJsonObject());
|
||||
if (itemJwtId != null && !itemJwtId.isEmpty() && (jwtId == null || itemJwtId.compareTo(jwtId) > 0)) {
|
||||
jwtId = itemJwtId;
|
||||
}
|
||||
}
|
||||
if (jwtId != null && !jwtId.isEmpty()) {
|
||||
if (jwtId != null) {
|
||||
prefs.edit().putString(KEY_LAST_ACKED_JWT_ID, jwtId).apply();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.timesafari.notifications;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
@@ -12,5 +13,20 @@ public class NotificationInspectorPlugin extends Plugin {
|
||||
call.unimplemented(
|
||||
"Pending notification inspection is currently implemented on iOS only");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this build carries a Firebase config. Normal builds do.
|
||||
* -PfirebaseEnabled=false omits it. Without it, PushNotifications.register() throws on the
|
||||
* Capacitor plugin thread and kills the app, so JS must check this first.
|
||||
* The google-services Gradle plugin generates the google_app_id resource,
|
||||
* which is what Firebase's own auto-initialization looks for.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void isFirebaseConfigured(PluginCall call) {
|
||||
int resId = getContext().getResources().getIdentifier(
|
||||
"google_app_id", "string", getContext().getPackageName());
|
||||
JSObject ret = new JSObject();
|
||||
ret.put("configured", resId != 0);
|
||||
call.resolve(ret);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Backup rules for Android 11 and lower; data_extraction_rules.xml has the same
|
||||
exclusions for Android 12 and higher. Everything not excluded is backed up.
|
||||
|
||||
The SQLite plugin's encrypted preferences are tied to an Android Keystore key,
|
||||
and Keystore keys are never backed up. Restoring the preferences without the
|
||||
key makes the plugin fail to load on every launch.
|
||||
-->
|
||||
<full-backup-content>
|
||||
<exclude domain="sharedpref" path="sqlite_encrypted_shared_prefs.xml" />
|
||||
<exclude domain="sharedpref" path="__androidx_security_crypto_encrypted_prefs_key_keyset__.xml" />
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Backup and device-transfer rules for Android 12 and higher; backup_rules.xml
|
||||
has the same exclusions for Android 11 and lower. Everything not excluded is
|
||||
backed up and transferred, including the database (which holds the user's keys).
|
||||
|
||||
The SQLite plugin's encrypted preferences are tied to an Android Keystore key,
|
||||
and Keystore keys never leave the device. Restoring the preferences without
|
||||
the key makes the plugin fail to load on every launch.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="sharedpref" path="sqlite_encrypted_shared_prefs.xml" />
|
||||
<exclude domain="sharedpref" path="__androidx_security_crypto_encrypted_prefs_key_keyset__.xml" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="sharedpref" path="sqlite_encrypted_shared_prefs.xml" />
|
||||
<exclude domain="sharedpref" path="__androidx_security_crypto_encrypted_prefs_key_keyset__.xml" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -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 = [:]
|
||||
|
||||
@@ -22,3 +22,8 @@ org.gradle.jvmargs=-Xmx1536m --add-opens=jdk.compiler/com.sun.tools.javac.api=AL
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
android.suppressUnsupportedCompileSdk=36
|
||||
|
||||
# Firebase/FCM is on for every Gradle build (Android Studio, ./gradlew, npm scripts).
|
||||
# Opt out with -PfirebaseEnabled=false. android/app/google-services.json does not
|
||||
# toggle this; a missing file fails the build while Firebase is enabled.
|
||||
firebaseEnabled=true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ext {
|
||||
minSdkVersion = 23
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.8.0'
|
||||
@@ -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
|
||||
@@ -30,16 +30,16 @@
|
||||
"splashImmersive": true
|
||||
},
|
||||
"CapacitorSQLite": {
|
||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
||||
"iosDatabaseLocation": "Documents",
|
||||
"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
|
||||
@@ -34,17 +34,19 @@ const config: CapacitorConfig = {
|
||||
splashFullScreen: true,
|
||||
splashImmersive: true
|
||||
},
|
||||
CapSQLite: {
|
||||
iosDatabaseLocation: 'Library/CapacitorDatabase',
|
||||
// The key must match the plugin's registered name or every setting here is ignored.
|
||||
CapacitorSQLite: {
|
||||
// Installed iOS apps keep their database in Documents; changing this strands existing data.
|
||||
iosDatabaseLocation: 'Documents',
|
||||
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 +105,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
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
# Plan: Contacts Without a DID ("Placeholder Contacts")
|
||||
|
||||
## Goal
|
||||
|
||||
Let a user record a gift (or offer) given by or to a person who has not
|
||||
installed the app, under that person's name, and later swap that person's real
|
||||
DID into those claims once they join.
|
||||
|
||||
Requirements:
|
||||
|
||||
1. A locally saved name for a person with no DID, plus everything a real
|
||||
contact can hold locally: phone numbers and other contact methods, notes,
|
||||
and labels, edited through the same screens.
|
||||
2. That person can be picked as the giver or recipient in the gift dialogs,
|
||||
the same way a real contact is.
|
||||
3. The app keeps a local list of every claim that names the person, so the
|
||||
claims can be found again without a server search.
|
||||
4. When the person joins, the user links the placeholder to the person's real
|
||||
contact, and the app edits each linked claim so the real DID appears
|
||||
where the placeholder was.
|
||||
5. Nothing that identifies a placeholder — its name or its local key — is
|
||||
sent to the endorser server or shared with other users.
|
||||
6. When the app links contacts to the device's address book, a placeholder
|
||||
links the same way a real contact does, and the link survives the
|
||||
placeholder becoming a real contact.
|
||||
|
||||
## Relevant constraints in the current schema
|
||||
|
||||
These come from `src/db-sql/migration.ts` and
|
||||
`src/utils/PlatformServiceMixin.ts`.
|
||||
|
||||
| Constraint | Where | Effect on this feature |
|
||||
|---|---|---|
|
||||
| `contacts.did TEXT NOT NULL` | migration 008 | A contact row must have some DID value. |
|
||||
| `UNIQUE INDEX idx_contacts_did ON contacts(did)` | migration 008 | Every contact's DID must be distinct. (SQLite allows multiple NULLs in a unique index, so the index alone would not block NULL DIDs; `NOT NULL` does.) |
|
||||
| `contact_labels.did ... REFERENCES contacts(did) ON DELETE CASCADE` | migration 008 | Labels are keyed by DID. There is no `ON UPDATE` action, so `UPDATE contacts SET did = ...` fails with `FOREIGN KEY constraint failed` while the contact has labels (verified with the `sqlite3` CLI), when foreign keys are enforced on the connection. |
|
||||
| `$insertContact` uses `INSERT OR REPLACE` | `PlatformServiceMixin.ts` | REPLACE deletes the conflicting row before inserting. With foreign keys enforced, that delete cascades and wipes the contact's labels. The new row also gets a new `id`, because `id` is not in the insert's column list (both verified with the `sqlite3` CLI). Any table that references `contacts(did)` with `ON DELETE CASCADE` inherits the first hazard, and anything that references `contacts.id` inherits the second. Callers: `ContactsView`, `InviteOneView`, `MeetingMembersList`, `BulkMembersDialog`. |
|
||||
| `Contact.did: string` (required) | `src/db/tables/contacts.ts` | Every view, lookup (`contactForDid`, `didInfo`), and mixin method (`$getContact`, `$updateContact`, `$deleteContact`, label methods) is keyed by DID string. |
|
||||
| `isDid(s)` is `s.startsWith("did:")` | `src/libs/endorserServer.ts` | Any string starting with `did:` is treated as a real DID by import validation, display logic, and server calls. |
|
||||
|
||||
Whether foreign keys are enforced at runtime depends on whether a
|
||||
`PRAGMA foreign_keys = ON` took effect on the current connection; nothing
|
||||
outside the migrations sets it. The plan below assumes they may be enforced
|
||||
and must work either way.
|
||||
|
||||
## Recommended: local placeholder key in `contacts`, no DID in the claim
|
||||
|
||||
### Summary
|
||||
|
||||
- A placeholder is an ordinary row in `contacts` whose `did` column holds a
|
||||
local-only key: `local:` followed by a random UUID, e.g.
|
||||
`local:3f0c9a8e-5d2b-4c1e-9b7a-2e6f1d0c4a11`.
|
||||
- The key deliberately does **not** start with `did:`, so `isDid()` is false
|
||||
for it and every existing DID-only code path rejects it instead of treating
|
||||
it as real.
|
||||
- Claims naming a placeholder carry **no** agent/recipient DID in that slot —
|
||||
the same shape the gift dialog already sends for "Unnamed".
|
||||
- A new table records which claims involve which placeholder, so they can be
|
||||
edited when the real DID is known.
|
||||
|
||||
The only schema change is one additive `CREATE TABLE`. The `contacts` and
|
||||
`contact_labels` tables are untouched, so no table rebuild is needed (a
|
||||
rebuild is the risky part of migration 008).
|
||||
|
||||
### Schema change (migration 012)
|
||||
|
||||
```sql
|
||||
-- Claims that name a placeholder contact (contacts.did LIKE 'local:%') in place of a DID,
|
||||
-- kept so they can be edited to carry the real DID once that person joins.
|
||||
CREATE TABLE placeholder_claims (
|
||||
contactKey TEXT NOT NULL, -- contacts.did: the placeholder key, or the real DID after linking
|
||||
claimHandleId TEXT NOT NULL, -- handleId from the server; stays the same across edits
|
||||
role TEXT NOT NULL CHECK (role IN ('agent', 'recipient')),
|
||||
claimType TEXT NOT NULL, -- eg. 'GiveAction', 'Offer'
|
||||
issuerDid TEXT NOT NULL, -- only this identity can edit the claim
|
||||
apiServer TEXT NOT NULL, -- the server the claim was sent to
|
||||
dateCreated TEXT NOT NULL,
|
||||
dateAssigned TEXT, -- set when the claim has been edited to carry the real DID
|
||||
PRIMARY KEY (contactKey, claimHandleId, role)
|
||||
);
|
||||
CREATE INDEX idx_placeholder_claims_key ON placeholder_claims(contactKey);
|
||||
```
|
||||
|
||||
The column is named for the contact rather than the placeholder because
|
||||
linking rekeys it to the real DID along with every other contact-keyed column (see
|
||||
*Linking when the person joins*). A row with a real DID and
|
||||
`dateAssigned IS NULL` is a claim still waiting to be edited.
|
||||
|
||||
No foreign key to `contacts`: a cascade would let any `INSERT OR REPLACE` of
|
||||
the placeholder contact silently delete its claim links (see the
|
||||
`$insertContact` row above), and those links are the only record of which
|
||||
claims to fix. Orphan cleanup happens in code when a placeholder is deleted
|
||||
on purpose.
|
||||
|
||||
The `role` column is part of the key because one claim can name the same
|
||||
placeholder as both giver and recipient.
|
||||
|
||||
### Code changes
|
||||
|
||||
**Prerequisite: make `$insertContact` an upsert that keeps the row**
|
||||
|
||||
Change `INSERT OR REPLACE` to an `UPDATE ... WHERE did = ?`, followed by an
|
||||
`INSERT` only when no row was updated. That keeps the row's `id` and leaves
|
||||
its labels and other contact-keyed rows alone. An `INSERT ... ON CONFLICT(did)
|
||||
DO UPDATE` does the same in one statement, but needs SQLite 3.24+ on every
|
||||
platform's driver, and that hasn't been checked. This fixes an existing
|
||||
label-loss hazard and is worth shipping first, on its own.
|
||||
|
||||
**Identity helpers** (`src/libs/endorserServer.ts`)
|
||||
|
||||
- `PLACEHOLDER_KEY_PREFIX = "local:"`, `isPlaceholderKey(s)`, and
|
||||
`newPlaceholderKey()` (uses `crypto.randomUUID()`).
|
||||
- `didInfoForContact`: a placeholder contact displays its name, with a marker
|
||||
such as "(not on Time Safari yet)", and `known: true`.
|
||||
|
||||
**Creating a placeholder**
|
||||
|
||||
- Contacts view: an "Add someone without the app" action that asks only for
|
||||
a name (notes and contact methods optional) and saves a contact with a new
|
||||
placeholder key.
|
||||
- Gift dialog entity picker: the same action inline, so a user can create a
|
||||
placeholder while recording a gift.
|
||||
- Device address book (once that integration exists): pick a person from the
|
||||
phone's contacts. The placeholder is created with that name and phone
|
||||
number filled in and already linked to the device contact.
|
||||
|
||||
**Phone numbers, notes, and labels**
|
||||
|
||||
These need no new storage. `ContactEditView` (`/contact-edit/:did`) edits
|
||||
`contactMethods` (the JSON column holding phone numbers, emails, and so
|
||||
on), `notes`, and labels through `contact_labels`, all keyed by the
|
||||
`contacts.did` value and with no server calls. A placeholder key in that
|
||||
route parameter works as-is. Changes needed:
|
||||
|
||||
- Hide the server-backed parts of contact pages (visibility, registration,
|
||||
the DID/key display) for placeholders. The edit form itself stays as it
|
||||
is.
|
||||
- Store phone numbers in a normalized form (E.164, e.g. `+18015550100`)
|
||||
alongside what the user typed, so they can be compared for matching when
|
||||
the person joins (see *Linking*). Normalizing on save is cheaper than
|
||||
normalizing on every comparison.
|
||||
|
||||
**Device address-book links**
|
||||
|
||||
Whatever table holds the link between an app contact and a device contact
|
||||
(Android lookup key / iOS `CNContact` identifier) is keyed by the
|
||||
`contacts.did` value, the same as `contact_labels`. That gives real contacts
|
||||
and placeholders one shared mechanism, and linking rekeys the link along
|
||||
with everything else. Two rules for that table:
|
||||
|
||||
- No `ON DELETE CASCADE` foreign key to `contacts`, for the same reason as
|
||||
`placeholder_claims`. Code cleans up these rows when a contact is
|
||||
deleted on purpose.
|
||||
- Device identifiers mean nothing on another device, so a backup restored
|
||||
elsewhere either leaves these rows out or re-checks them against that
|
||||
device's address book.
|
||||
|
||||
**One contact-keyed registry**
|
||||
|
||||
Every table with a column holding a `contacts.did` value is listed in one
|
||||
constant, e.g. `CONTACT_KEYED_COLUMNS = [["contact_labels", "did"],
|
||||
["placeholder_claims", "contactKey"], ...]`, which `rekeyContact` and
|
||||
`deleteContact` iterate over. A test compares that list with the live
|
||||
schema: every foreign key to `contacts(did)` (from `PRAGMA
|
||||
foreign_key_list`), plus any column named `contactKey`. A table added later
|
||||
and left out of the list fails the test, so it can't be orphaned by a
|
||||
rekey.
|
||||
|
||||
**Recording a claim** (`GiftedDialog`, offer dialog, `hydrateGive`, `hydrateOffer` callers)
|
||||
|
||||
- When the chosen giver or recipient is a placeholder, pass `undefined` for
|
||||
that DID to `createAndSubmitGive` / `createAndSubmitOffer`.
|
||||
- On success, insert a `placeholder_claims` row with the returned `handleId`,
|
||||
the role, the active DID as issuer, and the API server.
|
||||
- If the submission succeeds but the local insert fails, show an error that
|
||||
names the claim, because the link would otherwise be lost silently.
|
||||
- A placeholder on both sides is allowed: the server accepts a give with no
|
||||
agent, no recipient, and no project.
|
||||
|
||||
**Choosing a giver or recipient** (`EntityGrid`, `EntitySelectionStep`, `PersonCard`)
|
||||
|
||||
Placeholders appear in the picker in alphabetical order, searchable by name,
|
||||
mixed in with real contacts. Because a placeholder is a row in `contacts`,
|
||||
this needs almost no list-merging work:
|
||||
|
||||
- `GiftedDialog` passes `$contactsByDateAdded()` to `EntityGrid`, which
|
||||
already builds both sections from that one array: "Recently Added" (the
|
||||
first rows, newest by `id`) and "Everyone" (sorted by
|
||||
`(name || did).toLowerCase()` with `localeCompare`). Placeholders land in
|
||||
both sections with no extra query and no merge step.
|
||||
- `performContactSearch` filters the same array by name and DID substring
|
||||
and sorts it the same way, so placeholders are searchable automatically.
|
||||
|
||||
What does need care:
|
||||
|
||||
- **Require a name.** Both sorts fall back to the DID when the name is blank,
|
||||
which would put a nameless placeholder under "L" for `local:`. Saving a
|
||||
placeholder requires a non-blank, trimmed name.
|
||||
- **Don't match on the key.** Searching "local" or a UUID fragment would
|
||||
match every placeholder. In `performContactSearch`, compare against the
|
||||
DID only when `!isPlaceholderKey(contact.did)`.
|
||||
- **One comparator.** The sort is written out twice (`alphabeticalContacts`
|
||||
and `performContactSearch`). Pull it into one exported function, e.g.
|
||||
`compareContactsByName`, so a placeholder tiebreak only has to be added
|
||||
once. For a tie, put real contacts before placeholders and then compare
|
||||
keys, so a real "Bob" and a placeholder "Bob" always come out in the same
|
||||
order.
|
||||
- **Mark placeholders on the card.** `PersonCard` shows a small badge or
|
||||
subtitle ("not on Time Safari yet"), and `EntityIcon` shows a fixed
|
||||
"person without the app" icon rather than an identicon built from the
|
||||
key. This matters most when two people share a name.
|
||||
- **Create from search.** When a contact search finds nothing (the
|
||||
`filteredEntities.length === 0` message), offer "Add “<term>” as someone
|
||||
without the app". The new placeholder is selected immediately and the
|
||||
dialog moves to the next step. Next to the "Unnamed" `SpecialEntityCard`,
|
||||
an "Add someone without the app" card covers the case where nothing has
|
||||
been typed yet.
|
||||
- **Keep the selection key-based.** `handlePersonSelected` and
|
||||
`isPersonConflicted` compare `did` strings, and the placeholder key works
|
||||
there unchanged. Convert key → `undefined` only at submission time (see
|
||||
*Recording a claim*), never in the picker, or two placeholders would
|
||||
look identical to the conflict check.
|
||||
- **Filter where a real DID is required.** `ProjectRepresentativeDialog`
|
||||
uses the same grid to pick someone who will act on the server, so it gets
|
||||
`allContacts.filter(c => !isPlaceholderKey(c.did))`. The same goes for any
|
||||
other picker whose choice is sent to the server as a DID.
|
||||
|
||||
The `EntityGrid` doc comment says arrays must be sorted by date added
|
||||
(newest first). That holds for placeholders because they get ordinary
|
||||
autoincrement `id`s.
|
||||
|
||||
**Guards: a placeholder key must never reach the server or another user**
|
||||
|
||||
Every place that sends a contact's DID somewhere needs to skip or refuse
|
||||
placeholders. From the current code, that includes:
|
||||
|
||||
- Visibility toggles and checks (`setVisibilityUtil`, "sees me" refresh)
|
||||
- Registration (`register`) and invite flows
|
||||
- Contact sharing: QR/JWT URL generation, CSV export (`CONTACT_CSV_HEADER`),
|
||||
"copy selected contacts", `contactImportPayload`
|
||||
- Server-side give/offer totals per contact in `ContactsView` (`loadGives`)
|
||||
- The `/did/:did` route (`DIDView`), which queries the server: placeholders
|
||||
open a local-only detail page instead
|
||||
- Meeting member flows (`MeetingMembersList`, `BulkMembersDialog`)
|
||||
|
||||
As a backstop, `createAndSubmitClaim` throws if the serialized claim contains
|
||||
any `local:` key, so a missed guard fails loudly rather than publishing the
|
||||
key.
|
||||
|
||||
**Backups**: the full data export (DataExportSection / backup) **includes**
|
||||
placeholders and the `placeholder_claims` table, since they exist only on
|
||||
this device. Contact sharing with other users **excludes** them.
|
||||
|
||||
**Linking when the person joins**
|
||||
|
||||
The placeholder's row **becomes** the real contact: its `did` value is
|
||||
rekeyed from the `local:` key to the real DID. Its `id`, phone numbers,
|
||||
notes, labels, device link, and claim rows all stay with it. Two contact
|
||||
rows are never kept for one person.
|
||||
|
||||
1. The person's DID arrives the usual way (QR scan, contact link). Before
|
||||
inserting it, the app checks for placeholders that match: the same
|
||||
normalized phone number, the same device-contact link, or the same name.
|
||||
It asks "Is this <name>, whom you recorded without the app?" The user can
|
||||
also start from the placeholder's page ("This person joined") and pick a
|
||||
contact that already exists.
|
||||
2. `rekeyContact(fromKey, toDid)`, in one transaction:
|
||||
1. Read every contact-keyed row (from the registry) for both `fromKey`
|
||||
and `toDid`.
|
||||
2. If a contact row with `toDid` already exists, copy its server-derived
|
||||
fields (`publicKeyBase64`, `nextPubKeyHashB64`, `seesMe`,
|
||||
`registered`, `profileImageUrl`) onto the placeholder row, and fill
|
||||
any other field the placeholder left empty. Then delete that row's
|
||||
dependent rows and the row itself.
|
||||
3. Delete the dependent rows for `fromKey`.
|
||||
4. `UPDATE contacts SET did = toDid WHERE did = fromKey`.
|
||||
5. Re-insert the union of both sets of dependent rows under `toDid`
|
||||
(`INSERT OR IGNORE`, so a label both rows had is kept once).
|
||||
|
||||
Dependent rows are deleted before the update and re-inserted after it
|
||||
because `UPDATE contacts SET did` fails while `contact_labels` rows
|
||||
reference the old value and foreign keys are enforced. This order works
|
||||
whether or not they are enforced, so it doesn't rely on
|
||||
`PRAGMA defer_foreign_keys`. (That pragma also works, verified with the
|
||||
`sqlite3` CLI, but only inside an explicit transaction.)
|
||||
3. Before any edits, warn about confirmations. Editing a claim does not
|
||||
carry its confirmations over to the edited version, so the app lists the
|
||||
linked claims that have confirmations (from the same data the claim
|
||||
detail view shows) and says those confirmations will have to be given
|
||||
again. The user can go ahead or cancel.
|
||||
4. For each `placeholder_claims` row for this contact with
|
||||
`dateAssigned IS NULL`:
|
||||
- skip it, with a message, if `issuerDid` is not the active identity
|
||||
(the server accepts edits only from the claim's issuer; the message
|
||||
names which of this device's accounts to switch to, if any), or if its
|
||||
`apiServer` differs from the active one;
|
||||
- fetch the current claim by handle ID, call `editAndSubmitGive` /
|
||||
`editAndSubmitOffer` with the real DID in the row's role, keeping every
|
||||
other field;
|
||||
- set `dateAssigned` on success.
|
||||
5. When every row is assigned, its `placeholder_claims` rows can be
|
||||
deleted; the contact itself stays, as an ordinary real contact. Any
|
||||
unassigned rows stay listed on the contact's page with a retry button,
|
||||
since some edits may need another account or a network connection.
|
||||
6. Prompt the user to tell the person. Once at least one claim is assigned,
|
||||
offer to share a message (through the share sheet, or a contact method
|
||||
saved on the placeholder) saying their earlier gifts are recorded under
|
||||
their identity, with a link to their gifts.
|
||||
|
||||
`hydrateGive` / `hydrateOffer` only set agent/recipient when a DID is passed
|
||||
and never clear them. That is correct for this flow (it only ever adds a
|
||||
DID), but a test should pin it so a later "clear when blank" change does not
|
||||
break assignment.
|
||||
|
||||
### Tests
|
||||
|
||||
- Migration test (`src/test/migrations.test.ts`): 012 applies on a database
|
||||
that already has contacts and labels, and leaves them unchanged.
|
||||
- `createAndSubmitClaim` rejects a claim containing a `local:` key.
|
||||
- `$insertContact` on an existing DID keeps the row's `id` and its labels,
|
||||
with foreign keys both on and off.
|
||||
- `rekeyContact`, with foreign keys both on and off: labels, claim rows,
|
||||
and device links move to the real DID; the row's `id` is unchanged; when
|
||||
a row for the real DID already existed, it is folded in and its labels
|
||||
are kept.
|
||||
- Registry coverage: every foreign key to `contacts(did)` and every
|
||||
`contactKey` column in the migrated schema appears in
|
||||
`CONTACT_KEYED_COLUMNS`.
|
||||
- Each guard listed above returns early (or hides the control) for a
|
||||
placeholder.
|
||||
|
||||
## Rejected
|
||||
|
||||
### Make `contacts.did` nullable and key contacts by `contacts.id`
|
||||
|
||||
The relationally clean model: a contact without a DID is just a row with
|
||||
`did IS NULL`.
|
||||
|
||||
- Requires rebuilding `contacts` (dropping `NOT NULL` is not possible with
|
||||
`ALTER TABLE` in SQLite) and rebuilding `contact_labels` to reference
|
||||
`contacts.id` — the same backup/drop/recreate pattern that made migration
|
||||
008 fail on every startup for some users.
|
||||
- `Contact.did` becomes optional, which breaks type-checking at every lookup,
|
||||
route, and mixin method keyed by DID across roughly 70 files, and every
|
||||
`WHERE did = ?` method needs an `id` variant.
|
||||
- Gains nothing functional over the placeholder key: the key is a surrogate
|
||||
ID that happens to live in the existing column.
|
||||
- Its one real advantage is that linking a DID never rekeys child rows. That
|
||||
advantage depends on `id` being stable, and it isn't: `INSERT OR REPLACE`
|
||||
gives the row a new `id`, and contact export/import doesn't keep `id`, so
|
||||
ids don't match across devices. The `rekeyContact` registry gets the same
|
||||
safety without a table rebuild.
|
||||
|
||||
Worth revisiting only if contacts get rekeyed by `id` for some other reason.
|
||||
|
||||
### A separate, permanent `contactKey` column on `contacts`
|
||||
|
||||
Add `contacts.contactKey TEXT UNIQUE` that never changes (the `local:` key
|
||||
for placeholders, the DID for everyone else), and key every child table by
|
||||
it, so linking only sets `did`. It is additive for new tables, but
|
||||
`contact_labels` references `contacts(did)`, so moving it to the new key is
|
||||
a table rebuild. Every DID-keyed lookup would also need to decide which key
|
||||
it means. It costs more than the rekey function it replaces.
|
||||
|
||||
### Separate `placeholder_people` table outside `contacts`
|
||||
|
||||
No risk to existing tables, but every picker and list (gift dialog entity
|
||||
grid, contacts list, labels, search) must merge two sources of different
|
||||
types, and labels need a second mapping table. The recommended option gets
|
||||
those UIs for free because a placeholder is a contact.
|
||||
|
||||
### Fake DID written into the published claim
|
||||
|
||||
E.g. `did:none:<uuid>` or a random `did:ethr:` with no key behind it, sent
|
||||
to the server as the agent/recipient.
|
||||
|
||||
- Publishes a dangling identifier that other users see as "Someone Visible
|
||||
But Not In Your Contact List"; it never resolves.
|
||||
- `did:none:` is the server's hidden-DID namespace (`did:none:HIDDEN`);
|
||||
reusing the prefix risks the server treating it as hidden or rejecting it.
|
||||
- The claim still has to be edited when the person joins, so it saves no
|
||||
work; the only gain is finding claims by server search, which the local
|
||||
table already provides.
|
||||
|
||||
### Custodial key: generate a real identity for the person
|
||||
|
||||
The app creates a real keypair for the placeholder and uses its DID in claims;
|
||||
when the person joins, they import that key, so no claims need editing.
|
||||
|
||||
- The user's device holds another person's private key, and every claim
|
||||
"about" them was made while someone else held their identity.
|
||||
- The person may already have an identity, or may install on a device
|
||||
without a way to receive the key; then the claims need editing anyway.
|
||||
- Handing over a seed phrase is a security-sensitive step this app's
|
||||
onboarding does not have.
|
||||
|
||||
### Put the person's name in the claim (`agent: { name: "..." }`)
|
||||
|
||||
- Publishes a name for someone who has not consented to being on the
|
||||
platform; server visibility rules hide DIDs, not free-text names.
|
||||
- Still needs the local record to assign the DID later.
|
||||
- Server acceptance of an agent without `identifier` is unverified.
|
||||
|
||||
### Store linked claim IDs as a JSON column on `contacts`
|
||||
|
||||
An `ALTER TABLE contacts ADD COLUMN placeholderClaims TEXT` is also additive,
|
||||
but role, issuer, server, and assignment status per claim fit a table
|
||||
better, and any whole-row rewrite of the contact (such as `INSERT OR
|
||||
REPLACE` from a stale copy) could overwrite the list.
|
||||
@@ -0,0 +1,353 @@
|
||||
# Plan: New Activity Screen on alertSearch
|
||||
|
||||
## Goal
|
||||
|
||||
The New Activity screen (`/new-activity`, `src/views/NewActivityView.vue`)
|
||||
shows the same total the daily push or text reported, broken down by kind of
|
||||
activity. Each kind opens a page with the exact list and count for that kind.
|
||||
When a page's count differs from the number the person was told, the page
|
||||
says why.
|
||||
|
||||
## The two endpoints
|
||||
|
||||
alertSearch has two halves. Each returns **lists**, not counts; a count is a
|
||||
list's length.
|
||||
|
||||
| Bucket | Server | What it holds | Needs |
|
||||
|---|---|---|---|
|
||||
| `claims` | endorser `/api/v2/report/alertSearch` | Claims by others with you as `toEntity` or `fromEntity` (offers, gives, every other claim type) | — |
|
||||
| `personalPlanContributions` | endorser | Gives and offers to or from projects you issued or are agent of (your own claims included) | — |
|
||||
| `trackedPlanUpdates` | endorser | Edits to starred projects | `planHandleIds` |
|
||||
| `trackedPlanClaims` | endorser | Gives and offers to or from starred projects | `planHandleIds` |
|
||||
| `plansNearby` | endorser | Projects created or edited in the search area | `location` |
|
||||
| `profilesNearby` | partner `/api/partner/alertSearch` | Profiles created or edited in the search area | `location` |
|
||||
| `profilesSimilar` | partner | Profiles close in meaning to your profile, best first, at most 50, not paged | a profile with text |
|
||||
| `plansSimilar` | partner | Projects close in meaning to your profile, best first, at most 50, not paged | a profile with text |
|
||||
|
||||
Paging facts (from the endorser-ch handlers):
|
||||
|
||||
- Each server has one cursor covering all of its buckets. Endorser cursors
|
||||
look like `1234567`; partner cursors look like `45678-1234567` (partner
|
||||
position, then main position). Clients treat both as opaque text, compared
|
||||
only for equality.
|
||||
- `afterCursor` returns changes after it, oldest first; `beforeCursor` returns
|
||||
changes before it, newest first. Both together bound a window.
|
||||
- A page holds at most 50 rows per list, all cut at the same change point;
|
||||
`hasMore` is one flag for the whole page.
|
||||
- A row's `changeSeq` is not a cursor, so no cursor points at a single item.
|
||||
|
||||
## How the notification service counts
|
||||
|
||||
`notification-wakeup-service` runs one daily alertSearch per channel and
|
||||
identity:
|
||||
|
||||
- `src/alertSearch/retrieve.ts` calls both endpoints with the channel's stored
|
||||
`afterCursor`, `planHandleIds` and `location`, pages forward while `hasMore`
|
||||
(at most 20 pages), and merges the pages.
|
||||
- `src/alertSearch/digest.ts` totals all eight buckets.
|
||||
- Cursors are stored per channel (`fcm` and `sms` in separate tables) and
|
||||
advance after every run in which both sources complete, sent or not. Each
|
||||
daily count therefore covers only changes after the previous run, whether or
|
||||
not the person opened the app in between, so no item is counted in two
|
||||
notifications. Rejected: subtracting the previous day's count, which would
|
||||
subtract it twice.
|
||||
- An upload carrying a cursor for a server replaces that server's stored
|
||||
cursor, and the first run counts from it. An upload without one leaves the
|
||||
stored cursor alone.
|
||||
- A run with no stored cursor for a server only stores that server's cursor
|
||||
and counts nothing from it. The app avoids this by sending cursors with
|
||||
every upload that turns a channel on (see "Starting from the latest
|
||||
change").
|
||||
- The SMS body is `Gift Economies: you have N new updates.
|
||||
https://giftopia.me/deep-link/new-activity?c=<start cursors> Reply STOP to
|
||||
end.` The FCM payload carries title, body, `type: "alert_search"` and `c`.
|
||||
|
||||
## Read state
|
||||
|
||||
Stored as JSON in one settings column, `newActivityCursors`
|
||||
(`NewActivityCursorState` in `src/libs/newActivity.ts`):
|
||||
|
||||
- **Visit window**, one cursor per server. The summary counts every bucket from
|
||||
its start, the same window the notification service counts from once the
|
||||
visit is uploaded to it, except a bucket whose list was opened after that
|
||||
start was recorded: it counts from the end of that list (`summaryStart` in
|
||||
`src/libs/newActivity.ts`, `countSummaryActivity` in
|
||||
`src/services/newActivity.ts`).
|
||||
- **Bucket window**, one per bucket, on that bucket's server. A detail page
|
||||
lists everything since the end of its last load, so opening it marks the
|
||||
bucket read; reopening it shows nothing new until more arrives, and "Show
|
||||
earlier" lists what it showed before. A bucket never opened starts where the
|
||||
summary does, so its first detail count equals the summary's.
|
||||
- **Uploaded**, the cursors last sent to each notification channel.
|
||||
|
||||
A window records where a load counted from (`from`) and when that cursor was
|
||||
recorded (`fromSeenAt`), where it ended (`to`) and when (`seenAt`), and when
|
||||
the visit began (`at`). A summary load less than 30 minutes after `at` is the
|
||||
same visit and counts from the same `from`, so reloading the summary keeps the
|
||||
counts of unopened lists; a later load starts a new visit from the last
|
||||
`to`. "Mark unread from this item" does not exist, since no cursor points at an
|
||||
item.
|
||||
|
||||
The first load ever has no cursor. It only records where to start and shows
|
||||
nothing as new:
|
||||
|
||||
1. The app sends each server a request with no cursor and no ID. The server
|
||||
answers with its most recent rows and its latest cursors
|
||||
(`nextAfterCursor`).
|
||||
2. The app ignores the rows: every count is 0 and every list is empty
|
||||
(`countServer` and `loadBucket` in `src/services/newActivity.ts` count
|
||||
nothing without a start).
|
||||
3. It stores a visit ending at those cursors (`seedIfNeeded`, `seededState`),
|
||||
with a start time long past, so the next load counts from them.
|
||||
|
||||
Home and the detail pages seed before counting; the summary's own first load
|
||||
does the same, since the end of a load with no start becomes the start of
|
||||
the next visit. Turning on notifications seeds too, with the cursors it
|
||||
uploads. Seeding needs both servers to answer; otherwise nothing is stored,
|
||||
nothing shows as new, and the next load tries again. A detail page's "Show
|
||||
earlier" still lists what came before the seed.
|
||||
|
||||
Arriving through a notification link with no visit yet counts from the
|
||||
link's `c` instead.
|
||||
|
||||
Rejected: starting endorser after the oldest `lastAcked*JwtId` (`afterId`) so
|
||||
items unread on the earlier screen stay unread; the app would then depend on
|
||||
the ID path that endorser-ch plans to retire.
|
||||
|
||||
Opening a detail page also moves the `lastAcked*JwtId` read markers
|
||||
(`claims` → offers to you, `personalPlanContributions` → offers to your
|
||||
projects, `trackedPlanUpdates` → starred changes), which the Recent Offers
|
||||
pages use to mark the last-read offer.
|
||||
|
||||
## Summary screen
|
||||
|
||||
- One alertSearch call per server from the visit start, paging forward while
|
||||
`hasMore` up to 4 pages; past that a count shows "+".
|
||||
- Same inputs as the notification service: `planHandleIds` from
|
||||
`settings.starredPlanHandleIds`, `location` from the first
|
||||
`settings.searchBoxes` entry converted from `BoundingBox` to
|
||||
`minLocLat/maxLocLat/minLocLon/maxLocLon`.
|
||||
- The total at the top is the sum of all eight buckets, as the service totals
|
||||
them, so with no activity since the run it equals the notified number.
|
||||
- With no search box, the location rows link to `/search-area`; with no
|
||||
starred projects, the tracked rows show a hint.
|
||||
- A server that fails shows "?" on its buckets and the error; the other
|
||||
server's counts appear, and the visit does not move.
|
||||
|
||||
## Home screen
|
||||
|
||||
The Home screen shows one tile per bucket with something unseen, in the
|
||||
summary's order, from the same eight alertSearch buckets
|
||||
(`countUnseenActivity` in `src/services/newActivity.ts`).
|
||||
|
||||
- Each tile shows the count (with "+" past the page limit) and the bucket's
|
||||
label, and opens that bucket's detail page with `?expected=` set to the
|
||||
count. "View All New Activity For You" opens the summary.
|
||||
- A bucket counts from the end of whichever load the person saw last: the
|
||||
summary's visit or that bucket's own list (`unseenStart` in
|
||||
`src/libs/newActivity.ts`, ordered by each window's `seenAt`). It never
|
||||
uses a visit's start, so a summary just loaded shows no tiles, even inside
|
||||
the 30-minute visit in which the summary keeps showing its counts.
|
||||
- Buckets on one server with the same start share one request, so with no
|
||||
list opened since the summary, Home makes one request per server.
|
||||
- Home writes the cursor state only to seed it on the first load ever; it
|
||||
never starts a visit otherwise, and never uploads to the notification
|
||||
service.
|
||||
- Tile test IDs are `newActivityNumber-<bucket>`.
|
||||
|
||||
## Detail pages
|
||||
|
||||
One view, `/new-activity/:bucket` (`NewActivityBucketView.vue`), calls the
|
||||
same endpoint from the bucket start and shows only that bucket, so its count
|
||||
is exact. A per-bucket legacy endpoint cannot match it: `/report/offers`
|
||||
holds only offers, while `claims` holds every claim type that names you.
|
||||
|
||||
- The heading shows its own count. The summary and the Home tiles pass their
|
||||
count as `?expected=`; when they differ, a note says how many came before
|
||||
the tapped count started, or that the list has fewer than it showed.
|
||||
Rejected: handing Home's rows to the detail page, which saves one request
|
||||
while this query runs anyway after a deep link, a relaunch, a capped count
|
||||
or a different bucket start.
|
||||
- "Show more" continues forward; "Show earlier" pages back with
|
||||
`beforeCursor` from the bucket start, listing items already seen.
|
||||
- Rows (`NewActivityRow.vue`) link to `/claim/:id`, `/project/:handleId`,
|
||||
`/user-profile/:rowId`; similarity matches show their match percentage.
|
||||
- `trackedPlanUpdates` keeps the before/after diff (`PlanChangeDiff.vue`),
|
||||
fed by `plansLastUpdatedBetween` for the page's handle IDs with `afterId`
|
||||
just before the oldest edit, because alertSearch plan rows carry no earlier
|
||||
claim.
|
||||
- The `claims` and `personalPlanContributions` pages link to
|
||||
`RecentOffersToUserView` and `RecentOffersToUserProjectsView`.
|
||||
- endorser-ch returns `claim` on `claims` rows, so the page can say what was
|
||||
offered or given.
|
||||
|
||||
## Re-registering notifications on visit
|
||||
|
||||
When the summary (`/new-activity`) opens a new visit and the push or text
|
||||
channel is on, the app re-uploads that channel's alert authorization in the
|
||||
background, after the counts are on screen: a fresh
|
||||
JWT batch replacing the unused one, with the visit's end cursors, the starred
|
||||
handle IDs and the search area. The next daily count then starts from this
|
||||
visit and uses the person's current starred projects and area.
|
||||
|
||||
- It reuses `mintAlertAuthorizationBatch` and the POST
|
||||
`/notifications/alert-authorization` and POST
|
||||
`/notify-sms/alert-authorization` routes
|
||||
(`src/services/notifications/newActivityRefresh.ts`).
|
||||
- It is skipped inside the same visit, and when the channel already counts
|
||||
from this visit's start and nothing is new.
|
||||
- Detail pages and the Home screen never upload. A detail page's window is
|
||||
narrower than the service's single cursor per server, so uploading it would
|
||||
drop the other buckets' unseen items from the next notification.
|
||||
- Changing the text time re-uploads without cursors, so nothing waiting for
|
||||
the next text is dropped.
|
||||
|
||||
Notification service changes this needs:
|
||||
|
||||
1. `AlertAuthorizationRequestBody` accepts optional `endorserAfterCursor`,
|
||||
`partnerAfterCursor`, `planHandleIds` and `location`; the routes write all
|
||||
four into that channel's cursor table.
|
||||
2. The daily run sends the stored `planHandleIds` and `location` to
|
||||
alertSearch.
|
||||
3. `replaceUnusedBatch` skips days that already hold a consumed JWT.
|
||||
Without that, a re-upload after today's run installs an unused JWT for
|
||||
today, and the scheduler runs and pushes a second time the same day (SMS
|
||||
has a per-phone daily cap; FCM has none).
|
||||
4. `profilesSimilar` and `plansSimilar` join the total on both sides:
|
||||
`mergePartner` keeps them (deduplicated by profile `rowId` and plan
|
||||
`handleId`) and `ALERT_SEARCH_DIGEST_BUCKETS` lists them.
|
||||
5. Cursors advance only when both sources complete, so a half-failed run
|
||||
repeats the whole window on its retry.
|
||||
6. A run with no stored cursor for a source seeds that cursor and counts
|
||||
nothing from it.
|
||||
|
||||
## Starting from the latest change
|
||||
|
||||
Turning on push or text notifications (the toggles in `AccountViewView.vue`)
|
||||
sends the latest cursors with the first upload, so the first notification
|
||||
counts only what happens after the person turned it on. The app gets those
|
||||
cursors from one alertSearch call per server, made just before the upload,
|
||||
and stores them as its visit when it has none. Each server stands alone: one
|
||||
that does not answer gets the end of the last visit instead, and with neither
|
||||
its cursor is left out of the upload. Service change 6 covers only that last
|
||||
case.
|
||||
|
||||
## Notification links carry the window
|
||||
|
||||
Each run holds the cursors it counted from (`startCursors` on the daily
|
||||
result). The text and the push carry them, so the app can tell whether it is
|
||||
looking at the same window the person was told about.
|
||||
|
||||
- SMS URL: one parameter joining both servers' start cursors with `.`:
|
||||
`https://giftopia.me/deep-link/new-activity?c=1234567.45678-1234567`.
|
||||
That adds about 25 characters; the body stays inside one 160-character
|
||||
segment for counts under 10,000. A first run counts nothing and sends no
|
||||
text.
|
||||
- FCM: the same `c` in the data payload, and a tap routes to
|
||||
`/new-activity?c=…`.
|
||||
- App: `deepLinkQuerySchemas["new-activity"]` accepts optional `c`. When `c`
|
||||
differs from the visit start, the summary also counts from `c` and compares
|
||||
totals: fewer since the visit means "you may have already seen some of what
|
||||
it counted"; more means "this includes N from before that notification".
|
||||
- Optional, for an exact replay: also send the run's end cursors and offer a
|
||||
"Show what the notification counted" view using `afterCursor` = start and
|
||||
`beforeCursor` = end. That doubles the URL addition and can push the text
|
||||
past one segment.
|
||||
|
||||
## Steps
|
||||
|
||||
App (`crowd-funder-for-time-pwa`):
|
||||
|
||||
- [x] HTTP client and paging in `src/libs/alertSearch.ts`.
|
||||
- [x] Bucket registry and cursor state in `src/libs/newActivity.ts`; loads in
|
||||
`src/services/newActivity.ts`.
|
||||
- [x] Settings column `newActivityCursors`, migration
|
||||
`012_add_newActivityCursors_to_settings`.
|
||||
- [x] `NewActivityView.vue` as the summary, with the `c` comparison.
|
||||
- [x] `NewActivityBucketView.vue`, `NewActivityRow.vue`, `PlanChangeDiff.vue`,
|
||||
route `/new-activity/:bucket`.
|
||||
- [x] Re-upload on visit; latest cursors from the enable toggles.
|
||||
- [x] `c` in the deep-link query schema; FCM taps open New Activity with `c`.
|
||||
- [x] First load seeds cursors and shows nothing; no `afterId` sent.
|
||||
- [x] Home tiles for all eight buckets, each opening its detail page, counted
|
||||
from `unseenStart`; the legacy `/report/offers`,
|
||||
`offersToPlansOwnedByMe` and starred-changes counts removed from Home.
|
||||
- [x] Jest tests (`src/test/newActivity.test.ts`); Playwright specs 50 and 55
|
||||
updated.
|
||||
- [x] Playwright specs 50 and 55 pass against local servers in Chromium and
|
||||
Firefox.
|
||||
- [x] CHANGELOG entry (1.4.10).
|
||||
- [x] Test and start-up fixes found while running the specs (Home records
|
||||
where New Activity starts in parallel with the feed, Affirm Delivery waits
|
||||
for the offer's project, a WebAssembly explanation on the start-up error
|
||||
screen, test warm-up, retry and reporter).
|
||||
- [ ] Release the app after endorser-ch is deployed, since detail rows read
|
||||
`claim` from `alertSearch` — next: release (Trent)
|
||||
|
||||
Notification service (`notification-wakeup-service`):
|
||||
|
||||
- [x] Service changes 1–6, `c` in the SMS link and FCM data, tests, README,
|
||||
CHANGELOG.
|
||||
- [ ] Deploy to test and production — next: deploy, or tick this if done
|
||||
(Trent)
|
||||
|
||||
endorser-ch:
|
||||
|
||||
- [x] `claim` on `claims` rows of `/api/v2/report/alertSearch`, CHANGELOG.
|
||||
- [x] An Offer that names its plan in `itemOffered.isPartOf` (as the app
|
||||
sends) gets `toEntity`, so it reaches the owner's
|
||||
`personalPlanContributions` and `trackedPlanClaims`; migration V23 fills
|
||||
existing offers without moving `changeSeq`; test, CHANGELOG.
|
||||
- [ ] Commit the `isPartOf` change: `CHANGELOG.md`, `sql/README.md`,
|
||||
`sql/V23__offer_is_part_of_to_entity.sqlite3`, `claim.service.js`,
|
||||
`endorser.db.service.js`, `test/controller-endorser-6-plans-totals.js` —
|
||||
next: commit (Trent)
|
||||
- [ ] Deploy to test, then production, running Flyway on the main DB so V23
|
||||
applies — next: deploy (Trent)
|
||||
- [ ] Leave a person's own gives and offers out of their
|
||||
`personalPlanContributions` (the query includes them when a person
|
||||
contributes to their own project) — next: implement after the deploys above (agent)
|
||||
- endorser-ch: add `AND j.issuer != ?`, bound to the requesting DID, to
|
||||
both halves of the `UNION` in `jwtsForUserPlanContributions`
|
||||
(`endorser.db.service.js`), as `jwtsWithDidAfterId` does.
|
||||
- endorser-ch test in `controller-endorser-6-plans-totals.js`: user 1's own
|
||||
offer to user 1's plan is absent from user 1's
|
||||
`personalPlanContributions`, and user 2's offer is present.
|
||||
- App spec 50 "Record an offer": record the offer as User 1
|
||||
(`importUserFromAccount(page, "01")`) on a project User 0 owns, then
|
||||
switch to User 0 to check the `personalPlanContributions` tile and list.
|
||||
"Affirm delivery" then gets User 1 as giver and the project as recipient.
|
||||
- endorser-ch CHANGELOG entry; the notification service needs no change,
|
||||
since its counts come from the same query.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The native background fetcher (`TimeSafariNativeFetcher`) keeps its own
|
||||
read state.
|
||||
|
||||
## History
|
||||
|
||||
Closed findings from reading the notification service before these changes
|
||||
(2026-10-15):
|
||||
|
||||
- The schedulers called `runDailyAlertSearch(userId, now, {})`, and the upload
|
||||
body had no field for `location` or `planHandleIds`, so `trackedPlanUpdates`,
|
||||
`trackedPlanClaims`, `plansNearby` and `profilesNearby` were always empty: the
|
||||
reported total was `claims` + `personalPlanContributions`.
|
||||
- `profilesSimilar` and `plansSimilar` were dropped when partner pages merged.
|
||||
- Each source's cursor advanced on its own, so when one source completed and
|
||||
the other failed, the retry started past the completed source's rows and
|
||||
never counted them.
|
||||
- A first run counted the server's newest page, up to 50 rows per bucket of old
|
||||
activity.
|
||||
|
||||
Closed decisions:
|
||||
|
||||
- 2026-10-16: the first load seeds cursors and shows nothing, replacing the
|
||||
`afterId` handoff from the oldest `lastAcked*JwtId`, so the app sends
|
||||
`alertSearch` only cursors and endorser-ch can retire the ID path. A person
|
||||
upgrading with unread offers sees them under "Show earlier" and on the
|
||||
Recent Offers pages, not as new.
|
||||
- 2026-10-16: detail pages query `alertSearch` themselves and do not reuse
|
||||
Home's rows; an in-memory hand-off would be a second path that every
|
||||
entry other than a fresh, uncapped Home tile skips.
|
||||
@@ -0,0 +1,167 @@
|
||||
# Plan: Web Push on the Web Build
|
||||
|
||||
## Goal
|
||||
|
||||
A user of the web build (browser tab or installed web app) can turn on the
|
||||
Daily Reminder and New Activity notifications and receive them, matching what
|
||||
native builds deliver.
|
||||
|
||||
Requirements:
|
||||
|
||||
1. A `DIRECT_NOTIFICATION` push shows its message as sent.
|
||||
2. A `DAILY_CHECK` push shows the count of new activity for the active
|
||||
identity, using the same Endorser query the native fetcher uses.
|
||||
3. The service worker never holds a long-lived credential: it holds only
|
||||
day-scoped JWTs, the same pool native builds hand to their background
|
||||
fetcher.
|
||||
4. The Notifications section of the Account view works on the web build.
|
||||
|
||||
## Can FCM replace Web Push?
|
||||
|
||||
No. Firebase Cloud Messaging on the web (the `firebase/messaging` SDK) is a
|
||||
layer over the browser Push API: `getToken()` requires a registered service
|
||||
worker (`firebase-messaging-sw.js` by default), and that worker must display the
|
||||
notification, the same as a plain Web Push worker. FCM changes only the
|
||||
sending side — the push server would call the FCM HTTP v1 API instead of
|
||||
sending VAPID-signed Web Push requests — and it adds a Google dependency and
|
||||
Firebase config to the web build. It removes none of the work below.
|
||||
|
||||
## Current state
|
||||
|
||||
- `public/sw.js` is registered by `src/main.web.ts` on the web platform. It
|
||||
handles only the Web Share Target POST; it has no `push` or
|
||||
`notificationclick` handler.
|
||||
- The Notifications section of the Account view renders only on native
|
||||
(`v-if="isNativePlatform"`, `src/views/AccountViewView.vue:81`), and the
|
||||
"Notification Push Server" setting is hidden with `v-if="false"`
|
||||
(`src/views/AccountViewView.vue:719`). Web users cannot subscribe.
|
||||
- `src/components/PushNotificationPermission.vue` contains the web
|
||||
subscription flow:
|
||||
- GET `{pushUrl}/web-push/vapid` for the VAPID key (`:242`);
|
||||
- `pushManager.subscribe({ userVisibleOnly: true, ... })` (`:538`);
|
||||
- POST of the subscription to the relative URL `/web-push/subscribe`
|
||||
(`:583`), so the web host must proxy that path to the push server;
|
||||
- `askPermission()` sends the worker the placeholder secret
|
||||
`"temporary-secret"` via `SEND_LOCAL_DATA`, which nothing reads.
|
||||
- The push server sends a JSON payload whose `title` is `DAILY_CHECK` (run the
|
||||
new-activity check) or `DIRECT_NOTIFICATION` (show `message` as-is).
|
||||
- Account data lives in SQLite through absurd-sql, which a service worker
|
||||
cannot open. The worker therefore cannot decrypt the identity and sign its
|
||||
own JWTs.
|
||||
- Native builds solve the same problem with `mintBackgroundJwtTokenPool()`
|
||||
(`src/libs/crypto/backgroundJwtPool.ts`): 100 JWTs, each valid for one UTC
|
||||
day, stored at index `epochDay % 100`, handed to the background fetcher by
|
||||
`configureNativeFetcherIfReady()`
|
||||
(`src/services/notifications/nativeFetcherConfig.ts`). Passkey (`did:peer`)
|
||||
identities cannot mint the pool and raise
|
||||
`BackgroundJwtUnsupportedIdentityError`.
|
||||
|
||||
## Work
|
||||
|
||||
### 1. Service worker push handling (`public/sw.js`)
|
||||
|
||||
- `push`: parse the JSON payload.
|
||||
- `DIRECT_NOTIFICATION`: `showNotification("Direct Message", { body: message })`.
|
||||
- `DAILY_CHECK` (and any other title): take the token for today's slot from the
|
||||
pool, GET `{apiServer}/api/v2/report/claims` with it, count claims newer
|
||||
than the stored `lastNotifiedClaimId`, store the newest ID, and show "There
|
||||
is 1 new activity" / "There are N new activities".
|
||||
- `notificationclick`: close the notification, focus an open app window or open
|
||||
`/`.
|
||||
- Worker state (token pool, `apiServer`, `activeDid`, `lastNotifiedClaimId`)
|
||||
lives in the worker's own IndexedDB database, never in the app's SQLite
|
||||
database.
|
||||
|
||||
### 2. Hand the token pool to the worker
|
||||
|
||||
- On app start, identity change, and pool expiry, mint the pool with
|
||||
`mintBackgroundJwtTokenPool()` and `postMessage` it to the worker together
|
||||
with `apiServer` and `activeDid`. Reuse the trigger points in
|
||||
`src/services/notifications/notificationAuthLifecycle.ts`
|
||||
(`onNotificationAuthMayBeReady`).
|
||||
- Delete the `SEND_LOCAL_DATA` / placeholder-secret path in
|
||||
`PushNotificationPermission.vue`.
|
||||
- Passkey identities: do not offer New Activity notifications (the Daily
|
||||
Reminder needs no token).
|
||||
|
||||
### 3. Empty daily checks
|
||||
|
||||
Chrome requires every push received with `userVisibleOnly: true` to show a
|
||||
notification. A `DAILY_CHECK` that finds nothing new must show one anyway, or
|
||||
Chrome eventually shows its own "This site has been updated in the background"
|
||||
notice. Choose one (see Open questions):
|
||||
|
||||
- show a quiet "No new activity" notification and close it after a short delay;
|
||||
- have the push server skip pushes when there is nothing new, which requires
|
||||
the server to query Endorser on the user's behalf.
|
||||
|
||||
### 4. Re-enable the UI on the web build
|
||||
|
||||
- Render the Notifications section on web when `"PushManager" in window` and a
|
||||
service worker is registered, instead of only on native.
|
||||
- Restore the "Notification Push Server" setting for web.
|
||||
- iOS Safari delivers Web Push only to web apps added to the Home Screen
|
||||
(iOS 16.4+). `public/manifest.webmanifest` is linked from `index.html` and
|
||||
makes the app installable; the UI must explain the Home Screen requirement on
|
||||
iOS.
|
||||
|
||||
### 5. Hosting
|
||||
|
||||
- Confirm each web host (test, production, Docker `docker/*.conf`) proxies
|
||||
`/web-push/subscribe` to the push server, or change the client to POST to
|
||||
`{pushUrl}/web-push/subscribe`.
|
||||
- Serve `sw.js` with `Cache-Control: no-cache` so worker updates reach users
|
||||
promptly.
|
||||
|
||||
### 6. Tests
|
||||
|
||||
- Playwright: register the worker, then deliver pushes with the Chrome DevTools
|
||||
Protocol `ServiceWorker.deliverPushMessage` and assert on the shown
|
||||
notification (`registration.getNotifications()`), for both payload types.
|
||||
- Unit-test the claim-counting function, extracted so it runs outside the
|
||||
worker.
|
||||
|
||||
## Estimate
|
||||
|
||||
Two to four days of work, plus push-server and nginx checks (low confidence:
|
||||
the push server's current behavior and routing are not visible from this
|
||||
repository).
|
||||
|
||||
## Open questions
|
||||
|
||||
- Empty daily checks: silent-notification workaround, or server-side filtering?
|
||||
- Does the push server serve `/web-push/vapid` and accept
|
||||
`/web-push/subscribe`, and does it send `DAILY_CHECK` daily?
|
||||
- Should New Activity on web use the same Endorser query as the native fetcher
|
||||
(starred-plan changes) rather than `/api/v2/report/claims`, so both platforms
|
||||
report the same thing?
|
||||
|
||||
## Rejected
|
||||
|
||||
- **FCM for web:** still needs a service worker; adds a Google dependency to the
|
||||
web build and a server-side switch to the FCM API. See "Can FCM replace Web
|
||||
Push?".
|
||||
- **Reviving the old worker scripts (`sw_scripts/safari-notifications.js`):**
|
||||
they read the Dexie `TimeSafari` IndexedDB database, which no longer holds
|
||||
accounts, and decrypted the identity inside the worker using a secret passed
|
||||
from the page, shipping NaCl and noble crypto libraries into the worker. The
|
||||
day-scoped token pool needs neither.
|
||||
|
||||
## History
|
||||
|
||||
- 2025-02-18 (`2746b8fe`): the Vite config reorganization switched the web build
|
||||
to a Workbox-generated worker, so the custom push, notification-click, and
|
||||
share-target handlers stopped shipping.
|
||||
- 2025-07-18 (`8bc1c521`): VitePWA and the generated worker were removed; the
|
||||
web manifest stopped being generated and linked.
|
||||
- The leftover `sw_scripts/` files and PWA config were deleted, and
|
||||
`public/sw.js` was added for the Web Share Target only.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Decide the empty-daily-check behavior
|
||||
- [ ] Confirm push-server endpoints and routing per environment
|
||||
- [ ] `push` and `notificationclick` handlers in `public/sw.js`
|
||||
- [ ] Token-pool handoff to the worker; remove the placeholder secret
|
||||
- [ ] Re-enable the web Notifications UI, with the iOS Home Screen note
|
||||
- [ ] Playwright push-delivery tests
|
||||
@@ -0,0 +1,129 @@
|
||||
# Plan: Startup Database Failure — Diagnostics and Degraded Mode
|
||||
|
||||
## Goal
|
||||
|
||||
When the local database cannot be opened, a user should (1) get a useful
|
||||
report to the developers with one tap, and (2) keep using whatever
|
||||
parts of the app do not need local storage, with a clear path to recovery.
|
||||
|
||||
Requirements:
|
||||
|
||||
1. A failure report reaches the developers without the user composing an email,
|
||||
and it carries enough to identify the cause on the first report.
|
||||
2. The report is sent only with the user's consent and holds no keys, seed
|
||||
phrases, or contact data.
|
||||
3. A user whose database is down can browse public content (feed, projects,
|
||||
claims) instead of being held on the recovery screen.
|
||||
4. Every action that needs the database or an identity is visibly disabled with
|
||||
the real reason, and points to the recovery screen.
|
||||
5. Recovery steps never delete the database without keeping a copy.
|
||||
|
||||
## Startup failure behavior
|
||||
|
||||
- `CapacitorPlatformService._initialize()`
|
||||
(`src/services/platforms/CapacitorPlatformService.ts`) opens the database in
|
||||
three steps: "create connection", "open database", "run migrations". A
|
||||
failure throws `Failed to initialize database (step: <step>): <cause>`.
|
||||
- `src/utils/startupError.ts` recognizes that error by its message and hands it
|
||||
to `StartView` through `sessionStorage`.
|
||||
- Three places catch it and redirect to `/start`: the router `errorHandler`
|
||||
and `beforeEach` guard (`src/router/index.ts:344`, `:363-454`), and the Vue
|
||||
global error handler (`src/main.common.ts`).
|
||||
- The guard calls `retrieveAccountDids()` for every route except the
|
||||
identity-setup routes, so with the database down every route except
|
||||
`/account`, `/test` and `/logs` lands on `/start`.
|
||||
- `StartView` shows the recovery screen: Try Again, Email/Copy Details,
|
||||
"Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
|
||||
(`resetDatabase()`, which copies the file aside before deleting it).
|
||||
- API reads work without an identity: `getHeaders()`
|
||||
(`src/libs/endorserServer.ts:430`) omits the Authorization header when there
|
||||
is no DID.
|
||||
- Every path that creates, saves, or signs with an identity reads the `secret`
|
||||
and `accounts` tables (`src/libs/util.ts`: `saveNewIdentity`,
|
||||
`retrieveFullyDecryptedAccount`), so no action that needs a DID can work while
|
||||
the database is down.
|
||||
|
||||
## Part A: One-tap diagnostics report
|
||||
|
||||
Contents of the report:
|
||||
|
||||
- the startup error message (step and cause) and stack
|
||||
- app version, platform, OS version, device model
|
||||
- Android: WebView version and installer package (`getInstallerPackageName`)
|
||||
- whether a database file existed before the first connection attempt of this
|
||||
process, which shows whether Android restored a backup at install time
|
||||
- whether the SQLite plugin loaded (a plugin load failure surfaces as a
|
||||
`CapacitorSQLitePlugin:` prefix on the cause)
|
||||
- the `migrations` table contents, when the database opens far enough to read it
|
||||
|
||||
Delivery: a POST to an endpoint on a server the project runs. The recovery
|
||||
screen shows the exact payload and a Send button; the email option stays for
|
||||
users who decline.
|
||||
|
||||
The database-existed flag needs a native-side or `Filesystem.stat` check before
|
||||
`createConnection`; `databaseDirectoryUri()` already resolves the directory on
|
||||
Android and iOS.
|
||||
|
||||
## Part B: Degraded mode
|
||||
|
||||
- Change the router guard so that a database startup error records the error
|
||||
and lets read-only routes through: home feed, projects, project and claim
|
||||
views, and discovery. Identity-setup routes and anything that writes stay
|
||||
redirected to `/start`.
|
||||
- Show a persistent banner on every page while in degraded mode: "Your
|
||||
device's storage isn't working. You can look around, but you can't act yet.
|
||||
Fix it →", linking to the recovery screen.
|
||||
- Disable buttons that need a DID or the database (give, offer, create project,
|
||||
confirm, invite, contacts) with that same reason, instead of the
|
||||
"someone must register you" text an unregistered user sees.
|
||||
- Default `apiServer` in every settings fallback. `$accountSettings()`
|
||||
(`src/utils/PlatformServiceMixin.ts:1071-1136`) returns `defaultSettings` or
|
||||
the caller's `defaults` (often `{}`) on its early-return and error paths, so
|
||||
`apiServer` can be undefined; `HomeView` works around this on its own.
|
||||
- Order the recovery screen as a ladder: Try Again → Send Diagnostics → Save a
|
||||
Copy → Start Over.
|
||||
|
||||
## Part C (optional): An identity that does not need SQLite
|
||||
|
||||
Key generation is pure (`generateSeed` → `deriveAddress` → `newIdentifier`).
|
||||
In degraded mode the app could generate an identity, require the user to write
|
||||
down the seed phrase, and keep the mnemonic in `@capacitor/preferences`
|
||||
(installed, and independent of SQLite). The user would then have a DID someone
|
||||
can register. When the database opens again, the app imports the identity from
|
||||
Preferences and deletes the Preferences copy.
|
||||
|
||||
Trade-off: the mnemonic sits unencrypted in Preferences, about the same
|
||||
protection as the database's secret stored next to the data it encrypts.
|
||||
Worth building only if startup failures keep being reported with the plugin's
|
||||
encryption setting (`androidIsEncryption: false`) in effect.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Which server and endpoint receive diagnostics, and how long reports are kept.
|
||||
- Whether to quarantine a corrupt database automatically ("file is not a
|
||||
database", "malformed") and start fresh. Automatic quarantine trades a
|
||||
visible failure for a silent switch to a new identity, and some "malformed"
|
||||
databases are recoverable with `REINDEX` or `.recover`; the manual Start Over
|
||||
button covers this case.
|
||||
- Whether Android backup should include `databases/`. Including it restores a
|
||||
user's identity on a new phone; it also gives the Google backup a copy of the
|
||||
keys together with the secret that decrypts them. The backup rules
|
||||
(`android/app/src/main/res/xml/backup_rules.xml`,
|
||||
`data_extraction_rules.xml`) include it.
|
||||
|
||||
## Rejected
|
||||
|
||||
- An IndexedDB (absurd-sql) database as a fallback inside the Capacitor
|
||||
WebView: untested there, and two stores that both hold keys risk losing an
|
||||
identity when they disagree.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] A1. Decide the diagnostics endpoint and payload retention.
|
||||
- [ ] A2. Capture "database file existed before first connection" during startup.
|
||||
- [ ] A3. Build the report payload and the Send Diagnostics button with preview.
|
||||
- [ ] B1. Let read-only routes through the router guard on a database startup error.
|
||||
- [ ] B2. Degraded-mode banner and disabled-action reasons.
|
||||
- [ ] B3. Default `apiServer` in all `$accountSettings()` fallbacks.
|
||||
- [ ] B4. Reorder the recovery screen.
|
||||
- [ ] C1. (Optional) Preferences-backed identity with import on recovery.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Fix: Startup Database Error on Android
|
||||
|
||||
## Symptom
|
||||
|
||||
On some Android installs from the Play Store, the app showed a "Startup
|
||||
Error" on every launch:
|
||||
|
||||
```
|
||||
[CapacitorPlatformService] Failed to initialize database
|
||||
```
|
||||
|
||||
Reinstalling did not help. The failure never appeared on developer devices
|
||||
installed through adb or Android Studio. Full symptom report:
|
||||
`startup-db-error-symptoms.md`.
|
||||
|
||||
## Causes
|
||||
|
||||
### 1. The SQLite plugin ignored its configuration (main cause)
|
||||
|
||||
`capacitor.config.ts` listed the SQLite settings under the key `CapSQLite`.
|
||||
The plugin registers as `CapacitorSQLite` and reads only that key, so every
|
||||
setting under `CapSQLite` was ignored and the plugin defaults applied:
|
||||
|
||||
- **Android:** `isEncryption` defaults to true, so on every launch the plugin
|
||||
created an Android Keystore master key and opened an
|
||||
`EncryptedSharedPreferences` file (`sqlite_encrypted_shared_prefs`) to hold a
|
||||
database passphrase. If that step throws, the plugin fails to load and every
|
||||
`createConnection` call is rejected.
|
||||
- **iOS:** the database location defaults to `Documents`, not the configured
|
||||
`Library/CapacitorDatabase`.
|
||||
- **Electron:** the plugin reads `plugins.CapacitorSQLite` directly, so a
|
||||
bundled config with the wrong key would throw at plugin startup.
|
||||
|
||||
The Keystore step fails on Android in two ways:
|
||||
|
||||
- **Restore without the key.** Android Auto Backup and device-to-device
|
||||
transfer restore the encrypted preferences file, but Keystore keys never
|
||||
leave the device. The restored file can't be decrypted, so the plugin fails on
|
||||
every launch, and a reinstall from Play restores the same file again.
|
||||
- **Device Keystore bugs.** Some manufacturers' Keystore implementations fail
|
||||
without any restore.
|
||||
|
||||
The app never used plugin encryption: it opens the database with
|
||||
`encrypted = false` and mode `"no-encryption"`, and never stores a passphrase.
|
||||
The Keystore setup protected nothing.
|
||||
|
||||
### 2. A migration could be applied but not recorded
|
||||
|
||||
The migration runner ran each migration's SQL in one transaction and recorded
|
||||
its name in the `migrations` table in a second one. If the app died between the
|
||||
two, the migration ran again on the next launch. Migration 001's
|
||||
`INSERT INTO secret (id, ...) VALUES (1, ...)` then failed with
|
||||
`UNIQUE constraint failed: secret.id` on every launch. The window is
|
||||
milliseconds, but once hit the failure is permanent, and Auto Backup carries it
|
||||
to a reinstall.
|
||||
|
||||
## Changes
|
||||
|
||||
| Area | Change |
|
||||
|---|---|
|
||||
| `capacitor.config.ts`, `electron/capacitor.config.ts`, `android/app/src/main/assets/capacitor.config.json` | Key renamed to `CapacitorSQLite`, so `androidIsEncryption: false` takes effect and the Keystore step never runs. `iosDatabaseLocation` set to `Documents`, the location every iOS install uses. |
|
||||
| `capacitor.config.json`, `electron/capacitor.config.json` | `iosDatabaseLocation` set to `Documents` to match. These files already used the correct key. |
|
||||
| `src/services/platforms/CapacitorPlatformService.ts` | `databaseDirectoryUri()` points at `Documents` on iOS, so "Save a Copy of My Data" and "Start Over" find the real database file. |
|
||||
| `src/services/migrationService.ts` | Each migration's `INSERT INTO migrations` is appended to its own statement batch, which the native plugin runs as one transaction. |
|
||||
| `src/db-sql/migration.ts` | Migration 001 uses `INSERT OR IGNORE` for the `secret` and `settings` rows, so a re-run never replaces the existing secret. |
|
||||
| `android/app/src/main/AndroidManifest.xml`, `res/xml/backup_rules.xml`, `res/xml/data_extraction_rules.xml` | Cloud backup and device transfer exclude the plugin's encrypted preference files. Everything else is backed up, including the database. |
|
||||
| `src/test/migrations.test.ts` | Tests for a crash right after a migration commits, and for a re-run of 001 keeping the secret. |
|
||||
| `src/test/capacitorConfig.test.ts` | Fails if the plugin config key or the iOS location is wrong. |
|
||||
|
||||
## Security
|
||||
|
||||
Encryption of user keys is unchanged. The identity and seed phrase are encrypted
|
||||
by the app (`simpleEncrypt` with the random secret from migration 001) and stored
|
||||
in `accounts`. The plugin's encryption flag only controlled whole-database
|
||||
SQLCipher encryption, which the app never used; the database file was plain
|
||||
SQLite before and after this change. App sandboxing keeps the file private to
|
||||
the app on both platforms.
|
||||
|
||||
## Compatibility with existing installs
|
||||
|
||||
- **Android:** the database has no password whether plugin encryption is on or
|
||||
off, so it opens the same way. The leftover encrypted preferences file is
|
||||
never read again.
|
||||
- **iOS:** the database stays in `Documents`. Setting the location back to
|
||||
`Library/CapacitorDatabase` would not be safe: the plugin moves databases into
|
||||
a custom location only when that folder does not exist yet, and installs from
|
||||
between May and August 2025 have that folder with an outdated database in it.
|
||||
- **Migrations:** existing installs already have 001 recorded, so its edit
|
||||
never runs for them. The recording change affects only migrations that have
|
||||
not run.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Reproduced before the fix** on an Android emulator: run the app once, copy
|
||||
`shared_prefs` out with `run-as app.timesafari.app`, uninstall (which deletes
|
||||
the Keystore key), reinstall, put the copied `shared_prefs` back, and launch.
|
||||
The startup error appeared.
|
||||
- **Unit tests:** `pkgx npm run test:unit -- src/test/migrations.test.ts
|
||||
src/test/capacitorConfig.test.ts`. The two migration tests fail on the
|
||||
earlier code and pass on the fixed code.
|
||||
- **Android resources:** `./gradlew :app:processDebugResources` builds, and
|
||||
the merged manifest carries both backup-rule attributes.
|
||||
- **After the fix:** run the same emulator steps on a fixed build, and also
|
||||
restore `shared_prefs` captured from a pre-fix build. The app should start
|
||||
normally either way.
|
||||
|
||||
## Follow-up
|
||||
|
||||
The one-tap diagnostics report and a degraded mode for users without a working
|
||||
database are planned in `doc/CHANGES/PLAN-startup-db-failure-recovery.md`.
|
||||
|
||||
## History
|
||||
|
||||
- 2025-05-27: `capacitor.config.json` set `iosDatabaseLocation` to
|
||||
`Library/CapacitorDatabase` under the correct `CapacitorSQLite` key
|
||||
(commit 7b4f9d65).
|
||||
- 2025-08-14: the config moved to `capacitor.config.ts` under the key
|
||||
`CapSQLite`, which the plugin does not read (commit a2840675). Plugin
|
||||
encryption setup ran on Android from then on, and iOS databases moved to the
|
||||
default `Documents` location.
|
||||
- 2026-10-10: commit d6ccf0e4 added the failed step and underlying cause to the
|
||||
startup error, made migration 008 tolerate duplicate contact DIDs and labels
|
||||
pointing at deleted contacts, and added the recovery screen.
|
||||
- 2026-10-10: this fix (config key, atomic migration records, backup rules).
|
||||
@@ -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
|
||||
|
||||
@@ -158,9 +198,7 @@ All deep links follow the format: `timesafari://<route>/<param>?<query>`
|
||||
|
||||
### Gift Routes
|
||||
|
||||
- `timesafari://confirm-gift/:id`
|
||||
- Query params:
|
||||
- `action`: "confirm" | "details"
|
||||
- `timesafari://confirm-gift/:id` opens the claim page, the same as `timesafari://claim/:id`
|
||||
|
||||
### Offer Routes
|
||||
|
||||
|
||||
@@ -104,9 +104,7 @@ Build Scripts:
|
||||
├── scripts/** # All build and automation scripts
|
||||
├── electron/** # Electron build files
|
||||
├── android/** # Android build configuration
|
||||
├── ios/** # iOS build configuration
|
||||
├── sw_scripts/** # Service worker scripts
|
||||
└── sw_combine.js # Service worker combination
|
||||
└── ios/** # iOS build configuration
|
||||
|
||||
Deployment:
|
||||
├── Dockerfile # Docker configuration
|
||||
|
||||
@@ -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,81 @@
|
||||
# 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 is **on by default** for local, test, and production builds. GMS-less devices still run the app; FCM degrades when Play Services is absent. F-Droid and other builds that must omit Firebase opt out explicitly.
|
||||
|
||||
## How the switch works
|
||||
|
||||
`android/app/build.gradle` applies `com.google.gms.google-services` unless `firebaseEnabled` is `false`. `android/gradle.properties` sets `firebaseEnabled=true`, so Android Studio, `./gradlew`, and `scripts/build-android.sh` all pick it up. `-PfirebaseEnabled` and `-PfirebaseEnabled=true` stay on. The config file does not decide the switch.
|
||||
|
||||
When Firebase is on, `android/app/google-services.json` must exist, be non-empty, and contain `mobilesdk_app_id`. Otherwise configuration fails with an error that names that path. When Firebase is off, the file is ignored even if it is on disk.
|
||||
|
||||
`scripts/build-android.sh` forwards an opt-out: `FIREBASE_ENABLED=false` adds `-PfirebaseEnabled=false` to its Gradle commands. Default npm scripts leave Firebase on. A `:nostore` script sets `FIREBASE_ENABLED=false` and reuses that script (`npm run build:android:test:nostore:run`).
|
||||
|
||||
iOS uses the same default. `ios/App/scripts/bundle-firebase-config.sh` copies `ios/App/App/GoogleService-Info.plist` into the app and the share extension. `scripts/build-ios.sh` runs it with `--check` before a device build, and both Xcode targets run it as a build phase. The build and archive `xcodebuild` commands both receive `FIREBASE_ENABLED`. `FIREBASE_ENABLED=false` becomes `FIREBASE_ENABLED=NO` and skips the plist. A `:nostore` script sets that variable (`npm run build:ios:prod:nostore`). A missing or empty plist fails the build when Firebase is on.
|
||||
|
||||
`:nostore` disables Firebase only. It does not strip other store-linked dependencies (the ML Kit barcode scanner still pulls in Play Services) and does not guarantee the build will run on every device outside the Play Store.
|
||||
|
||||
## Build commands
|
||||
|
||||
| Target | Command | Firebase |
|
||||
|---|---|---|
|
||||
| Local / test / production APK or AAB | `./gradlew assembleRelease` or `bundleRelease` | on |
|
||||
| Play Store with FCM | `./gradlew bundleRelease` | on |
|
||||
| iOS (Xcode or `scripts/build-ios.sh`) | default | on |
|
||||
| F-Droid / FOSS Android | `./gradlew assembleRelease -PfirebaseEnabled=false` or `npm run build:android:test:nostore` | off |
|
||||
| iOS without Firebase | `FIREBASE_ENABLED=false` or `npm run build:ios:prod:nostore` | off |
|
||||
|
||||
## Behavior on non-GMS devices
|
||||
|
||||
When Firebase is enabled (the default), 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`, Firebase on by default) 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 default Play Store AAB (Firebase on) is the one to upload.
|
||||
|
||||
**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 -PfirebaseEnabled=false` build and a dedicated F-Droid listing.
|
||||
|
||||
## Push registration in builds without Firebase
|
||||
|
||||
A build with `-PfirebaseEnabled=false` contains the `@capacitor/push-notifications` plugin, but no Firebase config. In that build, `PushNotifications.register()` throws `Default FirebaseApp is not initialized` on the Capacitor plugin thread, which kills the app; a JS `try/catch` cannot intercept it.
|
||||
|
||||
The JS therefore asks native code first. `NotificationInspector.isFirebaseConfigured()` (`android/app/src/main/java/app/timesafari/notifications/NotificationInspectorPlugin.java`) reports whether the `google_app_id` string resource exists; the google-services Gradle plugin generates that resource only when Firebase is enabled, and it is the same signal Firebase's auto-initialization uses. `firebaseMessagingClient.ts` skips `register()` on Android when it is absent (or when the check fails), logging `No Firebase config in this Android build; skipping push registration`. Listener setup and the notification permission request run either way, since local daily notifications need that permission.
|
||||
|
||||
`NotificationInspector` must be listed in `scripts/restore-local-plugins.js`: `MainActivity` registers plugins after the Capacitor bridge is built, so a plugin missing from `capacitor.plugins.json` reports "not implemented on android" and the check falls back to skipping push.
|
||||
|
||||
Any code that calls `PushNotifications.register()` or other `FirebaseMessaging` APIs on Android must go through the same check.
|
||||
|
||||
## The `google-services.json` file
|
||||
|
||||
- Gitignored (`android/.gitignore` line 80) — never commit it
|
||||
- Contains Firebase project credentials (project number, app ID, API key)
|
||||
- Required for the default (Firebase on) build. Place it at `android/app/google-services.json`
|
||||
- Ignored when you pass `-PfirebaseEnabled=false`; leaving it on disk does not re-enable Firebase
|
||||
- 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`.
|
||||
|
||||
## Incident: September 2026
|
||||
|
||||
After the June fix, local builds without `-PfirebaseEnabled` (including every `scripts/build-android.sh` build and the FOSS APKs from v1.4.4 on) crashed at launch as soon as notification permission was granted, because startup code called `PushNotifications.register()` unconditionally. Found while running `npm run build:android:prod:run` on an emulator.
|
||||
|
||||
**Fix applied:** the `isFirebaseConfigured()` gate described in "Push registration in builds without Firebase", plus adding `NotificationInspector` to `scripts/restore-local-plugins.js` so the check is reachable on Android.
|
||||
@@ -128,19 +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
|
||||
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
|
||||
```
|
||||
|
||||
**Important**: Replace `192.168.1.100` with your actual IP address.
|
||||
|
||||
### 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,7 +12,7 @@
|
||||
|
||||
## 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 ┌──────────────────────┐
|
||||
@@ -19,7 +21,7 @@ End-to-end flow when testing New Activity / silent wake on a physical Android ph
|
||||
│ wakeup-service │ └──────────┬───────────┘
|
||||
└──────────┬──────────┘ │
|
||||
│ │ fetch
|
||||
│ POST /notifications/refresh │ POST /notifications/register
|
||||
│ │ POST /notifications/register
|
||||
│ ▼
|
||||
│ ┌──────────────────────┐
|
||||
│ │ crowd-funder-for- │
|
||||
@@ -28,7 +30,7 @@ End-to-end flow when testing New Activity / silent wake on a physical Android ph
|
||||
│ └──────────┬───────────┘
|
||||
│ │
|
||||
│ 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 │
|
||||
@@ -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`).
|
||||
|
||||
@@ -240,7 +240,7 @@ Complete this section once before your first physical-device push test. If Fireb
|
||||
|
||||
2. The repo gitignores this file — each developer keeps a local copy; never commit it.
|
||||
|
||||
3. Rebuild so Gradle applies the Google Services plugin (`android/app/build.gradle` applies `com.google.gms.google-services` when the file exists). If the file is missing, the build logs: *google-services.json not found, google-services plugin not applied. Push Notifications won't work*.
|
||||
3. Rebuild. Firebase is on by default, so Gradle applies the Google Services plugin. If `android/app/google-services.json` is missing or empty, the build stops and names that path. Opt out only with `-PfirebaseEnabled=false` or `FIREBASE_ENABLED=false`.
|
||||
|
||||
4. Sync Capacitor if you changed native config:
|
||||
|
||||
@@ -290,12 +290,11 @@ For a full panel reference (configuration, URL resolution order, authentication,
|
||||
| Control | Purpose |
|
||||
|---------|---------|
|
||||
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) |
|
||||
| **Test Mode** | Sends `testMode: true/false` in register/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 `DEFAULT_NOTIFY_API_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.
|
||||
|
||||
---
|
||||
|
||||
@@ -899,35 +848,28 @@ Compare with panel **Backend Status** and Event Log error text.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -988,11 +928,11 @@ Compare with panel **Backend Status** and Event Log error text.
|
||||
|
||||
### Gradle: Push Notifications won't work
|
||||
|
||||
**Symptoms:** Build log: `google-services.json not found, google-services plugin not applied`.
|
||||
**Symptoms:** Build fails: `android/app/google-services.json is missing, empty, or has no mobilesdk_app_id`.
|
||||
|
||||
**Likely causes:** Missing `android/app/google-services.json`.
|
||||
|
||||
**Verification:** File exists on disk; rebuild shows Google Services plugin applied.
|
||||
**Verification:** File exists on disk and the build log says `Firebase enabled: google-services plugin applied`.
|
||||
|
||||
**Fixes:** Download from Firebase Console (package `app.timesafari.app`); place in `android/app/`; `npx cap sync android` and rebuild.
|
||||
|
||||
@@ -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,6 +281,7 @@ Before ngrok end-to-end testing, confirm:
|
||||
|
||||
## 6. Configure the Notification Debug Panel backend override
|
||||
|
||||
|
||||
The app normally calls `DEFAULT_NOTIFY_API_SERVER` (from `VITE_DEFAULT_NOTIFY_API_SERVER`, falling back to `AppString.PROD_NOTIFY_API_SERVER`). That is independent of `APP_SERVER`. For local wakeup testing, override the notification API base URL in the Debug Panel without rebuilding.
|
||||
|
||||
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
|
||||
@@ -296,12 +297,11 @@ For a full panel reference (configuration, URL resolution order, authentication,
|
||||
| Control | Purpose |
|
||||
|---------|---------|
|
||||
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) |
|
||||
| **Test Mode** | Sends `testMode: true/false` in register/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,15 +476,15 @@ Confirm parameters (token vs deviceId, auth headers) in that repo’s README or
|
||||
- App **backgrounded**, not force-quit
|
||||
- Physical device, correct provisioning profile
|
||||
- APNs key uploaded to Firebase; bundle ID matches
|
||||
- FCM message includes `data.type = "WAKEUP_PING"` (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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -212,7 +212,6 @@ export default class ComponentName extends Vue {
|
||||
- [ ] ContactGiftingView.vue
|
||||
- [x] DiscoverView.vue ✅ **MIGRATED & HUMAN TESTED**
|
||||
- [ ] ImportAccountView.vue
|
||||
- [ ] ConfirmGiftView.vue
|
||||
- [ ] SeedBackupView.vue
|
||||
- [ ] ContactAmountsView.vue
|
||||
- [x] ContactQRScanFullView.vue ✅ **MIGRATED & HUMAN TESTED**
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Notification Debug Panel
|
||||
|
||||
**Created:** 2026-07-07
|
||||
**Updated:** 2026-07-22
|
||||
**Audience:** Developers testing notification registration, refresh, and WAKEUP_PING flows on native (iOS/Android) dev builds.
|
||||
**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/refresh`, `/debug/send-wakeup`, etc.) do **not** use `APP_SERVER`. They use a dedicated Notification API host, resolved at runtime by `getNotificationApiBaseUrl()` in `NotificationDebugConfig.ts`.
|
||||
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
|
||||
|
||||
@@ -65,7 +65,7 @@ Settings persist in `localStorage` via `NotificationDebugConfig.ts`:
|
||||
| `notificationDebug.testMode` | `true` | Sent in JSON request bodies (`testMode: true/false`) |
|
||||
| `notificationDebug.bypassAuth` | `false` | When `true`, omit JWT `Authorization` headers on notification API calls |
|
||||
|
||||
All notification API requests (`/notifications/register`, `/notifications/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
|
||||
|
||||
@@ -75,7 +75,7 @@ Leave empty to use the configured build default (`DEFAULT_NOTIFY_API_SERVER`, fr
|
||||
|
||||
### Test Mode
|
||||
|
||||
When enabled (default if never saved), register and 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.
|
||||
|
||||
@@ -124,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).
|
||||
|
||||
@@ -136,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. |
|
||||
|
||||
---
|
||||
|
||||
@@ -198,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:
|
||||
|
||||
@@ -222,7 +217,7 @@ See platform-specific guides for extended ngrok and FCM workflows:
|
||||
| `src/services/notifications/notificationApiDebugMode.ts` | Auth bypass gate |
|
||||
| `src/services/notifications/NotificationDebugService.ts` | Panel action handlers |
|
||||
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
|
||||
| `src/services/notifications/NativeNotificationService.ts` | `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.*
|
||||
@@ -289,7 +289,6 @@ const { sql, params } = this.$generateInsertStatement(contact, 'contacts');
|
||||
- [ ] ContactGiftingView.vue
|
||||
- [ ] DiscoverView.vue
|
||||
- [ ] ImportAccountView.vue
|
||||
- [ ] ConfirmGiftView.vue
|
||||
- [ ] SeedBackupView.vue
|
||||
- [ ] [5 more view files]
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -175,7 +175,6 @@ export async function createBuildConfig(mode: string) {
|
||||
return defineConfig({
|
||||
define: {
|
||||
'process.env.VITE_PLATFORM': JSON.stringify(mode),
|
||||
// PWA is automatically enabled for web platforms via build configuration
|
||||
__IS_MOBILE__: JSON.stringify(isCapacitor),
|
||||
__USE_QR_READER__: JSON.stringify(!isCapacitor)
|
||||
},
|
||||
|
||||
@@ -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”
|
||||
```
|
||||
@@ -5,10 +5,9 @@
|
||||
# IMPORTANT: Build web assets first using npm scripts before running docker-compose
|
||||
#
|
||||
# Usage:
|
||||
# Development: npm run build:web:build -- --mode development && docker-compose up dev
|
||||
# Test: npm run build:web:build -- --mode test && docker-compose up test
|
||||
# Production: npm run build:web:build -- --mode production && docker-compose up production
|
||||
# Custom: BUILD_MODE=test npm run build:web:build -- --mode test && docker-compose up custom
|
||||
# Test: npm run docker:up:test (runs npm run build:web:test && docker-compose up test)
|
||||
# Production: npm run docker:up:prod (runs npm run build:web:prod && docker-compose up production)
|
||||
# Custom: npm run build:web:test && BUILD_MODE=test docker-compose up custom
|
||||
#
|
||||
# Environment Variables:
|
||||
# BUILD_MODE: development, test, or production (default: production)
|
||||
@@ -16,7 +15,7 @@
|
||||
# PORT: port to expose (default: 80 for production, 8080 for test)
|
||||
# ENV_FILE: environment file to use (default: .env.production)
|
||||
#
|
||||
# Note: For development, use npm run build:web directly (no Docker needed)
|
||||
# Note: For development, use npm run dev directly (no Docker needed)
|
||||
|
||||
version: '3.8'
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ The Dockerfile supports these build arguments:
|
||||
| `BUILD_MODE` | `production` | Build mode: development, staging, or production |
|
||||
| `NODE_ENV` | `production` | Node.js environment |
|
||||
| `VITE_PLATFORM` | `web` | Vite platform type |
|
||||
| PWA | `enabled` | Automatically enabled for web platforms |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
@@ -114,7 +113,6 @@ Docker Compose supports these environment variables:
|
||||
| `BUILD_MODE` | `production` | Build mode |
|
||||
| `NODE_ENV` | `production` | Node environment |
|
||||
| `VITE_PLATFORM` | `web` | Vite platform |
|
||||
| PWA | `enabled` | Automatically enabled for web platforms |
|
||||
| `DEV_PORT` | `5173` | Development port |
|
||||
| `STAGING_PORT` | `8080` | Staging port |
|
||||
| `PROD_PORT` | `80` | Production port |
|
||||
@@ -219,13 +217,12 @@ BUILD_MODE=staging NODE_ENV=staging ./docker/run.sh custom
|
||||
docker build \
|
||||
--build-arg BUILD_MODE=staging \
|
||||
--build-arg NODE_ENV=staging \
|
||||
|
||||
-t timesafari:staging-no-pwa .
|
||||
-t timesafari:staging .
|
||||
|
||||
# Run with custom configuration
|
||||
docker run -d -p 9000:80 \
|
||||
-e VITE_APP_SERVER=https://test.example.com \
|
||||
timesafari:staging-no-pwa
|
||||
timesafari:staging
|
||||
```
|
||||
|
||||
### Docker Compose with Custom Variables
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
# BUILD_MODE: development, staging, or production
|
||||
# NODE_ENV: node environment
|
||||
# VITE_PLATFORM: vite platform
|
||||
# PWA: automatically enabled for web platforms
|
||||
# PORT: port to expose
|
||||
# ENV_FILE: environment file to use
|
||||
|
||||
@@ -71,7 +70,6 @@ show_usage() {
|
||||
echo " BUILD_MODE: development, staging, or production"
|
||||
echo " NODE_ENV: node environment"
|
||||
echo " VITE_PLATFORM: vite platform"
|
||||
echo " PWA: automatically enabled for web platforms"
|
||||
echo " PORT: port to expose"
|
||||
echo " ENV_FILE: environment file to use"
|
||||
}
|
||||
@@ -86,7 +84,6 @@ show_build_args() {
|
||||
echo " BUILD_MODE: development"
|
||||
echo " NODE_ENV: development"
|
||||
echo " VITE_PLATFORM: web"
|
||||
echo " PWA: enabled (web platform)"
|
||||
echo " Target: development"
|
||||
echo " Port: 5173"
|
||||
;;
|
||||
@@ -94,7 +91,6 @@ show_build_args() {
|
||||
echo " BUILD_MODE: staging"
|
||||
echo " NODE_ENV: staging"
|
||||
echo " VITE_PLATFORM: web"
|
||||
echo " PWA: enabled (web platform)"
|
||||
echo " Target: staging"
|
||||
echo " Port: 80 (mapped to 8080)"
|
||||
;;
|
||||
@@ -102,7 +98,6 @@ show_build_args() {
|
||||
echo " BUILD_MODE: production"
|
||||
echo " NODE_ENV: production"
|
||||
echo " VITE_PLATFORM: web"
|
||||
echo " PWA: enabled (web platform)"
|
||||
echo " Target: production"
|
||||
echo " Port: 80"
|
||||
;;
|
||||
@@ -110,7 +105,6 @@ show_build_args() {
|
||||
echo " BUILD_MODE: \${BUILD_MODE:-production}"
|
||||
echo " NODE_ENV: \${NODE_ENV:-production}"
|
||||
echo " VITE_PLATFORM: \${VITE_PLATFORM:-web}"
|
||||
echo " PWA: enabled for web platforms"
|
||||
echo " Target: \${BUILD_TARGET:-production}"
|
||||
echo " Port: \${CUSTOM_PORT:-8080}:\${CUSTOM_INTERNAL_PORT:-80}"
|
||||
;;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"appId": "app.timesafari",
|
||||
"appName": "TimeSafari",
|
||||
"appName": "Giftopia",
|
||||
"webDir": "dist",
|
||||
"server": {
|
||||
"cleartext": true
|
||||
@@ -30,16 +30,16 @@
|
||||
"splashImmersive": true
|
||||
},
|
||||
"CapacitorSQLite": {
|
||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
||||
"iosDatabaseLocation": "Documents",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -31,8 +31,9 @@ const config: CapacitorConfig = {
|
||||
splashFullScreen: true,
|
||||
splashImmersive: true
|
||||
},
|
||||
CapSQLite: {
|
||||
iosDatabaseLocation: 'Library/CapacitorDatabase',
|
||||
// The key must match the plugin's registered name or every setting here is ignored.
|
||||
CapacitorSQLite: {
|
||||
iosDatabaseLocation: 'Documents',
|
||||
iosIsEncryption: false,
|
||||
iosBiometric: {
|
||||
biometricAuth: false,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no, interactive-widget=overlays-content" />
|
||||
|
||||
<!-- CORS headers removed to allow images from any domain -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */; };
|
||||
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */; };
|
||||
C8E73DD12FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */; };
|
||||
C8E73DD22FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */; };
|
||||
E9F1A0022EE05A8B00737D01 /* NotificationInspectorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F1A0012EE05A8B00737D01 /* NotificationInspectorPlugin.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -72,7 +70,7 @@
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
C86585E32ED456DE00824752 /* Exceptions for "TimeSafariShareExtension" folder in "TimeSafariShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
@@ -82,7 +80,18 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
|
||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
C86585E32ED456DE00824752 /* Exceptions for "TimeSafariShareExtension" folder in "TimeSafariShareExtension" target */,
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = TimeSafariShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -174,10 +183,11 @@
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
F1REBA5E000000000000A001 /* Bundle Firebase Config */,
|
||||
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
|
||||
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
|
||||
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
|
||||
3FE25897CF40A571D4AC2ACE /* [CP] Copy Pods Resources */,
|
||||
2B3F98670AF3508A35AC3248 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -196,6 +206,7 @@
|
||||
C86585D12ED456DE00824752 /* Sources */,
|
||||
C86585D22ED456DE00824752 /* Frameworks */,
|
||||
C86585D32ED456DE00824752 /* Resources */,
|
||||
F1REBA5E000000000000A002 /* Bundle Firebase Config */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -259,7 +270,6 @@
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
C8E73DD12FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -267,7 +277,6 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C8E73DD22FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -294,19 +303,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 */ = {
|
||||
@@ -331,6 +340,48 @@
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
F1REBA5E000000000000A001 /* Bundle Firebase Config */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
enableUserScriptSandboxing = 0;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Bundle Firebase Config";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PROJECT_DIR}/scripts/bundle-firebase-config.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
F1REBA5E000000000000A002 /* Bundle Firebase Config */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
enableUserScriptSandboxing = 0;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Bundle Firebase Config";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PROJECT_DIR}/scripts/bundle-firebase-config.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -455,7 +506,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 +563,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 +581,19 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
FIREBASE_ENABLED = YES;
|
||||
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.10;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -559,17 +612,19 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
FIREBASE_ENABLED = YES;
|
||||
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.10;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -587,21 +642,23 @@
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
FIREBASE_ENABLED = YES;
|
||||
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.10;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
@@ -625,21 +682,23 @@
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
FIREBASE_ENABLED = YES;
|
||||
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.10;
|
||||
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)
|
||||
@@ -136,20 +144,16 @@ final class TimeSafariNativeFetcher: NativeNotificationContentFetcher {
|
||||
return (s?.isEmpty == false) ? s : nil
|
||||
}
|
||||
|
||||
/// Store the newest JWT ID in the response (the highest ULID), so the next fetch
|
||||
/// asks only for later changes. The server lists them newest first, so the last
|
||||
/// item is the oldest.
|
||||
private func updateLastAckedJwtIdFromResponse(responseBody: String) {
|
||||
guard let data = responseBody.data(using: .utf8),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let dataArray = root["data"] as? [[String: Any]], !dataArray.isEmpty
|
||||
else { return }
|
||||
|
||||
let lastItem = dataArray[dataArray.count - 1]
|
||||
var jwtId: String?
|
||||
if let j = lastItem["jwtId"] as? String {
|
||||
jwtId = j
|
||||
} else if let plan = lastItem["plan"] as? [String: Any], let j = plan["jwtId"] as? String {
|
||||
jwtId = j
|
||||
}
|
||||
if let jwtId = jwtId, !jwtId.isEmpty {
|
||||
if let jwtId = dataArray.compactMap({ extractJwtIdFromItem($0) }).max() {
|
||||
UserDefaults.standard.set(jwtId, forKey: prefsLastAckedKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,96 +1,86 @@
|
||||
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)
|
||||
- SQLCipher/common (4.10.0)
|
||||
- SQLCipher/standard (4.10.0):
|
||||
- SQLCipher/common
|
||||
- TimesafariDailyNotificationPlugin (4.0.1):
|
||||
- TimesafariDailyNotificationPlugin (5.0.0):
|
||||
- Capacitor
|
||||
- ZIPFoundation (0.9.20)
|
||||
|
||||
@@ -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
|
||||
TimesafariDailyNotificationPlugin: b60e726f8776b5d22e026caef906f1c3b0e963dd
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/bin/sh
|
||||
# Bundle GoogleService-Info.plist when Firebase is enabled (the default).
|
||||
# Opt out with FIREBASE_ENABLED=false, or the Xcode build setting
|
||||
# FIREBASE_ENABLED=NO. The plist does not decide the switch: a missing file
|
||||
# fails an enabled build, and an opt-out build does not bundle the plist
|
||||
# even when the file is on disk.
|
||||
#
|
||||
# Xcode run-script phases invoke this with no arguments. build-ios.sh may pass
|
||||
# --check to validate before a long build without copying into a product.
|
||||
set -eu
|
||||
|
||||
check_only=0
|
||||
if [ "${1:-}" = "--check" ]; then
|
||||
check_only=1
|
||||
fi
|
||||
|
||||
if [ -z "${SRCROOT:-}" ]; then
|
||||
script_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
|
||||
SRCROOT=$(CDPATH= cd -- "$script_dir/.." && pwd)
|
||||
fi
|
||||
|
||||
normalized=$(printf '%s' "${FIREBASE_ENABLED:-true}" | tr '[:upper:]' '[:lower:]')
|
||||
plist="${SRCROOT}/App/GoogleService-Info.plist"
|
||||
|
||||
firebase_off=0
|
||||
case "$normalized" in
|
||||
false|0|no) firebase_off=1 ;;
|
||||
esac
|
||||
|
||||
remove_bundled_plist() {
|
||||
base="$1"
|
||||
leaf="$2"
|
||||
if [ -n "$base" ] && [ -n "$leaf" ]; then
|
||||
rm -f "${base}/${leaf}/GoogleService-Info.plist"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$firebase_off" -eq 1 ]; then
|
||||
if [ "$check_only" -eq 0 ]; then
|
||||
# Drop the plist from this product, including a copy left by an earlier
|
||||
# Firebase-enabled build. The app and the share extension each run this phase.
|
||||
remove_bundled_plist "${TARGET_BUILD_DIR:-}" "${UNLOCALIZED_RESOURCES_FOLDER_PATH:-}"
|
||||
remove_bundled_plist "${TARGET_BUILD_DIR:-}" "${WRAPPER_NAME:-}"
|
||||
remove_bundled_plist "${BUILT_PRODUCTS_DIR:-}" "${WRAPPER_NAME:-}"
|
||||
# Embed does not delete files that disappeared from the appex, so a plist
|
||||
# copied into PlugIns on an earlier Firebase-enabled build would otherwise stay.
|
||||
if [ -n "${TARGET_BUILD_DIR:-}" ] && [ -n "${WRAPPER_NAME:-}" ]; then
|
||||
for stale in "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/PlugIns/"*.appex/GoogleService-Info.plist; do
|
||||
if [ -f "$stale" ]; then
|
||||
rm -f "$stale"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
echo "note: Firebase disabled (FIREBASE_ENABLED=${FIREBASE_ENABLED}); GoogleService-Info.plist will not be bundled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -s "$plist" ] || ! grep -q "GOOGLE_APP_ID" "$plist"; then
|
||||
echo "error: Firebase is enabled, but ios/App/App/GoogleService-Info.plist is missing, empty, or has no GOOGLE_APP_ID." >&2
|
||||
echo "error: Download it from the Firebase console (Project settings → Your apps → Apple apps)" >&2
|
||||
echo "error: and place it at ios/App/App/GoogleService-Info.plist." >&2
|
||||
echo "error: To build without Firebase, set FIREBASE_ENABLED=false or pass the Xcode build setting FIREBASE_ENABLED=NO." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$check_only" -eq 1 ]; then
|
||||
echo "note: Firebase enabled; ios/App/App/GoogleService-Info.plist is present."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${TARGET_BUILD_DIR:-}" ] || [ -z "${UNLOCALIZED_RESOURCES_FOLDER_PATH:-}" ]; then
|
||||
echo "error: Bundle Firebase Config must run as an Xcode build phase (TARGET_BUILD_DIR is unset)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dest_dir="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"
|
||||
mkdir -p "$dest_dir"
|
||||
cp "$plist" "${dest_dir}/GoogleService-Info.plist"
|
||||
echo "note: Firebase enabled; bundled GoogleService-Info.plist."
|
||||
@@ -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,136 +1,149 @@
|
||||
{
|
||||
"name": "timesafari",
|
||||
"version": "1.4.1-beta",
|
||||
"description": "Gift Economies Application",
|
||||
"name": "giftopia",
|
||||
"version": "1.4.11-beta",
|
||||
"description": "Giftopia App",
|
||||
"author": {
|
||||
"name": "Gift Economies Team"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
||||
"type-safety-check": "./scripts/type-safety-check.sh",
|
||||
"type-check": "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",
|
||||
"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'",
|
||||
"test:web": "npx playwright test -c playwright.config-local.ts --trace on",
|
||||
"test:mobile": "./scripts/test-mobile.sh",
|
||||
"test:android": "node scripts/test-android.js",
|
||||
"test:ios": "node scripts/test-ios.js",
|
||||
"check:android-device": "adb devices | grep -w 'device' || (echo 'No Android device connected' && exit 1)",
|
||||
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
|
||||
"auto-run": "./scripts/auto-run.sh",
|
||||
"auto-run:test": "./scripts/auto-run.sh --test",
|
||||
"auto-run:prod": "./scripts/auto-run.sh --prod",
|
||||
"auto-run:ios": "./scripts/auto-run.sh --platform=ios",
|
||||
"auto-run:android": "./scripts/auto-run.sh --platform=android",
|
||||
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
|
||||
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
|
||||
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
|
||||
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && npx capacitor-assets generate",
|
||||
"assets:clean": "rimraf android/app/src/main/res/mipmap-* ios/App/App/Assets.xcassets/**/AppIcon*.png ios/App/App/Assets.xcassets/**/Splash*.png || true",
|
||||
"assets:config": "npx tsx scripts/assets-config.ts",
|
||||
"assets:validate": "npx tsx scripts/assets-validator.ts",
|
||||
"assets:validate:android": "./scripts/build-android.sh --assets-only",
|
||||
"assets:clean": "rimraf android/app/src/main/res/mipmap-* ios/App/App/Assets.xcassets/**/AppIcon*.png ios/App/App/Assets.xcassets/**/Splash*.png || true",
|
||||
"build:ios": "./scripts/build-ios.sh",
|
||||
"build:ios:dev": "./scripts/build-ios.sh --dev",
|
||||
"build:ios:test": "./scripts/build-ios.sh --test",
|
||||
"build:ios:test:run": "./scripts/build-ios.sh --test --auto-run",
|
||||
"build:ios:prod": "./scripts/build-ios.sh --prod",
|
||||
"build:ios:prod:run": "./scripts/build-ios.sh --prod --auto-run",
|
||||
"build:ios:debug": "./scripts/build-ios.sh --debug",
|
||||
"build:ios:debug:run": "./scripts/build-ios.sh --debug --auto-run",
|
||||
"build:ios:release": "./scripts/build-ios.sh --release",
|
||||
"build:ios:release:run": "./scripts/build-ios.sh --release --auto-run",
|
||||
"build:ios:studio": "./scripts/build-ios.sh --studio",
|
||||
"build:ios:ipa": "./scripts/build-ios.sh --ipa",
|
||||
"build:ios:clean": "./scripts/build-ios.sh --clean",
|
||||
"build:ios:sync": "./scripts/build-ios.sh --sync",
|
||||
"build:ios:assets": "./scripts/build-ios.sh --assets",
|
||||
"build:ios:deploy": "./scripts/build-ios.sh --deploy",
|
||||
"build:ios:dev:custom": "./scripts/build-ios.sh --dev --api-ip",
|
||||
"build:ios:test:custom": "./scripts/build-ios.sh --test --api-ip",
|
||||
"build:ios:dev:run:custom": "./scripts/build-ios.sh --dev --api-ip --auto-run",
|
||||
"build:ios:test:run:custom": "./scripts/build-ios.sh --test --api-ip --auto-run",
|
||||
"build:web": "./scripts/build-web.sh",
|
||||
"build:web:dev": "./scripts/build-web.sh --dev",
|
||||
"build:web:test": "./scripts/build-web.sh --test",
|
||||
"build:web:prod": "./scripts/build-web.sh --prod",
|
||||
"build:web:docker": "./scripts/build-web.sh --docker",
|
||||
"build:web:docker:test": "./scripts/build-web.sh --docker:test",
|
||||
"build:web:docker:prod": "./scripts/build-web.sh --docker:prod",
|
||||
"build:web:serve": "./scripts/build-web.sh --serve",
|
||||
"build:web:serve:test": "./scripts/build-web.sh --serve --test",
|
||||
"build:web:serve:prod": "./scripts/build-web.sh --serve --prod",
|
||||
"docker:up": "docker-compose up",
|
||||
"docker:up:test": "npm run build:web:build -- --mode test && docker-compose up test",
|
||||
"docker:up:prod": "npm run build:web:build -- --mode production && docker-compose up production",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:logs": "docker-compose logs -f",
|
||||
"build:electron": "./scripts/build-electron.sh",
|
||||
"build:electron:dev": "./scripts/build-electron.sh --dev",
|
||||
"build:electron:dev:run": "./scripts/build-electron.sh --dev --auto-run",
|
||||
"build:electron:test": "./scripts/build-electron.sh --test",
|
||||
"build:electron:test:run": "./scripts/build-electron.sh --test --auto-run",
|
||||
"build:electron:prod": "./scripts/build-electron.sh --prod",
|
||||
"build:electron:prod:run": "./scripts/build-electron.sh --prod --auto-run",
|
||||
"build:electron:windows": "./scripts/build-electron.sh --prod --windows",
|
||||
"build:electron:windows:dev": "./scripts/build-electron.sh --dev --windows",
|
||||
"build:electron:windows:test": "./scripts/build-electron.sh --test --windows",
|
||||
"build:electron:windows:prod": "./scripts/build-electron.sh --prod --windows",
|
||||
"build:electron:mac": "./scripts/build-electron.sh --prod --mac",
|
||||
"build:electron:mac:dev": "./scripts/build-electron.sh --dev --mac",
|
||||
"build:electron:mac:test": "./scripts/build-electron.sh --test --mac",
|
||||
"build:electron:mac:prod": "./scripts/build-electron.sh --prod --mac",
|
||||
"build:electron:linux": "./scripts/build-electron.sh --prod --linux",
|
||||
"build:electron:linux:dev": "./scripts/build-electron.sh --dev --linux",
|
||||
"build:electron:linux:test": "./scripts/build-electron.sh --test --linux",
|
||||
"build:electron:linux:prod": "./scripts/build-electron.sh --prod --linux",
|
||||
"build:electron:appimage": "./scripts/build-electron.sh --prod --appimage",
|
||||
"build:electron:appimage:dev": "./scripts/build-electron.sh --dev --appimage",
|
||||
"build:electron:appimage:test": "./scripts/build-electron.sh --test --appimage",
|
||||
"build:electron:appimage:prod": "./scripts/build-electron.sh --prod --appimage",
|
||||
"build:electron:deb": "./scripts/build-electron.sh --prod --deb",
|
||||
"build:electron:deb:dev": "./scripts/build-electron.sh --dev --deb",
|
||||
"build:electron:deb:test": "./scripts/build-electron.sh --test --deb",
|
||||
"build:electron:deb:prod": "./scripts/build-electron.sh --prod --deb",
|
||||
"build:electron:dmg": "./scripts/build-electron.sh --prod --dmg",
|
||||
"build:electron:dmg:dev": "./scripts/build-electron.sh --dev --dmg",
|
||||
"build:electron:dmg:test": "./scripts/build-electron.sh --test --dmg",
|
||||
"build:electron:dmg:prod": "./scripts/build-electron.sh --prod --dmg",
|
||||
"markdown:fix": "markdownlint-cli2 --fix",
|
||||
"markdown:check": "markdownlint-cli2",
|
||||
"markdown:setup": "./scripts/setup-markdown-hooks.sh",
|
||||
"prepare": "husky",
|
||||
"guard": "bash ./scripts/build-arch-guard.sh",
|
||||
"guard:test": "bash ./scripts/build-arch-guard.sh --staged",
|
||||
"guard:setup": "npm run prepare && echo '✅ Build Architecture Guard is now active!'",
|
||||
"clean:android": "./scripts/uninstall-android.sh",
|
||||
"clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true",
|
||||
"clean:electron": "./scripts/build-electron.sh --clean",
|
||||
"clean:all": "npm run clean:ios && npm run clean:android && npm run clean:electron",
|
||||
"auto-run": "./scripts/auto-run.sh",
|
||||
"auto-run:android": "./scripts/auto-run.sh --platform=android",
|
||||
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
|
||||
"auto-run:ios": "./scripts/auto-run.sh --platform=ios",
|
||||
"auto-run:prod": "./scripts/auto-run.sh --prod",
|
||||
"auto-run:test": "./scripts/auto-run.sh --test",
|
||||
"build:android": "./scripts/build-android.sh",
|
||||
"build:android:dev": "./scripts/build-android.sh --dev",
|
||||
"build:android:test": "./scripts/build-android.sh --test",
|
||||
"build:android:test:run": "./scripts/build-android.sh --test --auto-run",
|
||||
"build:android:prod": "./scripts/build-android.sh --prod",
|
||||
"build:android:prod:run": "./scripts/build-android.sh --prod --auto-run",
|
||||
"build:android:aab": "./scripts/build-android.sh --aab",
|
||||
"build:android:apk": "./scripts/build-android.sh --apk",
|
||||
"build:android:assets": "./scripts/build-android.sh --assets",
|
||||
"build:android:clean": "./scripts/build-android.sh --clean",
|
||||
"build:android:debug": "./scripts/build-android.sh --debug",
|
||||
"build:android:debug:run": "./scripts/build-android.sh --debug --auto-run",
|
||||
"build:android:deploy": "./scripts/build-android.sh --deploy",
|
||||
"build:android:dev": "./scripts/build-android.sh --dev",
|
||||
"build:android:dev:custom": "./scripts/build-android.sh --dev --api-ip",
|
||||
"build:android:dev:nostore": "FIREBASE_ENABLED=false npm run build:android:dev",
|
||||
"build:android:dev:run:custom": "./scripts/build-android.sh --dev --api-ip --auto-run",
|
||||
"build:android:prod": "./scripts/build-android.sh --prod",
|
||||
"build:android:prod:nostore": "FIREBASE_ENABLED=false npm run build:android:prod",
|
||||
"build:android:prod:nostore:run": "FIREBASE_ENABLED=false npm run build:android:prod:run",
|
||||
"build:android:prod:run": "./scripts/build-android.sh --prod --auto-run",
|
||||
"build:android:release": "./scripts/build-android.sh --release",
|
||||
"build:android:release:run": "./scripts/build-android.sh --release --auto-run",
|
||||
"build:android:studio": "./scripts/build-android.sh --studio",
|
||||
"build:android:apk": "./scripts/build-android.sh --apk",
|
||||
"build:android:aab": "./scripts/build-android.sh --aab",
|
||||
"build:android:clean": "./scripts/build-android.sh --clean",
|
||||
"build:android:sync": "./scripts/build-android.sh --sync",
|
||||
"build:android:assets": "./scripts/build-android.sh --assets",
|
||||
"build:android:deploy": "./scripts/build-android.sh --deploy",
|
||||
"build:android:dev:custom": "./scripts/build-android.sh --dev --api-ip",
|
||||
"build:android:test": "./scripts/build-android.sh --test",
|
||||
"build:android:test:custom": "./scripts/build-android.sh --test --api-ip",
|
||||
"build:android:dev:run:custom": "./scripts/build-android.sh --dev --api-ip --auto-run",
|
||||
"build:android:test:run:custom": "./scripts/build-android.sh --test --api-ip --auto-run"
|
||||
"build:android:test:nostore": "FIREBASE_ENABLED=false npm run build:android:test",
|
||||
"build:android:test:nostore:run": "FIREBASE_ENABLED=false npm run build:android:test:run",
|
||||
"build:android:test:run": "./scripts/build-android.sh --test --auto-run",
|
||||
"build:android:test:run:custom": "./scripts/build-android.sh --test --api-ip --auto-run",
|
||||
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
|
||||
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
|
||||
"build:electron": "./scripts/build-electron.sh",
|
||||
"build:electron:appimage": "./scripts/build-electron.sh --prod --appimage",
|
||||
"build:electron:appimage:dev": "./scripts/build-electron.sh --dev --appimage",
|
||||
"build:electron:appimage:prod": "./scripts/build-electron.sh --prod --appimage",
|
||||
"build:electron:appimage:test": "./scripts/build-electron.sh --test --appimage",
|
||||
"build:electron:deb": "./scripts/build-electron.sh --prod --deb",
|
||||
"build:electron:deb:dev": "./scripts/build-electron.sh --dev --deb",
|
||||
"build:electron:deb:prod": "./scripts/build-electron.sh --prod --deb",
|
||||
"build:electron:deb:test": "./scripts/build-electron.sh --test --deb",
|
||||
"build:electron:dev": "./scripts/build-electron.sh --dev",
|
||||
"build:electron:dev:run": "./scripts/build-electron.sh --dev --auto-run",
|
||||
"build:electron:dmg": "./scripts/build-electron.sh --prod --dmg",
|
||||
"build:electron:dmg:dev": "./scripts/build-electron.sh --dev --dmg",
|
||||
"build:electron:dmg:prod": "./scripts/build-electron.sh --prod --dmg",
|
||||
"build:electron:dmg:test": "./scripts/build-electron.sh --test --dmg",
|
||||
"build:electron:linux": "./scripts/build-electron.sh --prod --linux",
|
||||
"build:electron:linux:dev": "./scripts/build-electron.sh --dev --linux",
|
||||
"build:electron:linux:prod": "./scripts/build-electron.sh --prod --linux",
|
||||
"build:electron:linux:test": "./scripts/build-electron.sh --test --linux",
|
||||
"build:electron:mac": "./scripts/build-electron.sh --prod --mac",
|
||||
"build:electron:mac:dev": "./scripts/build-electron.sh --dev --mac",
|
||||
"build:electron:mac:prod": "./scripts/build-electron.sh --prod --mac",
|
||||
"build:electron:mac:test": "./scripts/build-electron.sh --test --mac",
|
||||
"build:electron:prod": "./scripts/build-electron.sh --prod",
|
||||
"build:electron:prod:run": "./scripts/build-electron.sh --prod --auto-run",
|
||||
"build:electron:test": "./scripts/build-electron.sh --test",
|
||||
"build:electron:test:run": "./scripts/build-electron.sh --test --auto-run",
|
||||
"build:electron:windows": "./scripts/build-electron.sh --prod --windows",
|
||||
"build:electron:windows:dev": "./scripts/build-electron.sh --dev --windows",
|
||||
"build:electron:windows:prod": "./scripts/build-electron.sh --prod --windows",
|
||||
"build:electron:windows:test": "./scripts/build-electron.sh --test --windows",
|
||||
"build:ios": "./scripts/build-ios.sh",
|
||||
"build:ios:assets": "./scripts/build-ios.sh --assets",
|
||||
"build:ios:clean": "./scripts/build-ios.sh --clean",
|
||||
"build:ios:debug": "./scripts/build-ios.sh --debug",
|
||||
"build:ios:debug:run": "./scripts/build-ios.sh --debug --auto-run",
|
||||
"build:ios:deploy": "./scripts/build-ios.sh --deploy",
|
||||
"build:ios:dev": "./scripts/build-ios.sh --dev",
|
||||
"build:ios:dev:custom": "./scripts/build-ios.sh --dev --api-ip",
|
||||
"build:ios:dev:nostore": "FIREBASE_ENABLED=false npm run build:ios:dev",
|
||||
"build:ios:dev:run:custom": "./scripts/build-ios.sh --dev --api-ip --auto-run",
|
||||
"build:ios:ipa": "./scripts/build-ios.sh --ipa",
|
||||
"build:ios:prod": "./scripts/build-ios.sh --prod",
|
||||
"build:ios:prod:nostore": "FIREBASE_ENABLED=false npm run build:ios:prod",
|
||||
"build:ios:prod:nostore:run": "FIREBASE_ENABLED=false npm run build:ios:prod:run",
|
||||
"build:ios:prod:run": "./scripts/build-ios.sh --prod --auto-run",
|
||||
"build:ios:release": "./scripts/build-ios.sh --release",
|
||||
"build:ios:release:run": "./scripts/build-ios.sh --release --auto-run",
|
||||
"build:ios:studio": "./scripts/build-ios.sh --studio",
|
||||
"build:ios:sync": "./scripts/build-ios.sh --sync",
|
||||
"build:ios:test": "./scripts/build-ios.sh --test",
|
||||
"build:ios:test:custom": "./scripts/build-ios.sh --test --api-ip",
|
||||
"build:ios:test:nostore": "FIREBASE_ENABLED=false npm run build:ios:test",
|
||||
"build:ios:test:nostore:run": "FIREBASE_ENABLED=false npm run build:ios:test:run",
|
||||
"build:ios:test:run": "./scripts/build-ios.sh --test --auto-run",
|
||||
"build:ios:test:run:custom": "./scripts/build-ios.sh --test --api-ip --auto-run",
|
||||
"build:native": "npm run build:capacitor:sync && bash -c 'source scripts/common.sh && ensure_ios_capacitor_asset_directories' && npx capacitor-assets generate --ios --android --assetPath resources",
|
||||
"build:web": "./scripts/build-web.sh",
|
||||
"build:web:docker": "./scripts/build-web.sh --docker",
|
||||
"build:web:docker:prod": "./scripts/build-web.sh --docker:prod",
|
||||
"build:web:docker:test": "./scripts/build-web.sh --docker:test",
|
||||
"build:web:prod": "./scripts/build-web.sh --prod",
|
||||
"build:web:serve": "./scripts/build-web.sh --serve",
|
||||
"build:web:serve:prod": "./scripts/build-web.sh --serve --prod",
|
||||
"build:web:serve:test": "./scripts/build-web.sh --serve --test",
|
||||
"build:web:test": "./scripts/build-web.sh --test",
|
||||
"check:android-device": "adb devices | grep -w 'device' || (echo 'No Android device connected' && exit 1)",
|
||||
"check:dependencies": "./scripts/check-dependencies.sh",
|
||||
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
|
||||
"check:prod-bundle": "./scripts/check-prod-bundle.sh",
|
||||
"clean:all": "npm run clean:ios && npm run clean:android && npm run clean:electron",
|
||||
"clean:android": "./scripts/uninstall-android.sh",
|
||||
"clean:electron": "./scripts/build-electron.sh --clean",
|
||||
"clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true",
|
||||
"deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
|
||||
"dev": "./scripts/build-web.sh --dev",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:logs": "docker-compose logs -f",
|
||||
"docker:up": "docker-compose up",
|
||||
"docker:up:prod": "npm run build:web:prod && docker-compose up production",
|
||||
"docker:up:test": "npm run build:web:test && docker-compose up test",
|
||||
"guard": "bash ./scripts/build-arch-guard.sh",
|
||||
"guard:setup": "npm run prepare && echo 'Build Architecture Guard is now active!'",
|
||||
"guard:test": "bash ./scripts/build-arch-guard.sh --staged",
|
||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
||||
"markdown:check": "markdownlint-cli2",
|
||||
"markdown:fix": "markdownlint-cli2 --fix",
|
||||
"markdown:setup": "./scripts/setup-markdown-hooks.sh",
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node scripts/copy-wasm.js",
|
||||
"prepare": "husky",
|
||||
"test:all": "npm run lint && npm run type-check && npm run type-check:vue && npm run test:unit && npm run test:web && npm run test:mobile && echo '\n\n\nGotta add the performance tests'",
|
||||
"test:android": "node scripts/test-android.js",
|
||||
"test:ios": "node scripts/test-ios.js",
|
||||
"test:mobile": "./scripts/test-mobile.sh",
|
||||
"test:prerequisites": "node scripts/check-prerequisites.js",
|
||||
"test:unit": "jest",
|
||||
"test:web": "npx playwright test -c playwright.config-local.ts --trace on",
|
||||
"type-check": "tsc --noEmit",
|
||||
"type-check:vue": "vue-tsc --noEmit"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,vue,css,json,yml,yaml}": "eslint --fix || true",
|
||||
@@ -138,21 +151,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",
|
||||
@@ -216,7 +229,6 @@
|
||||
"ramda": "^0.29.1",
|
||||
"readable-stream": "^4.5.2",
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"simple-vue-camera": "^1.1.3",
|
||||
"sqlite3": "^5.1.7",
|
||||
"stream-browserify": "^3.0.0",
|
||||
@@ -282,6 +294,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,20 @@ export default defineConfig({
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Load the app once before the tests so the dev server is warm; see that file
|
||||
* for the Firefox WebAssembly failure it reduces. */
|
||||
globalSetup: "./test-playwright/warmUpDevServer.ts",
|
||||
/* Retry once locally: with several instances in parallel, Firefox can fail to
|
||||
* start its WebAssembly compiler (so the app cannot open its database), at
|
||||
* any point in a run. A retried pass is reported as "flaky", not hidden. */
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 3,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: [
|
||||
['list'],
|
||||
// explains failures caused by the Firefox WebAssembly start-up error
|
||||
['./test-playwright/webAssemblyHintReporter.ts'],
|
||||
['html'],
|
||||
// ['html', { open: 'never' }], // don't automatically open report page after tests complete even with failures
|
||||
['json', { outputFile: 'test-playwright-results/test-results.json' }]
|
||||
@@ -106,7 +113,7 @@ export default defineConfig({
|
||||
*/
|
||||
webServer: {
|
||||
command:
|
||||
"VITE_APP_SERVER=http://localhost:8080 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_PASSKEYS_ENABLED=true npm run build:web -- --port=8080",
|
||||
"VITE_APP_SERVER=http://localhost:8080 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_PASSKEYS_ENABLED=true npm run dev",
|
||||
url: "http://localhost:8080",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
{
|
||||
"name": "Giftopia",
|
||||
"short_name": "Giftopia",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#4a90e2",
|
||||
"background_color": "#ffffff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "../icons/icon-48.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "48x48",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-72.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "72x72",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-96.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "96x96",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-128.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "128x128",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-192.webp",
|
||||
"type": "image/png",
|
||||
"src": "/img/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any maskable"
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-256.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-512.webp",
|
||||
"type": "image/png",
|
||||
"src": "/img/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/android-chrome-maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/android-chrome-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"files": [
|
||||
{
|
||||
"name": "photo",
|
||||
"accept": ["image/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/* eslint-env serviceworker */
|
||||
/**
|
||||
* Service worker for the web build.
|
||||
*
|
||||
* Its only job is the Web Share Target: the manifest's share_target POSTs a
|
||||
* photo to /share-target, which no static server can accept. This worker
|
||||
* stashes the photo in Cache Storage and redirects to /shared-photo, where
|
||||
* src/libs/webShareTarget.ts picks it up. Every other request goes straight to
|
||||
* the network (no fetch handler response, no precaching).
|
||||
*
|
||||
* It also replaces the Workbox worker that older builds registered at this
|
||||
* same URL, and deletes that worker's precaches, which otherwise keep serving
|
||||
* a stale index.html that points at JavaScript files no longer deployed.
|
||||
*/
|
||||
|
||||
const SHARE_TARGET_CACHE = "share-target";
|
||||
// Keep in sync with src/libs/webShareTarget.ts
|
||||
const SHARED_PHOTO_KEY = "/share-target/photo";
|
||||
const FILE_NAME_HEADER = "X-File-Name";
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const names = await caches.keys();
|
||||
await Promise.all(
|
||||
names
|
||||
.filter((name) => name !== SHARE_TARGET_CACHE)
|
||||
.map((name) => caches.delete(name)),
|
||||
);
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
if (
|
||||
event.request.method !== "POST" ||
|
||||
url.origin !== self.location.origin ||
|
||||
url.pathname !== "/share-target"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const formData = await event.request.formData();
|
||||
const photo = formData.get("photo");
|
||||
if (photo instanceof File) {
|
||||
const cache = await caches.open(SHARE_TARGET_CACHE);
|
||||
await cache.put(
|
||||
SHARED_PHOTO_KEY,
|
||||
new Response(photo, {
|
||||
headers: {
|
||||
"Content-Type": photo.type || "application/octet-stream",
|
||||
[FILE_NAME_HEADER]: encodeURIComponent(photo.name || ""),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Response.redirect("/shared-photo?source=share-target", 303);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## Commands Run (paste exact logs/snips)
|
||||
|
||||
- [ ] Web: `npm run build:web` / `:prod`
|
||||
- [ ] Web: `npm run dev` / `npm run build:web`
|
||||
- [ ] Electron: `npm run build:electron:dev` / package step
|
||||
- [ ] Mobile: `npm run build:android:test` / iOS equivalent
|
||||
- [ ] Clean/auto-run impacted scripts
|
||||
|
||||
@@ -46,17 +46,19 @@ Assets are automatically generated during the build process using `capacitor-ass
|
||||
|
||||
```bash
|
||||
# Generate assets for all platforms
|
||||
npx capacitor-assets generate
|
||||
npx capacitor-assets generate --assetPath resources
|
||||
|
||||
# Generate assets for specific platform
|
||||
npx capacitor-assets generate --ios
|
||||
npx capacitor-assets generate --android
|
||||
npx capacitor-assets generate --web
|
||||
npx capacitor-assets generate --ios --assetPath resources
|
||||
npx capacitor-assets generate --android --assetPath resources
|
||||
npx capacitor-assets generate --web --assetPath resources
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Asset generation is configured in `capacitor-assets.config.json` at the project root.
|
||||
`resources/` is this project's canonical asset source. Pass it explicitly with
|
||||
`--assetPath resources`; `@capacitor/assets` otherwise prioritizes a top-level
|
||||
`assets/` directory when one exists, even when that directory is empty.
|
||||
|
||||
## Version Control
|
||||
|
||||
|
||||
|
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 |