Compare commits
476
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 | ||
|
|
0c400b8797 | ||
|
|
d5bcdbae3f | ||
|
|
46a2e0aaf5 | ||
|
|
b02a1b29c5 | ||
|
|
3f291b0d63 | ||
|
|
6b65ad8554 | ||
|
|
03658340f8 | ||
|
|
d5c357b291 | ||
|
|
86611fe50d | ||
|
|
4152012838 | ||
|
|
25110e3eea | ||
|
|
0ecd4c6dd7 | ||
|
|
821d3b7d05 | ||
|
|
ee8fb80cfe | ||
|
|
cdf5c721a5 | ||
|
|
974002fa90 | ||
|
|
8e6c83021f | ||
|
|
cfe90fd04e | ||
|
|
fb9da10fd2 | ||
|
|
6d221ee1ca | ||
|
|
823db447ca | ||
|
|
f23fe65078 | ||
|
|
82380b3d35 | ||
|
|
87ffa025e8 | ||
|
|
15c9088736 | ||
|
|
ec41dd52d5 | ||
|
|
463db39a6b | ||
|
|
fe97dff752 | ||
|
|
903047f13b | ||
|
|
48be234af4 | ||
|
|
6c0907d905 | ||
|
|
8d8bcf2a7e | ||
|
|
a4b47904c8 | ||
|
|
bb890baacf | ||
|
|
6a7f341990 | ||
|
|
693bfacc1e | ||
|
|
3d6ac2ab53 | ||
|
|
cd32895281 | ||
|
|
a1f94300ad | ||
|
|
58b61471b5 | ||
|
|
55ef36be06 | ||
|
|
00abd5277f | ||
|
|
227ae85bb7 | ||
|
|
dae23300fe | ||
|
|
9e401febea | ||
|
|
e0a3f7094f | ||
|
|
2dd76878ba | ||
|
|
4fb8f048cd | ||
|
|
cd4b279703 | ||
|
|
c97defef11 | ||
|
|
2c0992ba8b | ||
|
|
a3a2d97b9a | ||
|
|
802050259c | ||
|
|
efd7d50a84 | ||
|
|
39c389cda8 | ||
|
|
93fdcaf7ff | ||
|
|
ad419efa0d | ||
|
|
ce45ddb2bd | ||
|
|
964cdb4509 | ||
|
|
656de5eba3 | ||
|
|
0d7586865c | ||
|
|
5bc030125a | ||
|
|
8cd8727a84 | ||
|
|
8864a2049b | ||
|
|
63f5c4ecc7 | ||
|
|
a4453c0b1b | ||
|
|
794b48f0d7 | ||
|
|
4c97c578bb | ||
|
|
6a9f34a516 | ||
|
|
5a40075ab1 | ||
|
|
48637ae9a8 | ||
|
|
7d306bd204 | ||
|
|
9713313a40 | ||
|
|
a55dce6f3d | ||
|
|
d7d5e401b8 | ||
|
|
19427c2817 | ||
|
|
d4ac0acd01 | ||
|
|
1ef3f32b9e | ||
|
|
fd0b8ce6d0 | ||
|
|
320e55912b | ||
|
|
6bbade2a29 | ||
|
|
1cd329c720 | ||
|
|
7c8ef284c2 | ||
|
|
35a1b92559 | ||
|
|
c523c14d96 | ||
|
|
162158066f | ||
|
|
1643bab18b | ||
|
|
ce078862e7 | ||
|
|
b9f19d3898 | ||
|
|
ffa7bac319 | ||
|
|
24957e0c6f | ||
|
|
954500cf9d | ||
|
|
e0e0a0a183 | ||
|
|
ea662f4430 | ||
|
|
81647e1f3c | ||
|
|
73d595046a | ||
|
|
cf9d207895 | ||
|
|
7d87a746f9 | ||
|
|
90e6603d52 | ||
|
|
8290943b53 | ||
|
|
8ba84888ee | ||
|
|
230dc52974 | ||
|
|
2c8aa21fa5 | ||
|
|
bf1ee78025 | ||
|
|
9f44a53047 | ||
|
|
c9ea2e4120 | ||
|
|
66b7d0f46e | ||
|
|
63dcf44125 | ||
|
|
43c9b95c14 | ||
|
|
f4ee507918 | ||
|
|
0ebad3b497 | ||
|
|
aaee3bbbd2 | ||
|
|
d4cdee0698 | ||
|
|
178dcec5b8 | ||
|
|
cf1ecdfb4c | ||
|
|
e9ad61b780 | ||
|
|
ad8df3eb93 | ||
|
|
05d346edce | ||
|
|
e259e60fa7 | ||
|
|
821de3f006 | ||
|
|
43f83031d4 | ||
|
|
688a48a332 | ||
|
|
8938c242ee | ||
|
|
e121db5fcf | ||
|
|
358af42afd | ||
|
|
59c00241b8 | ||
|
|
1389a166fa | ||
|
|
33ec90e571 | ||
|
|
3c262c9eeb | ||
|
|
e155e55e49 | ||
|
|
263b12c37e | ||
|
|
1df47f17c4 | ||
|
|
6f066a7e23 | ||
|
|
9a23e2beba | ||
|
|
8ac6dd6ce0 | ||
|
|
c0678385df | ||
|
|
fa1c639a8b | ||
|
|
5ae0d6ba2c | ||
|
|
3aff1e9749 | ||
|
|
6415eb2a03 | ||
|
|
9902e5fac7 | ||
|
|
fb9d5165df | ||
|
|
ba8915e1fb | ||
|
|
616d0fd6e0 | ||
|
|
7ae36ec361 | ||
|
|
f3cf228b48 | ||
|
|
d5db13dc18 | ||
|
|
717efb087b | ||
|
|
f3cfa9552d | ||
|
|
de486a2e23 | ||
|
|
94f31faacc | ||
|
|
099eac594f | ||
|
|
6825bd5214 | ||
|
|
b4b7d71330 | ||
|
|
af63ab70e7 | ||
|
|
41149ad28a | ||
|
|
4f89869a87 | ||
|
|
a45f605c5f | ||
|
|
96ae89bcfa | ||
|
|
7e2b16ddad | ||
|
|
29aff896be | ||
|
|
d4721f5f4c | ||
|
|
baac36607b | ||
|
|
3c657848c5 | ||
|
|
cbd71b7efd | ||
|
|
25c3cd99e4 | ||
|
|
cd5f9f5317 | ||
|
|
47ead0ced2 | ||
|
|
dd8850aa24 | ||
|
|
e7e2830807 | ||
|
|
41e50bdf95 | ||
|
|
0dc3e2e251 | ||
|
|
5809cd568a | ||
|
|
30c6df557c | ||
|
|
07ebd1c32f | ||
|
|
f3152fc414 | ||
|
|
59303010c1 | ||
|
|
0d5602839c | ||
|
|
67ff0cfb99 | ||
|
|
f7cee7df78 | ||
|
|
8310152c34 | ||
|
|
c28c47a3c9 | ||
|
|
1b97ac08fd | ||
|
|
17ccfd1fea | ||
|
|
0e096b1a46 | ||
|
|
7838eea30f | ||
|
|
bb9b0d3c2f | ||
|
|
a910399cad | ||
|
|
dc3f12d53b | ||
|
|
ec55a74cbf | ||
|
|
c2fb493073 | ||
|
|
c05dff6654 | ||
|
|
a7fbb26847 | ||
|
|
0a927ccec5 | ||
|
|
1b19919121 | ||
|
|
1c3d449c85 | ||
|
|
0a88f23bc7 | ||
|
|
fe9cdd6398 | ||
|
|
f5cb70ec8b | ||
|
|
a71bd09b78 | ||
|
|
e38b752b27 | ||
|
|
80cc09de95 | ||
|
|
d0878507a6 | ||
|
|
099d70e8a9 | ||
|
|
cc7c7eb88b | ||
|
|
1345118b79 | ||
|
|
22c3ac80c2 | ||
|
|
fb4ea08f3c | ||
|
|
88f69054f4 | ||
|
|
77e8d2d2ab | ||
|
|
8991b29705 | ||
|
|
31dfeb0988 | ||
|
|
5a4ab84bfe | ||
|
|
df61e899da | ||
|
|
b775c5b4c1 | ||
|
|
84c3f79c57 | ||
|
|
a04730cd64 | ||
|
|
077f45f900 | ||
|
|
14ffcb5434 | ||
|
|
a0b99d5fca | ||
|
|
bcf654e2e8 | ||
|
|
e1b312a402 | ||
|
|
2684484a84 | ||
|
|
09c38a8b1c | ||
|
|
0c0bda725c | ||
|
|
6587506d83 | ||
|
|
29b2d9927d | ||
|
|
9a6e78ee9d | ||
|
|
679c4d6456 | ||
|
|
1fc7e4726d | ||
|
|
b500a1e7c0 | ||
|
|
46f2cbfcc6 | ||
|
|
08f91e4c96 | ||
|
|
e94effd111 | ||
|
|
84cad0e169 | ||
|
|
b6704b348b | ||
|
|
662da79df8 | ||
|
|
02eb891ee9 | ||
|
|
051af89476 | ||
|
|
9b2d14b418 | ||
|
|
6e73ab4a84 | ||
|
|
11736b5751 | ||
|
|
85e7682b90 | ||
|
|
b91d387815 | ||
|
|
4a3b968ee2 | ||
|
|
f64846ae17 | ||
|
|
24b636cd2f | ||
|
|
faef83a664 | ||
|
|
c992afe4d4 | ||
|
|
941d93f6db | ||
|
|
f460d6c3e2 | ||
|
|
e7ca2bb791 | ||
|
|
b864f1632d | ||
|
|
ffeac44b39 | ||
|
|
08d55519e6 | ||
|
|
bf8694fc75 | ||
|
|
386b7604eb | ||
|
|
9260892838 | ||
|
|
fe1df9a9fb | ||
|
|
7ef5889185 | ||
|
|
3a4cdf78d8 | ||
|
|
0697b14411 | ||
|
|
7aea818f01 | ||
|
|
d4a7c0dda0 | ||
|
|
34a7119086 | ||
|
|
70a0ef7ef6 | ||
|
|
306e221479 | ||
|
|
4b118b0b91 | ||
|
|
3d2201fc17 | ||
|
|
bb92e3ac4f | ||
|
|
a672c977a8 | ||
|
|
38b137a86b | ||
|
|
dbd18bba6c | ||
|
|
0c66142093 | ||
|
|
84983ee10b | ||
|
|
eeac7fdb66 | ||
|
|
1a8383bc63 | ||
|
|
4c771d8be3 | ||
|
|
0627cd32b7 | ||
|
|
e1eb91f26d | ||
|
|
09a230f43e | ||
|
|
eff4126043 | ||
|
|
ae49c0e907 | ||
|
|
1b4ab7a500 | ||
|
|
6ec2002cb0 | ||
|
|
36eb9a16b0 | ||
|
|
7d295dd062 | ||
|
|
5f1b4dcc21 | ||
|
|
11f122552d | ||
|
|
c84a3b6705 | ||
|
|
203cf6b078 | ||
|
|
9b84b28a78 | ||
|
|
e64902321f | ||
|
|
7abce8f95c | ||
|
|
88dce4d100 | ||
|
|
c4eb6f2d1d | ||
|
|
06fdaff879 | ||
|
|
8024a3d02a | ||
|
|
223031866b | ||
|
|
cb75b25529 | ||
|
|
83b470e28a | ||
|
|
acf104eaa7 | ||
|
|
e793d7a9e2 | ||
|
|
3ecae0be0f | ||
|
|
d37e53b1a9 | ||
|
|
2f89c7e13b | ||
|
|
6bf4055c2f | ||
|
|
bf7ee630d0 | ||
|
|
1739567b18 | ||
|
|
a5a9af5ddc | ||
|
|
4e3e293495 | ||
|
|
65533c15d2 | ||
|
|
2530bc0ec2 | ||
|
|
5050156beb | ||
|
|
b1fa6ac458 | ||
|
|
9ff24f8258 | ||
|
|
9a3409c29f | ||
|
|
d265a9f78c | ||
|
|
f848de15f1 | ||
|
|
ebaf2dedf0 | ||
|
|
749204f96b | ||
|
|
a142737771 | ||
|
|
1053bb6e4c | ||
|
|
88f46787e5 | ||
|
|
d9230d0be8 | ||
|
|
38f301f053 | ||
|
|
e42552c67a | ||
|
|
0e3c6cb314 | ||
|
|
232b787b37 | ||
|
|
c06ffec466 | ||
|
|
8b199ec76c | ||
|
|
7e861e2fca | ||
|
|
73806e78bc | ||
|
|
d32cca4f53 | ||
|
|
4004d9fe52 | ||
|
|
1bb3f52a30 | ||
|
|
2f99d0b416 | ||
|
|
9c3002f9c7 | ||
|
|
82fd7cddf7 | ||
|
|
10f2920e11 | ||
|
|
4b1a724246 | ||
|
|
d7db7731cf | ||
|
|
75c89b471c | ||
|
|
a804877a08 | ||
|
|
f7441f39e7 | ||
|
|
9628d5c8c6 | ||
|
|
b37051f25d | ||
|
|
7b87ab2a5c | ||
|
|
ca7ead224b | ||
|
|
bfc2f07326 | ||
|
|
562713d5a4 | ||
|
|
8100ee5be4 | ||
|
|
966ca8276d | ||
|
|
27e38f583b | ||
|
|
1e3ecf6d0f | ||
|
|
4d9435f257 | ||
|
|
e8e00d3eae | ||
|
|
5c0ce2d1fb | ||
|
|
9e1c267bc0 | ||
|
|
723a0095a0 | ||
|
|
9a94843b68 | ||
|
|
9f3c62a29c | ||
|
|
39173a8db2 | ||
|
|
7ea6a2ef69 | ||
|
|
f0f0f1681e | ||
|
|
2f1eeb6700 | ||
|
|
a353ed3c3e | ||
|
|
e048e4c86b | ||
|
|
16ed5131c4 | ||
|
|
e647af0777 | ||
|
|
e6cc058935 | ||
|
|
ad51c187aa | ||
|
|
37cff0083f | ||
|
|
2049c9b6ec | ||
|
|
6fbc9c2a5b | ||
|
|
f186e129db | ||
|
|
455dfadb92 | ||
|
|
035509224b | ||
|
|
e9ea89edae | ||
|
|
1ce7c0486a | ||
|
|
637fc10e64 | ||
|
|
37d4dcc1a8 | ||
|
|
c369c76c1a | ||
|
|
86caf793aa | ||
|
|
499fbd2cb3 | ||
|
|
a4a9293bc2 | ||
|
|
9ac9f1d4a3 | ||
|
|
4f3a1b390d | ||
|
|
4de4fbecaf | ||
|
|
e3598992e7 | ||
|
|
ea19195850 | ||
|
|
ca545fd4b8 | ||
|
|
07b538cadc | ||
|
|
b84546686a | ||
|
|
461ee84d2a | ||
|
|
acf7d611e8 | ||
|
|
fface30123 | ||
|
|
b0d13b3cd4 | ||
|
|
97b382451a | ||
|
|
7fd2c4e0c7 | ||
|
|
20322789a2 | ||
|
|
666bed0efd | ||
|
|
7432525f4c | ||
|
|
530cddfab0 | ||
|
|
5340c00ae2 |
@@ -181,26 +181,26 @@ Brief description of the document's purpose and scope.
|
||||
### Check Single File
|
||||
|
||||
```bash
|
||||
npx markdownlint docs/filename.md
|
||||
npx markdownlint doc/filename.md
|
||||
```
|
||||
|
||||
### Check All Documentation
|
||||
|
||||
```bash
|
||||
npx markdownlint docs/
|
||||
npx markdownlint doc/
|
||||
```
|
||||
|
||||
### Auto-fix Common Issues
|
||||
|
||||
```bash
|
||||
# Remove trailing spaces
|
||||
sed -i 's/[[:space:]]*$//' docs/filename.md
|
||||
sed -i 's/[[:space:]]*$//' doc/filename.md
|
||||
|
||||
# Remove multiple blank lines
|
||||
sed -i '/^$/N;/^\n$/D' docs/filename.md
|
||||
sed -i '/^$/N;/^\n$/D' doc/filename.md
|
||||
|
||||
# Add newline at end if missing
|
||||
echo "" >> docs/filename.md
|
||||
echo "" >> doc/filename.md
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
@@ -269,7 +269,7 @@ The workflow system integrates seamlessly with existing development practices:
|
||||
your task
|
||||
4. **Meta-Rules**: Use workflow-specific meta-rules for specialized tasks
|
||||
- **Documentation**: Use `meta_documentation.mdc` for all documentation work
|
||||
- **Getting Started**: See `docs/meta_rule_usage_guide.md` for comprehensive usage instructions
|
||||
- **Getting Started**: See `doc/meta_rule_usage_guide.md` for comprehensive usage instructions
|
||||
5. **Cross-References**: All files contain updated cross-references to
|
||||
reflect the new structure
|
||||
6. **Validation**: All files pass markdown validation and maintain
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -122,11 +122,11 @@ npm run lint-fix
|
||||
|
||||
## Resources
|
||||
|
||||
- **Testing**: `docs/migration-testing/`
|
||||
- **Testing**: `doc/migration-testing/`
|
||||
|
||||
- **Architecture**: `docs/architecture-decisions.md`
|
||||
- **Architecture**: `doc/architecture-decisions.md`
|
||||
|
||||
- **Build Context**: `docs/build-modernization-context.md`
|
||||
- **Build Context**: `doc/build-modernization-context.md`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
globs: **/src/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
✅ use system date command to timestamp all interactions with accurate date and
|
||||
✅ use system date command to timestamp all documentation with accurate date and
|
||||
time
|
||||
✅ remove whitespace at the end of lines
|
||||
✅ use npm run lint-fix to check for warnings
|
||||
|
||||
@@ -122,9 +122,9 @@ Copy/paste and fill:
|
||||
|
||||
- `src/...`
|
||||
|
||||
- ADR: `docs/adr/xxxx-yy-zz-something.md`
|
||||
- ADR: `doc/adr/xxxx-yy-zz-something.md`
|
||||
|
||||
- Design: `docs/...`
|
||||
- Design: `doc/...`
|
||||
|
||||
## Competence Hooks
|
||||
|
||||
@@ -230,7 +230,7 @@ Before proposing solutions, trace the actual execution path:
|
||||
|
||||
attach during service/feature investigations
|
||||
|
||||
- `docs/adr/**` — attach when editing ADRs
|
||||
- `doc/adr/**` — attach when editing ADRs
|
||||
|
||||
## Referenced Files
|
||||
|
||||
|
||||
+7
-2
@@ -6,13 +6,18 @@ VITE_LOG_LEVEL=debug
|
||||
# iOS doesn't like spaces in the app title.
|
||||
TIME_SAFARI_APP_TITLE="TimeSafari_Dev"
|
||||
VITE_APP_SERVER=http://localhost:8080
|
||||
# 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 the environment
|
||||
# test server
|
||||
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
|
||||
# production server
|
||||
#VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
|
||||
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
|
||||
# Using shared server by default to ease setup, which works for shared test users.
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
|
||||
#VITE_DEFAULT_PUSH_SERVER... can't be set up with localhost domain
|
||||
# Using shared test notify API (no local notify server by default).
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_PASSKEYS_ENABLED=true
|
||||
|
||||
@@ -11,3 +11,4 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
|
||||
VITE_DEFAULT_PUSH_SERVER=https://timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -15,4 +15,5 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch
|
||||
VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_PASSKEYS_ENABLED=true
|
||||
|
||||
+3
-2
@@ -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
|
||||
@@ -16,6 +14,9 @@ myenv
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# npm configuration with sensitive tokens
|
||||
.npmrc
|
||||
|
||||
# Log filesopenssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature") "$signing_input"
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
Executable → Regular
+7
-38
@@ -1,40 +1,9 @@
|
||||
echo "husky - DEPRECATED
|
||||
|
||||
Please remove the following two lines from $0:
|
||||
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# Husky Helper Script
|
||||
# This file is sourced by all Husky hooks
|
||||
#
|
||||
if [ -z "$husky_skip_init" ]; then
|
||||
debug () {
|
||||
if [ "$HUSKY_DEBUG" = "1" ]; then
|
||||
echo "husky (debug) - $1"
|
||||
fi
|
||||
}
|
||||
. \"\$(dirname -- \"\$0\")/_/husky.sh\"
|
||||
|
||||
readonly hook_name="$(basename -- "$0")"
|
||||
debug "starting $hook_name..."
|
||||
|
||||
if [ "$HUSKY" = "0" ]; then
|
||||
debug "HUSKY env variable is set to 0, skipping hook"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f ~/.huskyrc ]; then
|
||||
debug "sourcing ~/.huskyrc"
|
||||
. ~/.huskyrc
|
||||
fi
|
||||
|
||||
readonly husky_skip_init=1
|
||||
export husky_skip_init
|
||||
sh -e "$0" "$@"
|
||||
exitCode="$?"
|
||||
|
||||
if [ $exitCode != 0 ]; then
|
||||
echo "husky - $hook_name hook exited with code $exitCode (error)"
|
||||
fi
|
||||
|
||||
if [ $exitCode = 127 ]; then
|
||||
echo "husky - command not found in PATH=$PATH"
|
||||
fi
|
||||
|
||||
exit $exitCode
|
||||
fi
|
||||
They WILL FAIL in v10.0.0
|
||||
"
|
||||
@@ -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
|
||||
|
||||
+34
-2
@@ -1,14 +1,16 @@
|
||||
#!/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..."
|
||||
|
||||
# Run lint-fix first
|
||||
echo "📝 Running lint-fix..."
|
||||
|
||||
# Capture git status before lint-fix to detect changes
|
||||
git_status_before=$(git status --porcelain)
|
||||
|
||||
npm run lint-fix || {
|
||||
echo
|
||||
echo "❌ Linting failed. Please fix the issues and try again."
|
||||
@@ -18,6 +20,36 @@ npm run lint-fix || {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if lint-fix made any changes
|
||||
git_status_after=$(git status --porcelain)
|
||||
|
||||
if [ "$git_status_before" != "$git_status_after" ]; then
|
||||
echo
|
||||
echo "⚠️ lint-fix made changes to your files!"
|
||||
echo "📋 Changes detected:"
|
||||
git diff --name-only
|
||||
echo
|
||||
echo "❓ What would you like to do?"
|
||||
echo " [c] Continue commit without the new changes"
|
||||
echo " [a] Abort commit (recommended - review and stage the changes)"
|
||||
echo
|
||||
printf "Choose [c/a]: "
|
||||
# The `< /dev/tty` is necessary to make read work in git's non-interactive shell
|
||||
read choice < /dev/tty
|
||||
|
||||
case $choice in
|
||||
[Cc]* )
|
||||
echo "✅ Continuing commit without lint-fix changes..."
|
||||
sleep 3
|
||||
;;
|
||||
[Aa]* | * )
|
||||
echo "🛑 Commit aborted. Please review the changes made by lint-fix."
|
||||
echo "💡 You can stage the changes with 'git add .' and commit again."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Then run Build Architecture Guard
|
||||
|
||||
#echo "🏗️ Running Build Architecture Guard..."
|
||||
|
||||
@@ -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
@@ -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.
|
||||
+341
-351
File diff suppressed because it is too large
Load Diff
+155
@@ -5,6 +5,161 @@ All notable changes to this project will be documented in this file.
|
||||
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.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]
|
||||
### Added
|
||||
- Attendee exclusion and do-not-pair groups for meeting matching.
|
||||
### Fixed
|
||||
- Contact deep-links clicked or pasted act consistenly
|
||||
|
||||
|
||||
## [1.3.5] - 2026.02.22
|
||||
### Fixed
|
||||
- SQL error on startup (contact_labels -> contacts foreign key)
|
||||
### Added
|
||||
- Ability to toggle embeddings on list of contacts
|
||||
|
||||
|
||||
## [1.3.3] - 2026.02.17
|
||||
### Added
|
||||
- People can be marked as vector-embeddings users.
|
||||
- People can be matched during a meeting.
|
||||
### Fixed
|
||||
- Problem hiding new contacts in feed
|
||||
|
||||
|
||||
## [1.1.6] - 2026.01.21
|
||||
### Added
|
||||
- Labels on contacts
|
||||
- Ability to switch giver & recipient on the gift-details page
|
||||
### Changed
|
||||
- Invitations now must be explicitly accepted.
|
||||
### Fixed
|
||||
- Show all starred projects.
|
||||
- Incorrect contacts as "most recent" on gift-details page
|
||||
|
||||
|
||||
## [1.1.5] - 2025.12.28
|
||||
### Fixed
|
||||
- Incorrect prompts in give-dialog on a project or offer
|
||||
|
||||
|
||||
## [1.1.4] - 2025.12.18
|
||||
### Fixed
|
||||
- Contact notes & contact methods preserved in export
|
||||
### Added
|
||||
- This is a target for sharing
|
||||
- Switch to a project or person in give-dialog pop-up
|
||||
- Starred projects onto project-choice in give-dialog pop-up
|
||||
### Changed
|
||||
- Front page: 1 green "Thank" button
|
||||
|
||||
|
||||
## [1.1.3] - 2025.11.19
|
||||
### Changed
|
||||
- Project selection in dialogs now reaches out to server when filtering
|
||||
- Project selection during onboarding meeting is a search (not an input box)
|
||||
- Improve the switching of agent when agent edits a project
|
||||
### Fixed
|
||||
- Reassignment of "you" as recipient when changing giver project
|
||||
- Bad counts for project-change notification on front page
|
||||
|
||||
|
||||
## [1.1.2] - 2025.11.06
|
||||
### Fixed
|
||||
- Bad page when user follows prompt to backup seed
|
||||
|
||||
|
||||
## [1.1.1] - 2025.11.03
|
||||
|
||||
### Added
|
||||
- Meeting onboarding via prompts
|
||||
- Emojis on gift feed
|
||||
- Starred projects with notification
|
||||
|
||||
|
||||
## [1.0.7] - 2025.08.18
|
||||
|
||||
### Fixed
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
@@ -15,13 +15,43 @@ Quick start:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build:web:serve -- --test
|
||||
```
|
||||
|
||||
To be able to take action on the platform: go to [the test page](http://localhost:8080/test) and click "Become User 0".
|
||||
### Web
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Then go to [the test page](http://localhost:8080/test) and click "Become User 0" to take action on the platform.
|
||||
|
||||
### Android
|
||||
|
||||
```bash
|
||||
npm run build:android:test:run
|
||||
```
|
||||
|
||||
Assumes ADB is installed; see [Android Build](BUILDING.md#android-build) for SDK, emulator, and `PATH` setup.
|
||||
|
||||
### iOS
|
||||
|
||||
```bash
|
||||
npm run build:ios:studio
|
||||
```
|
||||
|
||||
Assumes Xcode and Xcode Command Line Tools are installed.
|
||||
|
||||
See [BUILDING.md](BUILDING.md) for comprehensive build instructions for all platforms (Web, Electron, iOS, Android, Docker).
|
||||
|
||||
## 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
|
||||
@@ -68,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
|
||||
@@ -89,6 +119,27 @@ VITE_LOG_LEVEL=debug npm run build:web:dev
|
||||
|
||||
See [Logging Configuration Guide](doc/logging-configuration.md) for complete details.
|
||||
|
||||
## Notification Debug Panel (dev builds)
|
||||
|
||||
In non-production bundles (for example `vite dev` or a Vite build whose mode is not `production`), the **Notification Debug Panel** at `/dev/notifications` helps you test 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**.
|
||||
|
||||
Key configuration (independent settings):
|
||||
|
||||
- **Notification Backend URL** — which notification server receives API calls
|
||||
- **Test Mode** — `testMode` sent in JSON request bodies (default on)
|
||||
- **Skip JWT Authentication (Local Development Only)** — omit JWT headers for local unauthenticated backends (default off)
|
||||
|
||||
See [doc/notification-debug-panel.md](doc/notification-debug-panel.md) for controls, recommended settings (hosted test server vs local ngrok), and troubleshooting.
|
||||
|
||||
Platform-specific end-to-end guides:
|
||||
|
||||
- [doc/local-android-testing-ngrok.md](doc/local-android-testing-ngrok.md)
|
||||
- [doc/local-ios-testing-ngrok.md](doc/local-ios-testing-ngrok.md)
|
||||
|
||||
## Database Clearing (development)
|
||||
|
||||
### Quick Usage
|
||||
```bash
|
||||
# Run the database clearing script
|
||||
@@ -96,7 +147,7 @@ See [Logging Configuration Guide](doc/logging-configuration.md) for complete det
|
||||
|
||||
# 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
|
||||
@@ -178,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
|
||||
@@ -279,18 +330,16 @@ The application uses a platform-agnostic database layer with Vue mixins for serv
|
||||
* `src/services/PlatformServiceFactory.ts` - Platform-specific service factory
|
||||
* `src/services/AbsurdSqlDatabaseService.ts` - SQLite implementation
|
||||
* `src/utils/PlatformServiceMixin.ts` - Vue mixin for database access with caching
|
||||
* `src/db/` - Legacy Dexie database (migration in progress)
|
||||
|
||||
**Development Guidelines**:
|
||||
|
||||
- Always use `PlatformServiceMixin` for database operations in components
|
||||
- Test with PlatformServiceMixin for new features
|
||||
- Use migration tools for data transfer between systems
|
||||
- Leverage mixin's ultra-concise methods: `$db()`, `$exec()`, `$one()`, `$contacts()`, `$settings()`
|
||||
|
||||
**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/
|
||||
@@ -305,21 +354,48 @@ timesafari/
|
||||
└── 📄 doc/README-BUILD-GUARD.md # Guard system documentation
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
### Building an APK for Android 6 (API 23)
|
||||
|
||||
### Critical Vue Reactivity Bug
|
||||
A critical Vue reactivity bug was discovered during ActiveDid migration testing where component properties fail to trigger template updates correctly.
|
||||
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").
|
||||
|
||||
**Impact**: The `newDirectOffersActivityNumber` element in HomeView.vue requires a watcher workaround to render correctly.
|
||||
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`.
|
||||
|
||||
**Status**: Workaround implemented, investigation ongoing.
|
||||
1. Get the 1.4.6 source:
|
||||
|
||||
**Documentation**: See [Vue Reactivity Bug Report](doc/vue-reactivity-bug-report.md) for details.
|
||||
```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
|
||||
2. **Use the PR template** - Complete the checklist for build-related changes
|
||||
3. **Test your changes** - Ensure builds work on affected platforms
|
||||
4. **Document updates** - Keep BUILDING.md current and accurate
|
||||
|
||||
|
||||
+56
-11
@@ -27,12 +27,20 @@ if (!project.ext.MY_KEYSTORE_FILE) {
|
||||
android {
|
||||
namespace 'app.timesafari'
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
compileOptions {
|
||||
// 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 41
|
||||
versionName "1.0.8"
|
||||
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.
|
||||
@@ -66,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 {
|
||||
@@ -94,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"
|
||||
@@ -101,6 +111,20 @@ dependencies {
|
||||
implementation project(':capacitor-android')
|
||||
implementation project(':capacitor-community-sqlite')
|
||||
implementation "androidx.biometric:biometric:1.2.0-alpha05"
|
||||
|
||||
// Daily Notification Plugin dependencies
|
||||
implementation "androidx.room:room-runtime:2.6.1"
|
||||
implementation "androidx.work:work-runtime-ktx:2.9.0"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
||||
annotationProcessor "androidx.room:room-compiler:2.6.1"
|
||||
|
||||
// Capacitor annotation processor for automatic plugin discovery
|
||||
annotationProcessor project(':capacitor-android')
|
||||
|
||||
// Additional dependencies for notification plugin
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
@@ -109,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,12 @@ dependencies {
|
||||
implementation project(':capacitor-camera')
|
||||
implementation project(':capacitor-clipboard')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capawesome-capacitor-file-picker')
|
||||
implementation project(':timesafari-daily-notification-plugin')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "123456789000",
|
||||
"project_id": "timesafari-app",
|
||||
"storage_bucket": "timesafari-app.appspot.com"
|
||||
"project_number": "1094643115061",
|
||||
"project_id": "pc-api-7249509642322112640-286",
|
||||
"storage_bucket": "pc-api-7249509642322112640-286.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:123456789000:android:1234567890abcdef",
|
||||
"mobilesdk_app_id": "1:1094643115061:android:f11bd26f6bd2fcdc887d7c",
|
||||
"android_client_info": {
|
||||
"package_name": "app.timesafari.app"
|
||||
}
|
||||
@@ -15,7 +15,45 @@
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDummyKeyForBuildPurposesOnly12345"
|
||||
"current_key": "AIzaSyCFLYeLfGQqh7ErvzXgy74H0Gx3yQAMEw8"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1094643115061:android:354e70007466b006887d7c",
|
||||
"android_client_info": {
|
||||
"package_name": "ch.endorser.mobile"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyCFLYeLfGQqh7ErvzXgy74H0Gx3yQAMEw8"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1094643115061:android:40b63cb5851f34ac887d7c",
|
||||
"android_client_info": {
|
||||
"package_name": "com.veramo_react_native"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyCFLYeLfGQqh7ErvzXgy74H0Gx3yQAMEw8"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
@@ -24,5 +62,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<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"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
@@ -27,8 +31,59 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="timesafari" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Share Target Intent Filter - Single Image -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Share Target Intent Filter - Multiple Images (optional, we'll handle first image) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Daily Notification Plugin Receivers (must be inside application) -->
|
||||
<!-- DailyNotificationReceiver: Handles alarm-triggered notifications -->
|
||||
<!-- Note: exported="true" allows AlarmManager to trigger this receiver -->
|
||||
<receiver
|
||||
android:name="org.timesafari.dailynotification.DailyNotificationReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.timesafari.daily.NOTIFICATION" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- NotifyReceiver: Handles notification delivery -->
|
||||
<receiver
|
||||
android:name="org.timesafari.dailynotification.NotifyReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
/>
|
||||
|
||||
<!-- BootReceiver: reschedule daily notification after device restart.
|
||||
Two intent-filters: BOOT_COMPLETED has no Uri, so must not share a filter with <data scheme="package"/> or the boot broadcast never matches. -->
|
||||
<receiver
|
||||
android:name="org.timesafari.dailynotification.BootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:directBootAware="true">
|
||||
<intent-filter android:priority="1000">
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
<intent-filter android:priority="1000">
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.PACKAGE_REPLACED" />
|
||||
<data android:scheme="package" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -45,4 +100,14 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||
|
||||
<!-- Daily Notification Plugin Permissions -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"appId": "app.timesafari",
|
||||
"appName": "TimeSafari",
|
||||
"appName": "Giftopia",
|
||||
"webDir": "dist",
|
||||
"server": {
|
||||
"cleartext": true
|
||||
@@ -16,6 +16,13 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"PushNotifications": {
|
||||
"presentationOptions": [
|
||||
"badge",
|
||||
"sound",
|
||||
"alert"
|
||||
]
|
||||
},
|
||||
"SplashScreen": {
|
||||
"launchShowDuration": 3000,
|
||||
"launchAutoHide": true,
|
||||
@@ -29,19 +36,44 @@
|
||||
"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
|
||||
},
|
||||
"DailyNotification": {
|
||||
"debugMode": true,
|
||||
"enableNotifications": true,
|
||||
"timesafariConfig": {
|
||||
"activeDid": "",
|
||||
"endpoints": {
|
||||
"projectsLastUpdated": "https://api.endorser.ch/api/v2/report/plansLastUpdatedBetween"
|
||||
},
|
||||
"starredProjectsConfig": {
|
||||
"enabled": true,
|
||||
"starredPlanHandleIds": [],
|
||||
"fetchInterval": "0 8 * * *"
|
||||
}
|
||||
},
|
||||
"networkConfig": {
|
||||
"timeout": 30000,
|
||||
"retryAttempts": 3,
|
||||
"retryDelay": 1000
|
||||
},
|
||||
"contentFetch": {
|
||||
"enabled": true,
|
||||
"schedule": "0 8 * * *",
|
||||
"fetchLeadTimeMinutes": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"ios": {
|
||||
@@ -75,7 +107,7 @@
|
||||
},
|
||||
"buildOptions": {
|
||||
"appId": "app.timesafari",
|
||||
"productName": "TimeSafari",
|
||||
"productName": "Giftopia",
|
||||
"directories": {
|
||||
"output": "dist-electron-packages"
|
||||
},
|
||||
|
||||
@@ -23,6 +23,14 @@
|
||||
"pkg": "@capacitor/filesystem",
|
||||
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/preferences",
|
||||
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/push-notifications",
|
||||
"classpath": "com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/share",
|
||||
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
|
||||
@@ -34,5 +42,21 @@
|
||||
{
|
||||
"pkg": "@capawesome/capacitor-file-picker",
|
||||
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@timesafari/daily-notification-plugin",
|
||||
"classpath": "org.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "SafeArea",
|
||||
"classpath": "app.timesafari.safearea.SafeAreaPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "SharedImage",
|
||||
"classpath": "app.timesafari.sharedimage.SharedImagePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "NotificationInspector",
|
||||
"classpath": "app.timesafari.notifications.NotificationInspectorPlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package app.timesafari;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.view.WindowInsetsController;
|
||||
@@ -11,9 +15,23 @@ import android.webkit.WebSettings;
|
||||
import android.webkit.WebViewClient;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import app.timesafari.safearea.SafeAreaPlugin;
|
||||
import app.timesafari.sharedimage.SharedImagePlugin;
|
||||
import app.timesafari.notifications.NotificationInspectorPlugin;
|
||||
//import com.getcapacitor.community.sqlite.SQLite;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
import java.io.InputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
private static final String TAG = "MainActivity";
|
||||
private static final String SHARED_PREFS_NAME = "shared_image";
|
||||
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) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -48,9 +66,190 @@ public class MainActivity extends BridgeActivity {
|
||||
// Register SafeArea plugin
|
||||
registerPlugin(SafeAreaPlugin.class);
|
||||
|
||||
// Register SharedImage plugin
|
||||
registerPlugin(SharedImagePlugin.class);
|
||||
|
||||
// Register NotificationInspector plugin (dev tooling; safe no-op on Android)
|
||||
registerPlugin(NotificationInspectorPlugin.class);
|
||||
|
||||
// Register DailyNotification plugin
|
||||
// Plugin is written in Kotlin but compiles to Java-compatible bytecode
|
||||
registerPlugin(org.timesafari.dailynotification.DailyNotificationPlugin.class);
|
||||
|
||||
// Register native content fetcher for API-driven daily notifications (Endorser.ch)
|
||||
org.timesafari.dailynotification.DailyNotificationPlugin.setNativeFetcher(
|
||||
new TimeSafariNativeFetcher(this));
|
||||
|
||||
// Initialize SQLite
|
||||
//registerPlugin(SQLite.class);
|
||||
|
||||
// Handle share intent if app was launched from share sheet
|
||||
handleShareIntent(getIntent());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
handleShareIntent(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle share intents (ACTION_SEND or ACTION_SEND_MULTIPLE)
|
||||
* Processes shared images and stores them in SharedPreferences for plugin to read
|
||||
*/
|
||||
private void handleShareIntent(Intent intent) {
|
||||
if (intent == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String action = intent.getAction();
|
||||
String type = intent.getType();
|
||||
|
||||
boolean handled = false;
|
||||
|
||||
// Handle single image share
|
||||
if (Intent.ACTION_SEND.equals(action) && type != null && type.startsWith("image/")) {
|
||||
Uri imageUri;
|
||||
// Use new API for API 33+ (Android 13+), fall back to deprecated API for older versions
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
imageUri = intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri.class);
|
||||
} else {
|
||||
// Deprecated but still works on older versions
|
||||
@SuppressWarnings("deprecation")
|
||||
Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
imageUri = uri;
|
||||
}
|
||||
if (imageUri != null) {
|
||||
String fileName = intent.getStringExtra(Intent.EXTRA_TEXT);
|
||||
if (processSharedImage(imageUri, fileName)) {
|
||||
notifySharedPhotoDeepLink();
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
// Handle multiple images share (we'll just process the first one)
|
||||
else if (Intent.ACTION_SEND_MULTIPLE.equals(action) && type != null && type.startsWith("image/")) {
|
||||
java.util.ArrayList<Uri> imageUris;
|
||||
// Use new API for API 33+ (Android 13+), fall back to deprecated API for older versions
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
imageUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri.class);
|
||||
} else {
|
||||
// Deprecated but still works on older versions
|
||||
@SuppressWarnings("deprecation")
|
||||
java.util.ArrayList<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
||||
imageUris = uris;
|
||||
}
|
||||
if (imageUris != null && !imageUris.isEmpty()) {
|
||||
if (processSharedImage(imageUris.get(0), null)) {
|
||||
notifySharedPhotoDeepLink();
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the intent after handling to release URI permissions and prevent
|
||||
// network issues in WebView. This is critical for preventing the WebView
|
||||
// from losing network connectivity after processing shared content.
|
||||
if (handled) {
|
||||
intent.setAction(null);
|
||||
intent.setData(null);
|
||||
intent.removeExtra(Intent.EXTRA_STREAM);
|
||||
intent.setType(null);
|
||||
setIntent(new Intent());
|
||||
Log.d(TAG, "Cleared share intent after processing");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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()) {
|
||||
String path = imageUri.getPath();
|
||||
if (path != null) {
|
||||
int lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < path.length() - 1) {
|
||||
actualFileName = path.substring(lastSlash + 1);
|
||||
}
|
||||
}
|
||||
if (actualFileName == null || actualFileName.isEmpty()) {
|
||||
actualFileName = "shared-image.jpg";
|
||||
}
|
||||
}
|
||||
|
||||
// Use try-with-resources to ensure streams are properly closed
|
||||
// This is critical to prevent resource leaks that can affect WebView networking
|
||||
try (InputStream inputStream = getContentResolver().openInputStream(imageUri);
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
|
||||
|
||||
if (inputStream == null) {
|
||||
Log.e(TAG, "Failed to open input stream for shared image");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read image bytes
|
||||
byte[] data = new byte[8192];
|
||||
int nRead;
|
||||
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
|
||||
buffer.write(data, 0, nRead);
|
||||
}
|
||||
buffer.flush();
|
||||
byte[] imageBytes = buffer.toByteArray();
|
||||
|
||||
// Convert to base64
|
||||
String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP);
|
||||
|
||||
// Store in SharedPreferences for plugin to read
|
||||
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 boolean storeSharedImageInPreferences(String base64, String fileName) {
|
||||
try {
|
||||
SharedPreferences prefs = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = prefs.edit();
|
||||
editor.putString(KEY_BASE64, base64);
|
||||
editor.putString(KEY_FILE_NAME, fileName);
|
||||
editor.putBoolean(KEY_READY, true);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package app.timesafari;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
import org.timesafari.dailynotification.DailyNotificationPlugin;
|
||||
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||
|
||||
public class TimeSafariApplication extends Application {
|
||||
|
||||
private static final String TAG = "TimeSafariApplication";
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
Log.i(TAG, "Initializing TimeSafari notifications");
|
||||
|
||||
// Register native fetcher with application context
|
||||
Context context = getApplicationContext();
|
||||
NativeNotificationContentFetcher fetcher =
|
||||
new TimeSafariNativeFetcher(context);
|
||||
DailyNotificationPlugin.setNativeFetcher(fetcher);
|
||||
|
||||
Log.i(TAG, "Native fetcher registered");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package app.timesafari;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import org.timesafari.dailynotification.FetchContext;
|
||||
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||
import org.timesafari.dailynotification.NotificationContent;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Native content fetcher for API-driven daily notifications.
|
||||
* Calls Endorser.ch plansLastUpdatedBetween with configured credentials and
|
||||
* starred plan IDs (from plugin's updateStarredPlans), then returns notification content.
|
||||
*/
|
||||
public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
|
||||
|
||||
private static final String TAG = "TimeSafariNativeFetcher";
|
||||
private static final String ENDORSER_ENDPOINT = "/api/v2/report/plansLastUpdatedBetween";
|
||||
private static final int CONNECT_TIMEOUT_MS = 10000;
|
||||
private static final int READ_TIMEOUT_MS = 15000;
|
||||
private static final int MAX_RETRIES = 3;
|
||||
/** Max chars of response body logged at DEBUG (avoids huge log lines). */
|
||||
private static final int MAX_RESPONSE_BODY_LOG_CHARS = 4096;
|
||||
private static final int RETRY_DELAY_MS = 1000;
|
||||
|
||||
// Must match plugin's SharedPreferences name and keys (DailyNotificationPlugin / TimeSafariIntegrationManager)
|
||||
private static final String PREFS_NAME = "daily_notification_timesafari";
|
||||
private static final String KEY_STARRED_PLAN_IDS = "starredPlanIds";
|
||||
private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id";
|
||||
|
||||
private final Gson gson = new Gson();
|
||||
private final Context appContext;
|
||||
private final SharedPreferences prefs;
|
||||
|
||||
private volatile String apiBaseUrl;
|
||||
private volatile String activeDid;
|
||||
private volatile String jwtToken;
|
||||
/** Distinct JWTs from configureNativeFetcher `jwtTokens`; null = use jwtToken only. */
|
||||
@Nullable
|
||||
private List<String> jwtTokenPool;
|
||||
|
||||
public TimeSafariNativeFetcher(Context context) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
this.prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(String apiBaseUrl, String activeDid, String jwtToken) {
|
||||
configure(apiBaseUrl, activeDid, jwtToken, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(
|
||||
String apiBaseUrl,
|
||||
String activeDid,
|
||||
String jwtToken,
|
||||
@Nullable List<String> jwtTokenPool) {
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
this.activeDid = activeDid;
|
||||
this.jwtToken = jwtToken;
|
||||
this.jwtTokenPool =
|
||||
jwtTokenPool != null && !jwtTokenPool.isEmpty()
|
||||
? new ArrayList<>(jwtTokenPool)
|
||||
: null;
|
||||
int starredCount = getStarredPlanIds().size();
|
||||
Log.i(
|
||||
TAG,
|
||||
"Configured with API: "
|
||||
+ apiBaseUrl
|
||||
+ ", starredPlanIds count="
|
||||
+ starredCount
|
||||
+ (this.jwtTokenPool != null
|
||||
? ", jwtPoolSize=" + this.jwtTokenPool.size()
|
||||
: ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()) {
|
||||
return jwtToken;
|
||||
}
|
||||
long epochDay = System.currentTimeMillis() / (24L * 60 * 60 * 1000);
|
||||
int idx = (int) (epochDay % pool.size());
|
||||
String t = pool.get(idx);
|
||||
if (t == null || t.isEmpty()) {
|
||||
return jwtToken;
|
||||
}
|
||||
Log.i(TAG, "Bearer from JWT pool: index=" + idx + " of " + pool.size());
|
||||
return t;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public CompletableFuture<List<NotificationContent>> fetchContent(@NonNull FetchContext fetchContext) {
|
||||
Long scheduled = fetchContext.scheduledTime;
|
||||
Log.i(
|
||||
TAG,
|
||||
"fetchContent START trigger="
|
||||
+ fetchContext.trigger
|
||||
+ " scheduledTime="
|
||||
+ (scheduled != null ? scheduled : "null")
|
||||
+ " callerThread="
|
||||
+ Thread.currentThread().getName());
|
||||
Log.d(TAG, "Fetching notification content, trigger: " + fetchContext.trigger);
|
||||
return fetchContentWithRetry(fetchContext, 0);
|
||||
}
|
||||
|
||||
private CompletableFuture<List<NotificationContent>> fetchContentWithRetry(
|
||||
@NonNull FetchContext context, int retryCount) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
Log.i(TAG, "fetchContent worker thread=" + Thread.currentThread().getName());
|
||||
String bearer = selectBearerTokenForRequest();
|
||||
if (apiBaseUrl == null || activeDid == null || bearer == null || bearer.isEmpty()) {
|
||||
Log.e(TAG, "Not configured. Call configureNativeFetcher() from TypeScript first.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
String urlString = apiBaseUrl + ENDORSER_ENDPOINT;
|
||||
URL url = new URL(urlString);
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(READ_TIMEOUT_MS);
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.setRequestProperty("Authorization", "Bearer " + bearer);
|
||||
connection.setDoOutput(true);
|
||||
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
List<String> planIds = getStarredPlanIds();
|
||||
requestBody.put("planIds", planIds);
|
||||
String afterId = getLastAcknowledgedJwtId();
|
||||
if (afterId == null || afterId.isEmpty()) {
|
||||
afterId = "0";
|
||||
}
|
||||
requestBody.put("afterId", afterId);
|
||||
Log.i(
|
||||
TAG,
|
||||
"POST "
|
||||
+ ENDORSER_ENDPOINT
|
||||
+ " planCount="
|
||||
+ planIds.size()
|
||||
+ " afterId="
|
||||
+ (afterId.length() > 12 ? afterId.substring(0, 12) + "…" : afterId));
|
||||
|
||||
String jsonBody = gson.toJson(requestBody);
|
||||
try (OutputStream os = connection.getOutputStream()) {
|
||||
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
|
||||
os.write(input, 0, input.length);
|
||||
}
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
Log.i(TAG, "HTTP response code: " + responseCode);
|
||||
|
||||
if (responseCode == 200) {
|
||||
StringBuilder response = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
}
|
||||
String responseBody = response.toString();
|
||||
String snippet =
|
||||
responseBody.length() <= MAX_RESPONSE_BODY_LOG_CHARS
|
||||
? responseBody
|
||||
: responseBody.substring(0, MAX_RESPONSE_BODY_LOG_CHARS) + "…";
|
||||
Log.d(
|
||||
TAG,
|
||||
"plansLastUpdatedBetween response len="
|
||||
+ responseBody.length()
|
||||
+ " body="
|
||||
+ snippet);
|
||||
List<NotificationContent> contents = parseApiResponse(responseBody, context);
|
||||
if (!contents.isEmpty()) {
|
||||
updateLastAckedJwtIdFromResponse(responseBody);
|
||||
}
|
||||
Log.i(TAG, "Fetched " + contents.size() + " notification(s)");
|
||||
return contents;
|
||||
}
|
||||
|
||||
if (retryCount < MAX_RETRIES && (responseCode >= 500 || responseCode == 429)) {
|
||||
int delayMs = RETRY_DELAY_MS * (1 << retryCount);
|
||||
String errBody = readHttpErrorBodySnippet(connection);
|
||||
Log.w(
|
||||
TAG,
|
||||
"Retryable error "
|
||||
+ responseCode
|
||||
+ (errBody.isEmpty() ? "" : " body: " + errBody)
|
||||
+ ", retrying in "
|
||||
+ delayMs
|
||||
+ "ms");
|
||||
try {
|
||||
Thread.sleep(delayMs);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return fetchContentWithRetry(context, retryCount + 1).join();
|
||||
}
|
||||
|
||||
String errBody = readHttpErrorBodySnippet(connection);
|
||||
if (errBody.isEmpty()) {
|
||||
Log.e(TAG, "API error " + responseCode);
|
||||
} else {
|
||||
Log.e(TAG, "API error " + responseCode + " body: " + errBody);
|
||||
}
|
||||
return Collections.emptyList();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Fetch failed", e);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
try {
|
||||
Thread.sleep(RETRY_DELAY_MS * (1 << retryCount));
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return fetchContentWithRetry(context, retryCount + 1).join();
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads error response body for logging (HttpURLConnection puts 4xx/5xx bodies on
|
||||
* {@link HttpURLConnection#getErrorStream()}).
|
||||
*/
|
||||
private static String readHttpErrorBodySnippet(HttpURLConnection connection) {
|
||||
InputStream stream = connection.getErrorStream();
|
||||
if (stream == null) {
|
||||
return "";
|
||||
}
|
||||
final int maxChars = 4096;
|
||||
try (BufferedReader reader =
|
||||
new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append('\n');
|
||||
}
|
||||
if (sb.length() + line.length() > maxChars) {
|
||||
sb.append(line, 0, Math.max(0, maxChars - sb.length()));
|
||||
sb.append("…");
|
||||
break;
|
||||
}
|
||||
sb.append(line);
|
||||
}
|
||||
return sb.toString().trim();
|
||||
} catch (IOException e) {
|
||||
return "(read error body failed: " + e.getMessage() + ")";
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> getStarredPlanIds() {
|
||||
try {
|
||||
String idsJson = prefs.getString(KEY_STARRED_PLAN_IDS, "[]");
|
||||
if (idsJson == null || idsJson.isEmpty() || "[]".equals(idsJson)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
JsonArray arr = JsonParser.parseString(idsJson).getAsJsonArray();
|
||||
List<String> list = new ArrayList<>();
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
list.add(arr.get(i).getAsString());
|
||||
}
|
||||
return list;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error loading starred plan IDs", e);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private String getLastAcknowledgedJwtId() {
|
||||
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;
|
||||
String jwtId = null;
|
||||
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) {
|
||||
prefs.edit().putString(KEY_LAST_ACKED_JWT_ID, jwtId).apply();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Could not extract JWT ID from response", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display title for a plansLastUpdatedBetween row; prefers {@code plan.name}, else "Unnamed Project".
|
||||
*/
|
||||
private String extractProjectDisplayTitle(JsonObject item) {
|
||||
if (item.has("plan")) {
|
||||
JsonObject plan = item.getAsJsonObject("plan");
|
||||
if (plan.has("name") && !plan.get("name").isJsonNull()) {
|
||||
String name = plan.get("name").getAsString();
|
||||
if (name != null && !name.trim().isEmpty()) {
|
||||
return name.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Unnamed Project";
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String extractJwtIdFromItem(JsonObject item) {
|
||||
if (item.has("plan")) {
|
||||
JsonObject plan = item.getAsJsonObject("plan");
|
||||
if (plan.has("jwtId") && !plan.get("jwtId").isJsonNull()) {
|
||||
return plan.get("jwtId").getAsString();
|
||||
}
|
||||
}
|
||||
if (item.has("jwtId") && !item.get("jwtId").isJsonNull()) {
|
||||
return item.get("jwtId").getAsString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<NotificationContent> parseApiResponse(String responseBody, FetchContext context) {
|
||||
List<NotificationContent> contents = new ArrayList<>();
|
||||
try {
|
||||
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
|
||||
JsonArray dataArray = root.has("data") ? root.getAsJsonArray("data") : null;
|
||||
if (dataArray == null || dataArray.size() == 0) {
|
||||
return contents;
|
||||
}
|
||||
|
||||
JsonObject firstItem = dataArray.get(0).getAsJsonObject();
|
||||
String firstTitle = extractProjectDisplayTitle(firstItem);
|
||||
String jwtId = extractJwtIdFromItem(firstItem);
|
||||
|
||||
NotificationContent content = new NotificationContent();
|
||||
content.setId("endorser_" + (jwtId != null ? jwtId : ("batch_" + System.currentTimeMillis())));
|
||||
int n = dataArray.size();
|
||||
String quotedFirst = "\u201C" + firstTitle + "\u201D";
|
||||
if (n == 1) {
|
||||
content.setTitle("Starred Project Update");
|
||||
content.setBody(quotedFirst + " has been updated.");
|
||||
} else {
|
||||
content.setTitle("Starred Project Updates");
|
||||
int more = n - 1;
|
||||
content.setBody(quotedFirst + " + " + more + " more have been updated.");
|
||||
}
|
||||
content.setScheduledTime(
|
||||
context.scheduledTime != null
|
||||
? context.scheduledTime
|
||||
: (System.currentTimeMillis() + 3600000));
|
||||
content.setPriority("default");
|
||||
content.setSound(true);
|
||||
contents.add(content);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error parsing API response", e);
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package app.timesafari.notifications;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
@CapacitorPlugin(name = "NotificationInspector")
|
||||
public class NotificationInspectorPlugin extends Plugin {
|
||||
@PluginMethod
|
||||
public void getPendingNotifications(PluginCall call) {
|
||||
call.unimplemented(
|
||||
"Pending notification inspection is currently implemented on iOS only");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package app.timesafari.sharedimage;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
@CapacitorPlugin(name = "SharedImage")
|
||||
public class SharedImagePlugin extends Plugin {
|
||||
|
||||
private static final String SHARED_PREFS_NAME = "shared_image";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Get shared image data from SharedPreferences
|
||||
* Returns base64 string and fileName, or null if no image exists
|
||||
* Clears the data after reading to prevent re-reading
|
||||
*/
|
||||
@PluginMethod
|
||||
public void getSharedImage(PluginCall call) {
|
||||
try {
|
||||
SharedPreferences prefs = getSharedPreferences();
|
||||
|
||||
String base64 = prefs.getString(KEY_BASE64, null);
|
||||
String fileName = prefs.getString(KEY_FILE_NAME, null);
|
||||
|
||||
if (base64 == null || fileName == null) {
|
||||
// No shared image exists - return null values (not an error)
|
||||
JSObject result = new JSObject();
|
||||
result.put("base64", (String) null);
|
||||
result.put("fileName", (String) null);
|
||||
call.resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the shared data after reading
|
||||
SharedPreferences.Editor editor = prefs.edit();
|
||||
editor.remove(KEY_BASE64);
|
||||
editor.remove(KEY_FILE_NAME);
|
||||
editor.remove(KEY_READY);
|
||||
editor.apply();
|
||||
|
||||
// Return the shared image data
|
||||
JSObject result = new JSObject();
|
||||
result.put("base64", base64);
|
||||
result.put("fileName", fileName);
|
||||
call.resolve(result);
|
||||
} catch (Exception e) {
|
||||
android.util.Log.e("SharedImagePlugin", "Error in getSharedImage()", e);
|
||||
call.reject("Error getting shared image: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if shared image exists without reading it
|
||||
* Useful for quick checks before calling getSharedImage()
|
||||
*/
|
||||
@PluginMethod
|
||||
public void hasSharedImage(PluginCall call) {
|
||||
SharedPreferences prefs = getSharedPreferences();
|
||||
boolean hasImage = prefs.contains(KEY_BASE64) && prefs.contains(KEY_FILE_NAME);
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("hasImage", hasImage);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SharedPreferences instance for shared image data
|
||||
*/
|
||||
private SharedPreferences getSharedPreferences() {
|
||||
Context context = getContext();
|
||||
if (context == null) {
|
||||
throw new IllegalStateException("Plugin context is null");
|
||||
}
|
||||
return context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -22,6 +22,9 @@ allprojects {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
// Note: KAPT JVM arguments for Java 17+ compatibility are configured in gradle.properties
|
||||
// The org.gradle.jvmargs setting includes --add-opens flags needed for KAPT
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
|
||||
@@ -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 = [:]
|
||||
|
||||
@@ -20,6 +20,12 @@ project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacito
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-preferences'
|
||||
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
||||
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
@@ -28,3 +34,6 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
|
||||
|
||||
include ':capawesome-capacitor-file-picker'
|
||||
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')
|
||||
|
||||
include ':timesafari-daily-notification-plugin'
|
||||
project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
# Added --add-opens flags for KAPT compatibility with Java 17+
|
||||
org.gradle.jvmargs=-Xmx1536m --add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
@@ -21,3 +22,8 @@ org.gradle.jvmargs=-Xmx1536m
|
||||
# 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 = 22
|
||||
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"
|
||||
},
|
||||
|
||||
+36
-6
@@ -2,7 +2,7 @@ import { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'app.timesafari',
|
||||
appName: 'TimeSafari',
|
||||
appName: 'Giftopia',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
cleartext: true
|
||||
@@ -18,6 +18,9 @@ const config: CapacitorConfig = {
|
||||
]
|
||||
}
|
||||
},
|
||||
PushNotifications: {
|
||||
presentationOptions: ['badge', 'sound', 'alert']
|
||||
},
|
||||
SplashScreen: {
|
||||
launchShowDuration: 3000,
|
||||
launchAutoHide: true,
|
||||
@@ -31,19 +34,46 @@ 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
|
||||
},
|
||||
DailyNotification: {
|
||||
debugMode: true,
|
||||
enableNotifications: true,
|
||||
timesafariConfig: {
|
||||
activeDid: '', // Will be set dynamically from user's DID
|
||||
endpoints: {
|
||||
projectsLastUpdated: 'https://api.endorser.ch/api/v2/report/plansLastUpdatedBetween'
|
||||
},
|
||||
starredProjectsConfig: {
|
||||
enabled: true,
|
||||
starredPlanHandleIds: [],
|
||||
fetchInterval: '0 8 * * *'
|
||||
}
|
||||
},
|
||||
networkConfig: {
|
||||
timeout: 30000,
|
||||
retryAttempts: 3,
|
||||
retryDelay: 1000
|
||||
},
|
||||
contentFetch: {
|
||||
enabled: true,
|
||||
schedule: '0 8 * * *',
|
||||
fetchLeadTimeMinutes: 5
|
||||
}
|
||||
}
|
||||
},
|
||||
ios: {
|
||||
@@ -75,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).
|
||||
@@ -27,7 +27,7 @@ Large Components (>500 lines): 5 components (12.5%)
|
||||
├── GiftedDialog.vue (670 lines) ⚠️ HIGH PRIORITY
|
||||
├── PhotoDialog.vue (669 lines) ⚠️ HIGH PRIORITY
|
||||
├── PushNotificationPermission.vue (660 lines) ⚠️ HIGH PRIORITY
|
||||
└── MembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
|
||||
└── MeetingMembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
|
||||
|
||||
Medium Components (200-500 lines): 12 components (30%)
|
||||
├── GiftDetailsStep.vue (450 lines)
|
||||
@@ -0,0 +1,120 @@
|
||||
# Daily Notification Bugs — Diagnosis (Plugin + App)
|
||||
|
||||
**Context:** Fixes were applied in both the plugin and the app, but "reset doesn't fire" and "notification text defaults to fallback" still occur. This doc summarizes what was checked and what to do next.
|
||||
|
||||
---
|
||||
|
||||
## What Was Verified
|
||||
|
||||
### App integration (correct)
|
||||
|
||||
- **NativeNotificationService.ts**
|
||||
- Pre-cancel is gated: only iOS calls `cancelDailyReminder()` before scheduling (lines 289–305). Android skips it.
|
||||
- Schedules with `id: this.reminderId` (`"daily_timesafari_reminder"`), plus `time`, `title`, `body`.
|
||||
- Calls `DailyNotification.scheduleDailyNotification(scheduleOptions)` (not `scheduleDailyReminder`).
|
||||
|
||||
- **AccountViewView.vue**
|
||||
- `editReminderNotification()` only calls `cancelDailyNotification()` when **not** Android (lines 1303–1305). On Android it only calls `scheduleDailyNotification()`.
|
||||
|
||||
So the app is not double-cancelling on Android and is passing the expected options.
|
||||
|
||||
### Plugin in app’s node_modules (fixed code present)
|
||||
|
||||
- **node_modules/@timesafari/daily-notification-plugin** is at **version 1.1.4** and contains:
|
||||
- **NotifyReceiver.kt:** DB idempotence is skipped when `skipPendingIntentIdempotence=true` (wrapped in `if (!skipPendingIntentIdempotence)`).
|
||||
- **DailyNotificationWorker.java:** `preserveStaticReminder` read from input, stable `scheduleId` for static reminders, and `scheduleExactNotification(..., preserveStaticReminder, ...)`.
|
||||
- **DailyNotificationPlugin.kt:** `cancelDailyReminder(call)` implemented.
|
||||
|
||||
So the **source** the app uses (from its dependency) already has the fixes.
|
||||
|
||||
### Plugin schedule path (correct)
|
||||
|
||||
- App calls `scheduleDailyNotification` → plugin’s `scheduleDailyNotification(call)` → `ScheduleHelper.scheduleDailyNotification(...)`.
|
||||
- That helper calls `NotifyReceiver.cancelNotification(context, scheduleId)` then `scheduleExactNotification(..., skipPendingIntentIdempotence = true)`.
|
||||
- So the “re-set” path does set `skipPendingIntentIdempotence = true` and the DB idempotence skip should apply.
|
||||
|
||||
---
|
||||
|
||||
## Likely Causes Why Bugs Still Appear
|
||||
|
||||
### 1. Stale Android build / old APK
|
||||
|
||||
The Android app compiles the plugin from:
|
||||
|
||||
`android/capacitor.settings.gradle` →
|
||||
`project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')`
|
||||
|
||||
If the app was not fully rebuilt after the plugin in node_modules was updated, the running APK may still contain old plugin code.
|
||||
|
||||
**Do this:**
|
||||
|
||||
- In the **app** repo (`crowd-funder-for-time-pwa`):
|
||||
- `./gradlew clean` (or Android Studio → Build → Clean Project)
|
||||
- Build and reinstall the app (e.g. Run on device/emulator).
|
||||
- Confirm you’re not installing an older APK from somewhere else.
|
||||
|
||||
### 2. Dependency not actually updated after plugin changes
|
||||
|
||||
The app depends on:
|
||||
|
||||
```json
|
||||
"@timesafari/daily-notification-plugin": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master"
|
||||
```
|
||||
|
||||
If the fixes were only made in a **local clone** and never pushed to **gitea** `master`, then:
|
||||
|
||||
- `npm install` / `npm update` in the app would not pull the fixes.
|
||||
- The app’s `node_modules` would only have the fixes if they were copied/linked from the fixed repo.
|
||||
|
||||
**Do this:**
|
||||
|
||||
- **Push** the fixed plugin to the official gitea repo (`trent_larson/daily-notification-plugin`), then in this app run `npm update @timesafari/daily-notification-plugin` (or set `package.json` to the branch/tag/commit you need), `npm install`, `npx cap sync android`, clean build and reinstall. The app should always depend on the published git remote, not a local `file:` path.
|
||||
|
||||
### 3. Fallback text from native fetcher (Bug 2 only)
|
||||
|
||||
**TimeSafariNativeFetcher.java** in the app is still a placeholder: it always returns:
|
||||
|
||||
- Title: `"TimeSafari Update"`
|
||||
- Body: `"Check your starred projects for updates!"`
|
||||
|
||||
That only affects flows that **fetch** content (e.g. prefetch or any path that uses the fetcher for display). The **static** daily reminder path does not use the fetcher for display: title/body come from the schedule Intent and WorkManager input. So if you only use the “daily reminder” (one time + custom title/body), the fetcher placeholder should not be the cause. If you have any flow that relies on **fetched** content for the text, you’ll see that placeholder until the fetcher is implemented and wired (and optionally token persistence).
|
||||
|
||||
---
|
||||
|
||||
## Verification Steps (after clean build + reinstall)
|
||||
|
||||
1. **Reset / “re-set” (Bug 1)**
|
||||
- Set reminder for 2–3 minutes from now.
|
||||
- Edit and save **without changing the time**.
|
||||
- Wait for the time; the notification should fire.
|
||||
- In logcat, filter by the plugin’s tags and look for:
|
||||
- `Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=...`
|
||||
- `Scheduling next daily alarm: id=daily_timesafari_reminder ...`
|
||||
If you see these, the fixed path is running.
|
||||
|
||||
2. **Static text on rollover (Bug 2)**
|
||||
- Set a custom title/body, let the notification fire once.
|
||||
- In logcat look for:
|
||||
- `DN|ROLLOVER next=... scheduleId=daily_timesafari_reminder static=true`
|
||||
If you see `static=true` and the same `scheduleId`, the next occurrence should keep your custom text.
|
||||
|
||||
3. **Plugin version at build time**
|
||||
- In the app’s `node_modules/@timesafari/daily-notification-plugin/package.json`, confirm `"version": "1.1.4"` (or the version that includes the fixes).
|
||||
- After that, a clean build ensures that version is what’s in the APK.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Check | Status |
|
||||
|-------|--------|
|
||||
| App gates cancel on Android | OK |
|
||||
| App calls scheduleDailyNotification with id/title/body | OK |
|
||||
| Plugin in app node_modules has DB idempotence skip | OK (1.1.4) |
|
||||
| Plugin in app node_modules has static rollover fix | OK |
|
||||
| Plugin in app node_modules has cancelDailyReminder | OK |
|
||||
| Schedule path passes skipPendingIntentIdempotence = true | OK |
|
||||
|
||||
**See also:** `doc/plugin-feedback-android-rollover-double-fire-and-user-content.md` — when two notifications fire (e.g. one ~3 min early, one on the dot) and neither shows user-set content.
|
||||
|
||||
Most likely the app is still running an **old Android build**. Do a **clean build and reinstall**, and ensure the plugin dependency in the app really points at the fixed code (gitea master or local path). Then re-test and check logcat for the lines above. If the bugs persist after that, the next step is to capture a full logcat from “edit reminder (same time)” through the next fire and from “first fire” through “next day” to see which path runs.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Daily Notification: Why Extra Notifications With Fallback / "Starred Projects" Still Fire
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Context:** After previous fixes (see `DAILY_NOTIFICATION_BUG_DIAGNOSIS.md` and `plugin-feedback-android-rollover-double-fire-and-user-content.md`), duplicate notifications and fallback/"starred projects" text still occur. This doc explains root causes and where fixes must happen.
|
||||
|
||||
---
|
||||
|
||||
## Summary of What’s Happening
|
||||
|
||||
1. **Extra notification(s)** fire at a different time (e.g. ~3 min early) or at the same time as the user-set one.
|
||||
2. **Wrong text** appears: either generic fallback ("Daily Update" / "Good morning! Ready to make today amazing?") or the app’s placeholder ("TimeSafari Update" / "Check your starred projects for updates!").
|
||||
3. The **correct** notification (user-set time and message) can still fire as well, so the user sees both correct and wrong notifications.
|
||||
|
||||
---
|
||||
|
||||
## Root Causes
|
||||
|
||||
### 1. Second alarm from prefetch (UUID / fallback)
|
||||
|
||||
**Mechanism**
|
||||
|
||||
- The plugin has two scheduling paths:
|
||||
- **NotifyReceiver** (AlarmManager): used for the app’s single daily reminder; uses `scheduleId` (e.g. `daily_timesafari_reminder`) and carries title/body in the Intent.
|
||||
- **DailyNotificationScheduler** (legacy): used by **DailyNotificationFetchWorker** when prefetch runs and then calls `scheduleNotificationIfNeeded(fallbackContent)`. That creates a **second** alarm with `notification_id` = **UUID** (from `createEmergencyFallbackContent()` or from fetcher placeholder).
|
||||
|
||||
- **ScheduleHelper** correctly **does not** enqueue prefetch for static reminders (see comment in `DailyNotificationPlugin.kt` ~2686: "Do not enqueue prefetch for static reminders"). So **new** schedules from the app no longer create a prefetch job.
|
||||
|
||||
- However:
|
||||
- **Existing** WorkManager prefetch jobs (tag `daily_notification_fetch`) that were enqueued **before** that fix (or by an older build) are still pending. When they run, fetch fails or returns placeholder → `useFallbackContent()` → `scheduleNotificationIfNeeded(fallbackContent)` → **second alarm with UUID**.
|
||||
- That UUID alarm is **not** stored in the Schedule table. So when the user later calls `scheduleDailyNotification`, **cleanupExistingNotificationSchedules** only cancels alarms for schedule IDs that exist in the DB (e.g. `daily_timesafari_reminder`, `daily_rollover_*`). The **UUID alarm is never cancelled**.
|
||||
|
||||
- **Result:** You can have two alarms: one for `daily_timesafari_reminder` (correct) and one for a UUID (fallback text). If the UUID alarm was set for a slightly different time (e.g. from an old rollover), you get two notifications at two times.
|
||||
|
||||
**Where the fallback text comes from (plugin)**
|
||||
|
||||
- **DailyNotificationFetchWorker** (in both app’s `node_modules` plugin and the standalone repo):
|
||||
- On failed fetch after max retries: `useFallbackContent(scheduledTime)` → `createEmergencyFallbackContent(scheduledTime)` → title "Daily Update", body "🌅 Good morning! Ready to make today amazing?".
|
||||
- That content is saved and then **scheduled** via `scheduleNotificationIfNeeded(fallbackContent)`, which uses **DailyNotificationScheduler** (legacy) and assigns a **new UUID** to the content. So the second alarm fires with that UUID and shows that fallback text.
|
||||
|
||||
### 2. Prefetch WorkManager jobs not cancelled when user reschedules
|
||||
|
||||
- **scheduleDailyNotification** (plugin) calls:
|
||||
- `ScheduleHelper.cleanupExistingNotificationSchedules(...)` → cancels **alarms** for all DB schedules (except current `scheduleId`).
|
||||
- `ScheduleHelper.scheduleDailyNotification(...)` → cancels alarm for current `scheduleId`, schedules NotifyReceiver alarm, **does not** enqueue prefetch.
|
||||
|
||||
- It does **not** cancel **WorkManager** jobs. So any already-enqueued prefetch work (tag `daily_notification_fetch`) remains. When that work runs, it creates the second (UUID) alarm as above.
|
||||
|
||||
- **ScheduleHelper** has `cancelAllWorkManagerJobs(context)` (cancels tags `prefetch`, `daily_notification_fetch`, etc.), but **nothing calls it** in the schedule path. So pending prefetch jobs are left in place.
|
||||
|
||||
**Fix (plugin):** When the app calls `scheduleDailyNotification`, **cancel all fetch-related WorkManager work** (e.g. call `ScheduleHelper.cancelAllWorkManagerJobs(context)` or a helper that only cancels `daily_notification_fetch` and `prefetch`) **before** or **right after** `cleanupExistingNotificationSchedules`. That prevents any pending prefetch from running and creating a UUID alarm later.
|
||||
|
||||
### 3. "Starred projects" message from the app’s native fetcher
|
||||
|
||||
- **TimeSafariNativeFetcher** (`android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`) is still a **placeholder**: it always returns:
|
||||
- Title: `"TimeSafari Update"`
|
||||
- Body: `"Check your starred projects for updates!"`
|
||||
|
||||
- That text is used whenever the plugin **fetches** content and then displays it:
|
||||
- **DailyNotificationFetchWorker**: on “successful” fetch it saves and schedules the fetcher’s result; for your app that result is the placeholder, so any notification created from that path shows “starred projects”.
|
||||
- **DailyNotificationWorker** (JIT path): when `is_static_reminder` is false and content is loaded from Room by `notification_id`, if the worker then does a JIT refresh (e.g. content stale), it calls `DailyNotificationFetcher.fetchContentImmediately()` which can use the app’s native fetcher and **overwrite** title/body with the placeholder.
|
||||
|
||||
- So “starred projects” appears on any notification that goes through a **fetch** path (prefetch success or JIT) instead of the **static reminder** path (Intent title/body or Room by canonical `schedule_id`).
|
||||
|
||||
**Fix (app):** For a static-reminder-only flow, the plugin should not run prefetch (already done) and should not overwrite with fetcher in JIT for static reminders. Reducing duplicate/out-of-schedule alarms (fixes above) ensures the main run is the static one. Optionally, implement **TimeSafariNativeFetcher** to return real content if you ever want “fetch-based” notifications; until then, the only path that should show user text is the NotifyReceiver alarm with `daily_timesafari_reminder` and title/body from Intent or from Room by `schedule_id`.
|
||||
|
||||
### 4. Rollover / Room content keyed by run-specific id
|
||||
|
||||
- When an alarm fires with `notification_id` = **UUID** or **notify_<timestamp>** (and no or missing title/body in the Intent), the Worker treats it as **non-static**. It loads content from Room by that `notification_id`. The entity for `daily_timesafari_reminder` (user title/body) is stored under a **different** id, so the Worker either finds nothing or finds content written by prefetch/fallback for that run → wrong text.
|
||||
|
||||
- When the alarm is the **correct** one (`daily_timesafari_reminder`) and Intent has title/body (or `schedule_id`), the Worker uses static reminder or resolves by `schedule_id` and shows user text. So the main fix is to **avoid creating the UUID/notify_* run in the first place** (cancel prefetch work; no second alarm). Rollover for the static reminder already passes `scheduleId` and title/body in the Intent (NotifyReceiver puts them in the PendingIntent), so once there’s only one alarm, rollover should keep user text.
|
||||
|
||||
---
|
||||
|
||||
## Where Fixes Must Happen
|
||||
|
||||
### Plugin (daily-notification-plugin)
|
||||
|
||||
**1. Cancel prefetch (and related) WorkManager jobs when scheduling**
|
||||
|
||||
- **File:** `DailyNotificationPlugin.kt` (or wherever `scheduleDailyNotification` is implemented).
|
||||
- **Change:** When handling `scheduleDailyNotification`, after `cleanupExistingNotificationSchedules` and before (or after) `ScheduleHelper.scheduleDailyNotification`, call a method that cancels all WorkManager work that can create a second alarm. Prefer reusing **ScheduleHelper.cancelAllWorkManagerJobs(context)** or adding a small helper that cancels only fetch-related tags (e.g. `daily_notification_fetch`, `prefetch`) so you don’t cancel display/dismiss work unnecessarily.
|
||||
- **Effect:** Pending prefetch jobs from older builds or previous flows will not run, so no new UUID alarm is created and no extra notification with fallback text.
|
||||
|
||||
**2. (Already done) Do not enqueue prefetch for static reminders**
|
||||
|
||||
- **ScheduleHelper.scheduleDailyNotification** already does **not** enqueue FetchWorker for static reminders. No change needed here; just ensure no other code path enqueues prefetch for the app’s single daily reminder.
|
||||
|
||||
**3. (Optional) DailyNotificationFetchWorker: skip scheduling second alarm for static-reminder schedules**
|
||||
|
||||
- If you ever enqueue prefetch with an explicit “static reminder” flag, in **DailyNotificationFetchWorker** inside `useFallbackContent` / `scheduleNotificationIfNeeded`, skip calling `scheduleNotificationIfNeeded` when that flag is set. For your current setup (no prefetch for static), this is redundant but makes the contract clear and future-proof.
|
||||
|
||||
**4. Receiver: no DB on main thread**
|
||||
|
||||
- Your **DailyNotificationReceiver** in the app’s plugin only reads Intent extras and enqueues work; it does not read Room on the main thread. If you still see `db_fallback_failed` in logcat, the failing DB access is elsewhere (e.g. another receiver or an old build). Ensure no BroadcastReceiver does Room/DB access on the main thread; resolve title/body in the Worker from `schedule_id` if Intent lacks them.
|
||||
|
||||
### App (crowd-funder-for-time-pwa)
|
||||
|
||||
**Scope: static reminders only.** For fixing static reminders, **no app code changes are required.** Real fetch-based content can be added later.
|
||||
|
||||
**1. TimeSafariNativeFetcher**
|
||||
|
||||
- **File:** `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`
|
||||
- **Current behavior:** Placeholder that returns `"TimeSafari Update"` / `"Check your starred projects for updates!"` (expected).
|
||||
- **For static reminders now:** Leave as-is. The plugin fix (cancel prefetch work when scheduling) ensures the only notification path is the static one; the fetcher is never used for display in that flow. No change needed.
|
||||
- **Later (optional):** When you implement real-world content fetching, replace the placeholder here so any future fetch-driven notifications show real content.
|
||||
|
||||
**2. Build and dependency**
|
||||
|
||||
- After plugin changes, ensure the app uses the updated plugin (point `package.json` at the fixed repo or publish and bump version), then **clean build** Android (`./gradlew clean`, rebuild, reinstall). Confirming the APK contains the plugin version that cancels prefetch work and does not enqueue prefetch for static reminders avoids stale behavior from old builds.
|
||||
|
||||
---
|
||||
|
||||
## Verification After Fixes
|
||||
|
||||
1. **Single notification, user text**
|
||||
- Set daily reminder with a **distinct** title/body and a time 2–3 minutes ahead. Wait until that time.
|
||||
- **Expect:** Exactly **one** notification at that time with your text. No second notification (no UUID, no “Daily Update” or “starred projects”).
|
||||
|
||||
2. **No out-of-schedule notification**
|
||||
- Change reminder time (e.g. from 21:53 to 21:56) and save. Wait past 21:53 and until 21:56.
|
||||
- **Expect:** No notification at 21:53; one at 21:56 with your text.
|
||||
|
||||
3. **Rollover**
|
||||
- Let the correct notification fire once so rollover runs. Next day (or next occurrence) you should see **one** notification with the same user text.
|
||||
|
||||
4. **Logcat**
|
||||
- No `display=<uuid>` at the same time as `static_reminder id=daily_timesafari_reminder`.
|
||||
- After scheduling (e.g. edit and save), you should see prefetch/fetch work being cancelled if you add a log in the cancel path.
|
||||
|
||||
---
|
||||
|
||||
## Short Summary
|
||||
|
||||
| Issue | Cause | Fix location |
|
||||
|-------|--------|--------------|
|
||||
| Extra notification at same or different time | Prefetch WorkManager job still runs and creates second (UUID) alarm via legacy scheduler; that alarm is never cancelled on reschedule | **Plugin:** Cancel fetch-related WorkManager jobs when `scheduleDailyNotification` is called |
|
||||
| Fallback text ("Daily Update" / "Good morning!") | FetchWorker’s `useFallbackContent` → `scheduleNotificationIfNeeded` creates alarm with that content | **Plugin:** Same as above (no prefetch run → no fallback alarm); optionally FetchWorker skips scheduling when static-reminder flag set |
|
||||
| "Starred projects" text | TimeSafariNativeFetcher placeholder used when a fetch path runs | **Plugin:** Same as above (no prefetch → no fetch path). **App:** No change for static reminders; leave fetcher as placeholder until real fetch is implemented. |
|
||||
| Wrong content on rollover | Rollover run keyed by UUID or notify_* and no title/body in Intent → Worker loads from Room by that id → wrong/empty content | **Plugin:** Avoid creating UUID/notify_* run (cancel prefetch). Static rollover already passes schedule_id and title/body. |
|
||||
|
||||
The critical missing step is **cancelling prefetch (and fetch) WorkManager work when the user schedules or reschedules** the daily notification. That prevents any pending prefetch from running and creating the second alarm with fallback or “starred projects” text.
|
||||
|
||||
---
|
||||
|
||||
## For Cursor (plugin repo) — actionable handoff
|
||||
|
||||
Use this section when applying the fix in the **daily-notification-plugin** repo (e.g. with Cursor). Paste or @-mention this doc as context.
|
||||
|
||||
**Goal:** For static reminders, only one notification at the user's chosen time with user-set title/body. No extra notification from pending prefetch (UUID alarm with fallback or "starred projects" text).
|
||||
|
||||
**Root cause:** `scheduleDailyNotification` cleans up DB schedules and alarms but **does not cancel WorkManager prefetch jobs**. Any previously enqueued job (tag `daily_notification_fetch`) still runs, then creates a second alarm via `DailyNotificationScheduler` (UUID). That alarm is never cancelled on reschedule. Fix: cancel fetch-related WorkManager work when the user schedules.
|
||||
|
||||
**Change (required):**
|
||||
|
||||
1. **Cancel fetch-related WorkManager jobs when handling `scheduleDailyNotification`**
|
||||
- **File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt`
|
||||
- **Where:** In `scheduleDailyNotification(call)`, inside the `CoroutineScope(Dispatchers.IO).launch { ... }` block, **after** `ScheduleHelper.cleanupExistingNotificationSchedules(...)` and **before** `ScheduleHelper.scheduleDailyNotification(...)`.
|
||||
- **What:** Call a method that cancels WorkManager work that can create a second alarm. Reuse **ScheduleHelper.cancelAllWorkManagerJobs(context)** (it already cancels `prefetch`, `daily_notification_fetch`, etc.). If you prefer not to cancel display/dismiss work, add a helper that only cancels `daily_notification_fetch` and `prefetch` and call that instead.
|
||||
- **Example (using existing helper):**
|
||||
```kotlin
|
||||
ScheduleHelper.cancelAllWorkManagerJobs(context)
|
||||
```
|
||||
(If `cancelAllWorkManagerJobs` is suspend, call it with `runBlocking { }` or from the same coroutine scope.)
|
||||
|
||||
**No other plugin changes needed for this fix:** ScheduleHelper already does not enqueue prefetch for static reminders; the only missing step is cancelling **pending** prefetch work when the user schedules or reschedules.
|
||||
|
||||
**Files to look at (plugin Android):**
|
||||
- `DailyNotificationPlugin.kt` — `scheduleDailyNotification(call)` (add cancel call after cleanup, before ScheduleHelper.scheduleDailyNotification).
|
||||
- `ScheduleHelper` (in same file or separate) — `cancelAllWorkManagerJobs(context)` (already exists; ensure it cancels at least `daily_notification_fetch` and `prefetch`).
|
||||
+41
-3
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# TimeSafari — Daily notifications troubleshooting (iOS & Android)
|
||||
|
||||
**Last updated:** 2026-03-06 17:08 PST
|
||||
**Audience:** End-users
|
||||
**Applies to:** TimeSafari iOS/Android native app (daily notifications scheduled on-device)
|
||||
|
||||
If your **Daily Reminder** or notification doesn’t show up, follow the steps below.
|
||||
|
||||
## Before you start
|
||||
|
||||
- These notifications are **scheduled on your device** (no browser/web push).
|
||||
- If you previously followed an older “web notifications” guide, those steps no longer apply for iOS/Android builds.
|
||||
|
||||
## 1) Check your in-app notification settings
|
||||
|
||||
- Tap **Profile** in the bottom bar
|
||||
- Under **Notifications**, confirm:
|
||||
- **Daily Reminder** is **enabled**
|
||||
- The **time** is set correctly
|
||||
- The message looks correct
|
||||
- If it’s already enabled, try to:
|
||||
- Turn it **off**
|
||||
- Turn it **on** again
|
||||
- Re-set the time and message
|
||||
|
||||
## 2) iOS troubleshooting
|
||||
|
||||
### Allow notifications for TimeSafari
|
||||
|
||||
1. Open **Settings** → **Notifications**
|
||||
2. Tap **TimeSafari**
|
||||
3. Turn **Allow Notifications** on
|
||||
4. Enable at least one delivery style (recommended):
|
||||
- **Lock Screen**
|
||||
- **Notification Center**
|
||||
- **Banners**
|
||||
5. Optional but helpful:
|
||||
- **Sounds** on (if you want an audible reminder)
|
||||
|
||||
### Focus / Do Not Disturb
|
||||
|
||||
If you’re using **Focus** or **Do Not Disturb**, notifications may be silenced or hidden.
|
||||
|
||||
- Open **Settings** → **Focus**
|
||||
- Check the active Focus mode and ensure **TimeSafari** is allowed (or temporarily disable Focus to test)
|
||||
|
||||
### After restarting your phone
|
||||
|
||||
If you recently restarted iOS and don’t see the notification, open **TimeSafari** once. (You don’t need to change anything.)
|
||||
|
||||
## 3) Android troubleshooting
|
||||
|
||||
### Allow notifications for TimeSafari
|
||||
|
||||
1. Open **Settings** → **Apps**
|
||||
2. Tap **TimeSafari** → **Manage notifications** (wording varies)
|
||||
3. Turn notifications **on**
|
||||
4. If Android shows notification categories/channels for the app, ensure the relevant channel is allowed.
|
||||
|
||||
### Battery / background restrictions
|
||||
|
||||
Battery optimization can delay or block scheduled notifications.
|
||||
|
||||
- Open **Settings** → **Apps** → **TimeSafari** → **Battery usage** (wording varies)
|
||||
- If available:
|
||||
- Set **Battery usage** to **Unrestricted**
|
||||
- Turn **Allow background usage** on
|
||||
- Disable optimization for TimeSafari
|
||||
- If your device has lists like **Sleeping apps** / **Restricted apps**, remove TimeSafari from them
|
||||
|
||||
### After restarting your phone
|
||||
|
||||
Depending on the device manufacturer, Android can clear scheduled notifications during a reboot. If you restarted recently:
|
||||
|
||||
- Open **TimeSafari** once (you don’t need to change anything)
|
||||
|
||||
## 4) If it still doesn’t work
|
||||
|
||||
- Ensure you’re on the latest TimeSafari app version.
|
||||
- If you denied permission earlier, re-enable notifications in system settings (above).
|
||||
- As a last resort, uninstall/reinstall the app (you’ll need to enable notifications again and reconfigure the daily reminder). **Important:** Before uninstalling, back up your identifier seed so you can import it back later: **Profile → Data Management → Backup Identifier Seed**.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Android plugin: New Activity notification when API has no activities
|
||||
|
||||
**Audience:** Maintainers of `@timesafari/daily-notification-plugin` (Android / Kotlin).
|
||||
**Host app:** TimeSafari (`crowd-funder-for-time-pwa`) — this file lives in the **app** repo only as a handoff; apply changes in the **plugin** repo.
|
||||
|
||||
**Problem (product):** “New Activity” should notify only when the API reports new/updated activity. The host’s native fetcher (`TimeSafariNativeFetcher`) returns an **empty** `List<NotificationContent>` when the API’s `data` array is empty. Users still see a **daily** local notification.
|
||||
|
||||
**Version note:** This diagnosis was first written against older plugin builds (e.g. **2.1.x / 2.2.x**). After upgrading the host to **`@timesafari/daily-notification-plugin` 3.0.0**, the Android files below were **re-read** from `node_modules`. The relevant logic is **unchanged** in 3.0.0: the same two mechanisms still explain unwanted daily notifications when the API returns no rows. If you maintain the plugin, re-verify after each major release.
|
||||
|
||||
**Root cause (Android, confirmed in plugin v3.0.0 sources under `node_modules`):** Two mechanisms interact:
|
||||
|
||||
1. **`FetchWorker.kt` — empty native fetch is converted to synthetic JSON instead of “skip”**
|
||||
When the dual prefetch runs with the native fetcher and the list is empty, `notificationContentsToDualPayloadBytes` **replaces** the empty list with a JSON payload `"No updates"` / `"No new content"`, and the work unit still completes successfully. The dual path then **always** arms the chained notify alarm when `isDual && nextNotifyAt > 0L` — so a notification is still scheduled for the notify window.
|
||||
|
||||
Reference (plugin):
|
||||
|
||||
```kotlin
|
||||
// FetchWorker.kt — notificationContentsToDualPayloadBytes (~371–374 in v3.0.0)
|
||||
if (contents.isEmpty()) {
|
||||
return """{"title":"No updates","body":"No new content"}""".toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// FetchWorker.kt — doWork(), tail of success path (~306–309 in v3.0.0)
|
||||
if (isDual && nextNotifyAt > 0L) {
|
||||
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
|
||||
DualScheduleFetchRecovery.enqueueFromPersistedConfig(applicationContext)
|
||||
}
|
||||
```
|
||||
|
||||
2. **`DualScheduleHelper.kt` — `fallbackBehavior: "show_default"` uses `userNotification` defaults**
|
||||
At display time, if there is **no** fresh dual-scope cache within `relationship.contentTimeout`, the helper falls back to the **persisted** `userNotification.title` / `userNotification.body` when `fallbackBehavior` is `"show_default"`. The host app sets those defaults to copy such as “New Activity” / “Check your starred projects…”, so the user sees that **even when the API had nothing**, if the cache path doesn’t supply something else.
|
||||
|
||||
Reference (plugin):
|
||||
|
||||
```kotlin
|
||||
// DualScheduleHelper.kt — resolveDualContentBlocking (simplified; ~31–57 in v3.0.0)
|
||||
val fallbackBehavior = relationship?.optString("fallbackBehavior", "show_default") ?: "show_default"
|
||||
val defaultTitle = userNotification.optString("title", "Daily Notification")
|
||||
val defaultBody = userNotification.optString("body", "Your daily update is ready")
|
||||
// ...
|
||||
} else {
|
||||
if (fallbackBehavior != "show_default") return null
|
||||
Pair(defaultTitle, defaultBody)
|
||||
}
|
||||
```
|
||||
|
||||
**TypeScript contract (plugin `src/definitions.ts` in v3.0.0 — `DualScheduleConfiguration.relationship`):**
|
||||
|
||||
```ts
|
||||
relationship?: {
|
||||
autoLink: boolean;
|
||||
contentTimeout: number;
|
||||
fallbackBehavior: 'skip' | 'show_default' | 'retry';
|
||||
};
|
||||
```
|
||||
|
||||
`skip` is only partially useful on Android **with the current fetch implementation**: it avoids the **default title/body** branch in `DualScheduleHelper` when cache is missing/stale, but it does **not** by itself stop a notification if the fetch path still materializes content (including the synthetic `"No updates"` payload) or if chained notify is already armed.
|
||||
|
||||
**3.0.0 vs 2.2.x:** Plugin **3.0.0** advertises broader features (e.g. TTL-at-fire, observability). Those do **not** replace the dual-fetch pipeline inspected here: `FetchWorker` still maps an empty native list to JSON and still schedules the chained notify on success; `DualScheduleHelper` still applies `show_default` vs defaults when cache is absent or outside `contentTimeout`. Revisit this doc if a future release changes `notificationContentsToDualPayloadBytes` or the dual notify gate.
|
||||
|
||||
---
|
||||
|
||||
## Recommended plugin changes (Android)
|
||||
|
||||
### 1) Treat empty native fetch as “no notification” (primary)
|
||||
|
||||
**File:** `android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt`
|
||||
|
||||
**Issue:** `notificationContentsToDualPayloadBytes` must not turn an empty list into a non-empty payload if the product contract is “no rows in API → no notification.”
|
||||
|
||||
**Direction:**
|
||||
|
||||
- **Before:** Empty list → JSON `No updates` / `No new content` → success → chained notify scheduled.
|
||||
- **After (one of):**
|
||||
- **A)** Return a dedicated sentinel payload (e.g. `{ "skipNotification": true }`) and teach **`NotifyReceiver` / worker** that resolves dual content to **not post** when that sentinel is present; **or**
|
||||
- **B)** On empty list, **do not** call `DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm` for this cycle (and optionally persist “last fetch had no content” for the helper); **or**
|
||||
- **C)** Store an empty/marker cache row that `DualScheduleHelper.resolveDualContentBlocking` interprets as “return null” (no notification).
|
||||
|
||||
Pick one strategy and keep behavior consistent with `relationship.fallbackBehavior`:
|
||||
|
||||
- If `fallbackBehavior == "skip"`: skip notification when fetch returns empty or when sentinel indicates skip.
|
||||
- If `fallbackBehavior == "show_default"`: keep current default-title/body behavior **only** when the product intends it (may be wrong for TimeSafari).
|
||||
|
||||
### 2) Honor `relationship.fallbackBehavior` end-to-end
|
||||
|
||||
**Files:** `FetchWorker.kt`, `DualScheduleHelper.kt`, any worker/receiver that posts the dual notification.
|
||||
|
||||
**Issue:** `DualScheduleHelper` reads `fallbackBehavior`, but the fetch path does not use the same semantics for “empty API result.”
|
||||
|
||||
**Direction:** When persisting dual config, pass `fallbackBehavior` into the fetch success path so that **empty fetch + `skip`** never schedules or displays a notification.
|
||||
|
||||
### 3) Tests
|
||||
|
||||
- Dual fetch + native fetcher returns **empty list** → **no** notification posted (or no chained alarm), matching host expectation.
|
||||
- Non-empty list → notification with fetcher-provided title/body.
|
||||
- Optional: `fallbackBehavior` matrix (`skip` / `show_default`) with stale cache vs fresh cache.
|
||||
|
||||
---
|
||||
|
||||
## Host app follow-up (separate PR in `crowd-funder-for-time-pwa`)
|
||||
|
||||
After the plugin implements empty-fetch semantics, set in `buildDualScheduleConfig` (`src/services/notifications/dualScheduleConfig.ts`):
|
||||
|
||||
```ts
|
||||
relationship: {
|
||||
autoLink: true,
|
||||
contentTimeout: 5 * 60 * 1000,
|
||||
fallbackBehavior: "skip", // was "show_default"
|
||||
},
|
||||
```
|
||||
|
||||
Only do this once Android behavior matches the contract (otherwise users may get **no** notification even when you would want defaults on network failure — product decision).
|
||||
|
||||
---
|
||||
|
||||
## References in this repo (context only)
|
||||
|
||||
- Host native fetcher returns no content when API `data` is empty: `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` (`parseApiResponse`).
|
||||
- Host dual config today uses `fallbackBehavior: "show_default"`: `src/services/notifications/dualScheduleConfig.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Plugin version verification
|
||||
|
||||
- **Last verified against:** `@timesafari/daily-notification-plugin` **3.0.0** (`node_modules/.../package.json`).
|
||||
- **Prior builds:** Behavior matched the earlier **2.1.x** analysis; **2.2.0 → 3.0.0** did not remove the empty-list → synthetic JSON mapping or the chained-notify success path in the inspected sources.
|
||||
- Re-verify line numbers after rebasing or patching the plugin repo.
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# Android API 23 Upgrade Impact Analysis
|
||||
|
||||
**Date:** 2025-12-03
|
||||
**Current minSdkVersion:** 22 (Android 5.1 Lollipop)
|
||||
**Proposed minSdkVersion:** 23 (Android 6.0 Marshmallow)
|
||||
**Impact Assessment:** Low to Moderate
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Upgrading from API 22 to API 23 will have **minimal code impact** but may affect device compatibility. The main change is that API 23 introduced runtime permissions, but since the app uses Capacitor plugins which handle permissions, the impact is minimal.
|
||||
|
||||
## Code Impact Analysis
|
||||
|
||||
### ✅ No Breaking Changes in Existing Code
|
||||
|
||||
#### 1. API Level Checks in Code
|
||||
All existing API level checks are for **much higher APIs** than 23, so they won't be affected:
|
||||
|
||||
**MainActivity.java:**
|
||||
- `Build.VERSION_CODES.R` (API 30+) - Edge-to-edge display
|
||||
- `Build.VERSION_CODES.TIRAMISU` (API 33+) - Intent extras handling
|
||||
- Legacy path (API 21-29) - Will still work, but API 22 devices won't be supported
|
||||
|
||||
**SafeAreaPlugin.java:**
|
||||
- `Build.VERSION_CODES.R` (API 30+) - Safe area insets
|
||||
|
||||
**Conclusion:** No code changes needed for API level checks.
|
||||
|
||||
#### 2. Permissions Handling
|
||||
|
||||
**Current Permissions in AndroidManifest.xml:**
|
||||
- `INTERNET` - Normal permission (no runtime needed)
|
||||
- `READ_EXTERNAL_STORAGE` - Dangerous permission (runtime required on API 23+)
|
||||
- `WRITE_EXTERNAL_STORAGE` - Dangerous permission (runtime required on API 23+)
|
||||
- `CAMERA` - Dangerous permission (runtime required on API 23+)
|
||||
|
||||
**Current Implementation:**
|
||||
- ✅ App uses **Capacitor plugins** for camera and file access
|
||||
- ✅ Capacitor plugins **already handle runtime permissions** automatically
|
||||
- ✅ No manual permission request code found in the codebase
|
||||
- ✅ QR Scanner uses Capacitor's BarcodeScanner plugin which handles permissions
|
||||
|
||||
**Conclusion:** No code changes needed - Capacitor handles runtime permissions automatically.
|
||||
|
||||
#### 3. Dependencies Compatibility
|
||||
|
||||
**AndroidX Libraries:**
|
||||
- `androidx.appcompat:appcompat:1.6.1` - ✅ Supports API 23+
|
||||
- `androidx.core:core:1.12.0` - ✅ Supports API 23+
|
||||
- `androidx.fragment:fragment:1.6.2` - ✅ Supports API 23+
|
||||
- `androidx.coordinatorlayout:coordinatorlayout:1.2.0` - ✅ Supports API 23+
|
||||
- `androidx.core:core-splashscreen:1.0.1` - ✅ Supports API 23+
|
||||
|
||||
**Capacitor Plugins:**
|
||||
- `@capacitor/core:6.2.0` - ✅ Requires API 23+ (official requirement)
|
||||
- `@capacitor/camera:6.0.0` - ✅ Handles runtime permissions
|
||||
- `@capacitor/filesystem:6.0.0` - ✅ Handles runtime permissions
|
||||
- `@capacitor-community/sqlite:6.0.2` - ✅ Supports API 23+
|
||||
- `@capacitor-mlkit/barcode-scanning:6.0.0` - ✅ Supports API 23+
|
||||
|
||||
**Third-Party Libraries:**
|
||||
- No Firebase or other libraries with API 22-specific requirements found
|
||||
- All dependencies appear compatible with API 23+
|
||||
|
||||
**Conclusion:** All dependencies are compatible with API 23.
|
||||
|
||||
#### 4. Build Configuration
|
||||
|
||||
**Current Configuration:**
|
||||
- `compileSdkVersion = 36` (Android 14)
|
||||
- `targetSdkVersion = 36` (Android 14)
|
||||
- `minSdkVersion = 22` (Android 5.1) ← **Only this needs to change**
|
||||
|
||||
**Required Change:**
|
||||
```gradle
|
||||
// android/variables.gradle
|
||||
ext {
|
||||
minSdkVersion = 23 // Change from 22 to 23
|
||||
// ... rest stays the same
|
||||
}
|
||||
```
|
||||
|
||||
**Conclusion:** Only one line needs to be changed.
|
||||
|
||||
## Device Compatibility Impact
|
||||
|
||||
### Device Coverage Loss
|
||||
|
||||
**API 22 (Android 5.1 Lollipop):**
|
||||
- Released: March 2015
|
||||
- Market share: ~0.1% of active devices (as of 2024)
|
||||
- Devices affected: Very old devices from 2015-2016
|
||||
|
||||
**API 23 (Android 6.0 Marshmallow):**
|
||||
- Released: October 2015
|
||||
- Market share: ~0.3% of active devices (as of 2024)
|
||||
- Still very low, but slightly higher than API 22
|
||||
|
||||
**Impact:** Losing support for ~0.1% of devices (essentially negligible)
|
||||
|
||||
### User Base Impact
|
||||
|
||||
**Recommendation:** Check your analytics to see actual usage:
|
||||
- If you have analytics, check percentage of users on API 22
|
||||
- If < 0.5%, upgrade is safe
|
||||
- If > 1%, consider the business impact
|
||||
|
||||
## Runtime Permissions (API 23 Feature)
|
||||
|
||||
### What Changed in API 23
|
||||
|
||||
**Before API 23 (API 22 and below):**
|
||||
- Permissions granted at install time
|
||||
- User sees all permissions during installation
|
||||
- No runtime permission dialogs
|
||||
|
||||
**API 23+ (Runtime Permissions):**
|
||||
- Dangerous permissions must be requested at runtime
|
||||
- User sees permission dialogs when app needs them
|
||||
- Better user experience and privacy
|
||||
|
||||
### Current App Status
|
||||
|
||||
**✅ Already Compatible:**
|
||||
- App uses Capacitor plugins which **automatically handle runtime permissions**
|
||||
- Camera plugin requests permissions when needed
|
||||
- Filesystem plugin requests permissions when needed
|
||||
- No manual permission code needed
|
||||
|
||||
**Conclusion:** App is already designed for runtime permissions via Capacitor.
|
||||
|
||||
## Potential Issues to Watch
|
||||
|
||||
### 1. APK Size
|
||||
- Some developers report APK size increases after raising minSdkVersion
|
||||
- **Action:** Monitor APK size after upgrade
|
||||
- **Expected Impact:** Minimal (API 22 → 23 is a small jump)
|
||||
|
||||
### 2. Testing Requirements
|
||||
- Need to test on API 23+ devices
|
||||
- **Action:** Test on Android 6.0+ devices/emulators
|
||||
- **Current:** App likely already tested on API 23+ devices
|
||||
|
||||
### 3. Legacy Code Path
|
||||
- MainActivity has legacy code for API 21-29
|
||||
- **Impact:** This code will still work, but API 22 devices won't be supported
|
||||
- **Action:** No code changes needed, but legacy path becomes API 23-29
|
||||
|
||||
### 4. Capacitor Compatibility
|
||||
- Capacitor 6.2.0 officially requires API 23+
|
||||
- **Current Situation:** App runs on API 22 (may be working due to leniency)
|
||||
- **After Upgrade:** Officially compliant with Capacitor requirements
|
||||
- **Benefit:** Better compatibility guarantees
|
||||
|
||||
## Files That Need Changes
|
||||
|
||||
### 1. Build Configuration
|
||||
**File:** `android/variables.gradle`
|
||||
```gradle
|
||||
ext {
|
||||
minSdkVersion = 23 // Change from 22
|
||||
// ... rest unchanged
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Documentation
|
||||
**Files to Update:**
|
||||
- `doc/shared-image-plugin-implementation-plan.md` - Update version notes
|
||||
- Any README files mentioning API 22
|
||||
- Build documentation
|
||||
|
||||
### 3. No Code Changes Required
|
||||
- ✅ No Java/Kotlin code changes needed
|
||||
- ✅ No AndroidManifest.xml changes needed
|
||||
- ✅ No permission handling code changes needed
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
After upgrading to API 23, test:
|
||||
|
||||
- [ ] App builds successfully
|
||||
- [ ] App installs on API 23 device/emulator
|
||||
- [ ] Camera functionality works (permissions requested)
|
||||
- [ ] File access works (permissions requested)
|
||||
- [ ] Share functionality works
|
||||
- [ ] QR code scanning works
|
||||
- [ ] Deep linking works
|
||||
- [ ] All Capacitor plugins work correctly
|
||||
- [ ] No crashes or permission-related errors
|
||||
- [ ] APK size is acceptable
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise:
|
||||
|
||||
1. Revert `android/variables.gradle` to `minSdkVersion = 22`
|
||||
2. Rebuild and test
|
||||
3. Document issues encountered
|
||||
4. Address issues before retrying upgrade
|
||||
|
||||
## Recommendation
|
||||
|
||||
### ✅ **Proceed with Upgrade**
|
||||
|
||||
**Reasons:**
|
||||
1. **Minimal Code Impact:** Only one line needs to change
|
||||
2. **Already Compatible:** App uses Capacitor which handles runtime permissions
|
||||
3. **Device Impact:** Negligible (~0.1% of devices)
|
||||
4. **Capacitor Compliance:** Officially meets Capacitor 6 requirements
|
||||
5. **Future-Proofing:** Better alignment with modern Android development
|
||||
|
||||
**Timeline:**
|
||||
- **Low Risk:** Can be done anytime
|
||||
- **Recommended:** Before implementing SharedImagePlugin (cleaner baseline)
|
||||
- **Testing:** 1-2 hours of testing on API 23+ devices
|
||||
|
||||
## Migration Steps
|
||||
|
||||
1. **Update Build Configuration:**
|
||||
```bash
|
||||
# Edit android/variables.gradle
|
||||
minSdkVersion = 23
|
||||
```
|
||||
|
||||
2. **Sync Gradle:**
|
||||
```bash
|
||||
cd android
|
||||
./gradlew clean
|
||||
```
|
||||
|
||||
3. **Build and Test:**
|
||||
```bash
|
||||
npm run build:android:test
|
||||
# Test on API 23+ device/emulator
|
||||
```
|
||||
|
||||
4. **Verify Permissions:**
|
||||
- Test camera access
|
||||
- Test file access
|
||||
- Verify permission dialogs appear
|
||||
|
||||
5. **Update Documentation:**
|
||||
- Update any docs mentioning API 22
|
||||
- Update implementation plan
|
||||
|
||||
## Summary
|
||||
|
||||
| Aspect | Impact | Status |
|
||||
|--------|--------|--------|
|
||||
| **Code Changes** | None required | ✅ Safe |
|
||||
| **Dependencies** | All compatible | ✅ Safe |
|
||||
| **Permissions** | Already handled | ✅ Safe |
|
||||
| **Device Coverage** | ~0.1% loss | ⚠️ Minimal |
|
||||
| **Build Config** | 1 line change | ✅ Simple |
|
||||
| **Testing** | Standard testing | ✅ Required |
|
||||
| **Risk Level** | Low | ✅ Low Risk |
|
||||
|
||||
**Final Recommendation:** Proceed with upgrade. The benefits (Capacitor compliance, future-proofing) outweigh the minimal risks (negligible device loss, no code changes needed).
|
||||
|
||||
@@ -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,85 @@
|
||||
# Android: Second notification doesn't fire (investigation & plan)
|
||||
|
||||
**Handoff to plugin repo:** This doc can be used as context in the daily-notification-plugin repo (e.g. in Cursor) to fix the Android re-schedule issue. See **Plugin-side: where to look and what to try** and **Could "re-scheduling too soon" cause the failure?** for actionable plugin changes.
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
- **Symptom**: After a fresh install, the first scheduled daily notification fires. When the user sets another notification (same or different time), it does not fire until the app is uninstalled and reinstalled.
|
||||
- **Test app**: The plugin's test app (`daily-notification-test`) does not show this issue; scheduling a second notification works.
|
||||
- **Attempted fix**: We changed the reminder ID from `timesafari_daily_reminder` to `daily_timesafari_reminder` so the plugin's rollover logic preserves the schedule ID (IDs starting with `daily_` are preserved). That did not fix the issue.
|
||||
|
||||
## Could "re-scheduling too soon" cause the failure?
|
||||
|
||||
**Yes, timing can matter.** The plugin is not very forgiving on Android in one case:
|
||||
|
||||
- **Idempotence in `NotifyReceiver.scheduleExactNotification`**: Before scheduling, the plugin checks for an existing PendingIntent (same `scheduleId` or same trigger time). If one exists, it **skips** scheduling to avoid duplicates.
|
||||
- **After cancel**: When you re-schedule, the flow is `cancelNotification(scheduleId)` then `scheduleExactNotification(...)`. Android may not remove a cancelled PendingIntent from its cache immediately. If the idempotence check runs right after cancel, it can still see the old PendingIntent and treat the new schedule as a duplicate, so the second schedule is skipped.
|
||||
- **After the first notification fires**: The alarm is gone but the PendingIntent might still be in the system. If the user opens the app and re-schedules within a few seconds, the same “duplicate” logic can trigger.
|
||||
|
||||
**Practical check:** Try waiting **5–10 seconds** after the first notification fires (or after changing time and saving) before saving again. If re-scheduling works when you wait but fails when you do it immediately, the cause is this timing/idempotence behavior. Fix would be in the plugin (e.g. short delay after cancel before idempotence check, or re-check after cancel).
|
||||
|
||||
**Other timing in the plugin (do not apply to your flow):** `DailyNotificationScheduler` has a 10s “notification throttle” and a 30s “activeDid changed” grace; those are used only when scheduling from **fetched content / rollover**, not when the user calls `scheduleDailyNotification`. Your re-schedule path goes through `NotifyReceiver.scheduleExactNotification` only, so those timeouts are not the cause.
|
||||
|
||||
## Differences: Test app vs TimeSafari
|
||||
|
||||
| Aspect | Test app | TimeSafari (before alignment) |
|
||||
|--------|----------|-------------------------------|
|
||||
| **Method** | `scheduleDailyNotification(options)` | `scheduleDailyReminder(options)` |
|
||||
| **Options** | `{ time, title, body, sound, priority }` — **no `id`** | `{ id, time, title, body, repeatDaily, sound, vibration, priority }` |
|
||||
| **Effective scheduleId** | Plugin default: `"daily_notification"` | Explicit: `"daily_timesafari_reminder"` (then `"daily_timesafari_reminder"` after prefix fix) |
|
||||
| **Pre-cancel** | None | Calls `cancelDailyReminder({ reminderId })` before scheduling |
|
||||
| **Android cancelDailyReminder** | Not used | Plugin **does not expose** `cancelDailyReminder` on Android (only `cancelAllNotifications`). So the pre-cancel is a no-op or fails silently. |
|
||||
|
||||
The plugin's `scheduleDailyNotification` flow already cancels the existing alarm for the **same** scheduleId via `NotifyReceiver.cancelNotification(context, scheduleId)` before scheduling. So the only behavioral difference that might matter is **which scheduleId is used** and **whether we pass an `id`**.
|
||||
|
||||
## Plan (app-side only)
|
||||
|
||||
1. **Platform-specific behavior** (implemented):
|
||||
- **Android**: Use **`scheduleDailyNotification`** without passing `id` so the plugin uses default scheduleId **`"daily_notification"`**. Use **`reminderId = "daily_notification"`** for cancel/getStatus. **Do not** call `cancelDailyReminder` before scheduling on Android (test app does not; plugin cancels the previous alarm internally).
|
||||
- **iOS**: Use **`scheduleDailyNotification`** with **`id: "daily_timesafari_reminder"`** and call **`cancelDailyReminder`** before scheduling so the reminder is removed from the notification center before rescheduling.
|
||||
2. **If Android re-schedule still fails**, next step is **plugin-side investigation** in the plugin repo (no patch in this repo):
|
||||
- Add logging in `NotifyReceiver.scheduleExactNotification` (idempotence checks, PendingIntent/DB) and in `ScheduleHelper.scheduleDailyNotification` / `cleanupExistingNotificationSchedules`; compare logcat for test app vs TimeSafari when scheduling twice.
|
||||
- Optionally in test app: pass an explicit `id` when scheduling and test scheduling twice; if it then fails, the bug is tied to custom scheduleIds and the fix belongs in the plugin.
|
||||
- Confirm whether the second schedule is skipped by an idempotence check (e.g. PendingIntent still present, or DB `nextRunAt` within 1 min of new trigger) or by another code path.
|
||||
|
||||
## Plugin-side: where to look and what to try
|
||||
|
||||
*(Use this section when working in the daily-notification-plugin repo.)*
|
||||
|
||||
**Entry point (user schedule):**
|
||||
`DailyNotificationPlugin.kt` → `scheduleDailyNotification` → `ScheduleHelper.scheduleDailyNotification` → `NotifyReceiver.cancelNotification(context, scheduleId)` then `NotifyReceiver.scheduleExactNotification(...)`.
|
||||
|
||||
**Relevant plugin files (paths relative to plugin root):**
|
||||
|
||||
- **`android/.../NotifyReceiver.kt`**
|
||||
- `scheduleExactNotification`: idempotence checks at start (PendingIntent by requestCode, by trigger time, then DB by scheduleId + nextRunAt within 60s). If any check finds an existing schedule, the function returns without scheduling.
|
||||
- `cancelNotification`: cancels alarm and `existingPendingIntent.cancel()`. Android may not drop the PendingIntent from its cache immediately.
|
||||
- **`android/.../DailyNotificationPlugin.kt`** (or ScheduleHelper companion/object)
|
||||
- `ScheduleHelper.scheduleDailyNotification`: calls `NotifyReceiver.cancelNotification(context, scheduleId)` then `NotifyReceiver.scheduleExactNotification(...)`.
|
||||
- `cleanupExistingNotificationSchedules`: cancels and deletes other schedules; excludes current scheduleId.
|
||||
|
||||
**Likely cause:** Idempotence in `scheduleExactNotification` runs *after* `cancelNotification` in the same flow. A just-cancelled PendingIntent can still be returned by `PendingIntent.getBroadcast(..., FLAG_NO_CREATE)` and cause the new schedule to be skipped.
|
||||
|
||||
**Suggested fixes (in plugin):**
|
||||
|
||||
1. **Re-check after cancel:** In the path that does cancel-then-schedule (e.g. in `ScheduleHelper.scheduleDailyNotification`), after `cancelNotification(scheduleId)` either:
|
||||
- Call `PendingIntent.getBroadcast(..., FLAG_NO_CREATE)` for that scheduleId in a short loop with a small delay (e.g. 50–100 ms) until it returns null, with a timeout (e.g. 500 ms), then call `scheduleExactNotification`; or
|
||||
- Pass a flag into `scheduleExactNotification` to skip or relax the "existing PendingIntent" idempotence when the caller has just cancelled this scheduleId.
|
||||
2. **Or brief delay before idempotence:** When the schedule path has just called `cancelNotification(scheduleId)`, have `scheduleExactNotification` skip the PendingIntent check for that scheduleId if last cancel was < 1–2 s ago (e.g. store "justCancelled(scheduleId)" with timestamp).
|
||||
3. **Logging:** In `NotifyReceiver.scheduleExactNotification`, log when scheduling is skipped and which check triggered (PendingIntent by requestCode, by time, or DB). Capture logcat for "schedule, then fire, then re-schedule within a few seconds" to confirm.
|
||||
|
||||
**Reproduce in test app:** In `daily-notification-test`, schedule once, let it fire (or wait), then schedule again within 1–2 seconds. If the second schedule doesn't fire, the bug is reproducible in the plugin; then apply one of the fixes above and re-test.
|
||||
|
||||
---
|
||||
|
||||
## If changes are needed in the plugin repo (TimeSafari app note)
|
||||
|
||||
Do **not** add a patch in this (TimeSafari) repo. Instead:
|
||||
|
||||
1. **Reproduce in the plugin's test app** (e.g. pass an explicit `id` like `"custom_id"` when scheduling and try scheduling twice) to see if the issue is tied to custom scheduleIds.
|
||||
2. **Add the logging** above in the plugin's Android code and capture logs for “first schedule → fire → second schedule” in both test app and TimeSafari.
|
||||
3. **Fix in the plugin** (e.g. relax or correct idempotence, or ensure cancel + DB state are consistent for the same scheduleId) and release a new plugin version; then bump the plugin dependency in this app.
|
||||
|
||||
No patch file or copy of plugin code is needed in the TimeSafari repo.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,526 @@
|
||||
# Android Physical Device Deployment Guide
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Date**: 2025-02-12
|
||||
**Status**: 🎯 **ACTIVE** - Complete guide for deploying TimeSafari to physical Android devices
|
||||
|
||||
## Overview
|
||||
|
||||
This guide provides comprehensive instructions for building and deploying TimeSafari to physical Android devices for testing and development. Unlike emulator testing, physical device testing requires additional setup for USB connections and network configuration.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required Tools
|
||||
|
||||
1. **Android SDK Platform Tools** (includes `adb`)
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install android-platform-tools
|
||||
|
||||
# Or via Android SDK Manager
|
||||
sdkmanager "platform-tools"
|
||||
```
|
||||
|
||||
2. **Node.js 18+** and npm
|
||||
|
||||
3. **Java Development Kit (JDK) 17+**
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install openjdk@17
|
||||
|
||||
# Verify installation
|
||||
java -version
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
Add to your shell configuration (`~/.zshrc` or `~/.bashrc`):
|
||||
|
||||
```bash
|
||||
# Android SDK location
|
||||
export ANDROID_HOME=$HOME/Library/Android/sdk # macOS default
|
||||
# export ANDROID_HOME=$HOME/Android/Sdk # Linux default
|
||||
|
||||
export PATH=$PATH:$ANDROID_HOME/platform-tools
|
||||
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin
|
||||
export PATH=$PATH:$ANDROID_HOME/build-tools/34.0.0
|
||||
```
|
||||
|
||||
Reload your shell:
|
||||
|
||||
```bash
|
||||
source ~/.zshrc # or source ~/.bashrc
|
||||
```
|
||||
|
||||
Verify installation:
|
||||
|
||||
```bash
|
||||
adb version
|
||||
```
|
||||
|
||||
## Device Setup
|
||||
|
||||
### Step 1: Enable Developer Options
|
||||
|
||||
Developer Options is hidden by default on Android devices. To enable it:
|
||||
|
||||
1. Open **Settings** on your Android device
|
||||
2. Scroll down and tap **About phone** (or **About device**)
|
||||
3. Find **Build number** and tap it **7 times** rapidly
|
||||
4. You'll see a message: "You are now a developer!"
|
||||
5. Go back to Settings - **Developer options** now appears
|
||||
|
||||
### Step 2: Enable USB Debugging
|
||||
|
||||
1. Go to **Settings** → **Developer options**
|
||||
2. Enable **USB debugging** (toggle it ON)
|
||||
3. Optionally enable these helpful options:
|
||||
- **Stay awake** - Screen stays on while charging
|
||||
- **Install via USB** - Allow app installations via USB
|
||||
|
||||
### Step 3: Connect Your Device
|
||||
|
||||
1. Connect your Android device to your computer via USB cable
|
||||
2. On your device, you'll see a prompt: "Allow USB debugging?"
|
||||
3. Check **"Always allow from this computer"** (recommended)
|
||||
4. Tap **Allow**
|
||||
|
||||
### Step 4: Verify Connection
|
||||
|
||||
```bash
|
||||
# List connected devices
|
||||
adb devices
|
||||
|
||||
# Expected output:
|
||||
# List of devices attached
|
||||
# XXXXXXXXXX device
|
||||
```
|
||||
|
||||
If you see `unauthorized` instead of `device`, check your phone for the USB debugging authorization prompt.
|
||||
|
||||
## Network Configuration for Development
|
||||
|
||||
### Understanding the Network Challenge
|
||||
|
||||
When running a local development server on your computer:
|
||||
- **Emulators** use `10.0.2.2` to reach the host machine
|
||||
- **Physical devices** need your computer's actual LAN IP address
|
||||
|
||||
### Step 1: Find Your Computer's IP Address
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
ipconfig getifaddr en0 # Wi-Fi
|
||||
# or
|
||||
ipconfig getifaddr en1 # Ethernet
|
||||
|
||||
# Linux
|
||||
hostname -I | awk '{print $1}'
|
||||
# or
|
||||
ip addr show | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d'/' -f1
|
||||
```
|
||||
|
||||
Example output: `192.168.1.100`
|
||||
|
||||
### Step 2: Ensure Same Network
|
||||
|
||||
Your Android device and computer **must be on the same Wi-Fi network** for the device to reach your local development servers.
|
||||
|
||||
### Step 3: Configure API Endpoints
|
||||
|
||||
Pass your computer's IP to the build with `--api-ip`. The build script points
|
||||
the claim and partner APIs at that address:
|
||||
|
||||
```bash
|
||||
npm run build:android:dev -- --api-ip 192.168.1.100
|
||||
```
|
||||
|
||||
**Important**: Replace `192.168.1.100` with your actual IP address.
|
||||
|
||||
Without `--api-ip`, a development build uses `10.0.2.2:3000`, which reaches the
|
||||
host machine from an emulator but not from a physical device.
|
||||
|
||||
The build script applies `--api-ip` after loading `.env.development`, so the
|
||||
flag wins for the claim and partner APIs. Other addresses come from that file,
|
||||
which development web builds share:
|
||||
|
||||
```bash
|
||||
# .env.development
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_APP_SERVER=http://192.168.1.100:8080
|
||||
```
|
||||
|
||||
### Step 4: Start Your Local Server
|
||||
|
||||
If testing against local API servers, ensure they're accessible from the network:
|
||||
|
||||
```bash
|
||||
# Start your API server bound to all interfaces (not just localhost)
|
||||
# Example for Node.js:
|
||||
node server.js --host 0.0.0.0
|
||||
|
||||
# Or configure your server to listen on 0.0.0.0 instead of 127.0.0.1
|
||||
```
|
||||
|
||||
### Alternative: Use Test/Production Servers
|
||||
|
||||
For simpler testing without local servers, use the test environment:
|
||||
|
||||
```bash
|
||||
# Build with test API servers (no local server needed)
|
||||
npm run build:android:test
|
||||
```
|
||||
|
||||
## Building and Deploying
|
||||
|
||||
### Quick Start (Recommended)
|
||||
|
||||
```bash
|
||||
# 1. Verify device is connected
|
||||
adb devices
|
||||
|
||||
# 2. Build and deploy in one command
|
||||
npm run build:android:debug:run
|
||||
```
|
||||
|
||||
### Step-by-Step Deployment
|
||||
|
||||
#### Step 1: Build the App
|
||||
|
||||
```bash
|
||||
# Development build (uses .env.development)
|
||||
npm run build:android:dev
|
||||
|
||||
# Test build (uses test API servers)
|
||||
npm run build:android:test
|
||||
|
||||
# Production build
|
||||
npm run build:android:prod
|
||||
```
|
||||
|
||||
#### Step 2: Install the APK
|
||||
|
||||
```bash
|
||||
# Install (replace existing if present)
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
#### Step 3: Launch the App
|
||||
|
||||
```bash
|
||||
# Start the app
|
||||
adb shell am start -n app.timesafari.app/app.timesafari.MainActivity
|
||||
```
|
||||
|
||||
### One-Line Deploy Commands
|
||||
|
||||
```bash
|
||||
# Development build + install + launch
|
||||
npm run build:android:debug:run
|
||||
|
||||
# Test build + install + launch
|
||||
npm run build:android:test:run
|
||||
|
||||
# Deploy to connected device (build must exist)
|
||||
npm run build:android:deploy
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### View App Logs
|
||||
|
||||
```bash
|
||||
# All logs from your app
|
||||
adb logcat | grep -E "(TimeSafari|Capacitor)"
|
||||
|
||||
# With color highlighting
|
||||
adb logcat | grep -E "(TimeSafari|Capacitor)" --color=always
|
||||
|
||||
# Save logs to file
|
||||
adb logcat > device-logs.txt
|
||||
```
|
||||
|
||||
### Chrome DevTools (Remote Debugging)
|
||||
|
||||
1. Open Chrome on your computer
|
||||
2. Navigate to `chrome://inspect`
|
||||
3. Your device should appear under "Remote Target"
|
||||
4. Click **inspect** to open DevTools for your app
|
||||
|
||||
**Requirements**:
|
||||
- USB debugging must be enabled
|
||||
- Device must be connected via USB
|
||||
- App must be a debug build
|
||||
|
||||
### Common Log Filters
|
||||
|
||||
```bash
|
||||
# Network-related issues
|
||||
adb logcat | grep -i "network\|http\|socket"
|
||||
|
||||
# JavaScript errors
|
||||
adb logcat | grep -i "console\|error\|exception"
|
||||
|
||||
# Capacitor plugin issues
|
||||
adb logcat | grep -i "capacitor"
|
||||
|
||||
# Detailed app logs
|
||||
adb logcat -s "TimeSafari:V"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Device Not Detected
|
||||
|
||||
**Symptom**: `adb devices` shows nothing or shows `unauthorized`
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check USB cable**: Some cables are charge-only. Use a data-capable USB cable.
|
||||
|
||||
2. **Revoke USB debugging authorizations** (on device):
|
||||
- Settings → Developer options → Revoke USB debugging authorizations
|
||||
- Reconnect and re-authorize
|
||||
|
||||
3. **Restart ADB server**:
|
||||
```bash
|
||||
adb kill-server
|
||||
adb start-server
|
||||
adb devices
|
||||
```
|
||||
|
||||
4. **Try different USB port**: Some USB hubs don't work well with ADB.
|
||||
|
||||
5. **Check device USB mode**: Pull down notification shade and ensure USB is set to "File Transfer" or "MTP" mode, not just charging.
|
||||
|
||||
### App Can't Connect to Local Server
|
||||
|
||||
**Symptom**: App loads but shows network errors or can't reach API
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Verify IP address**:
|
||||
```bash
|
||||
# Make sure you have the right IP
|
||||
ipconfig getifaddr en0 # macOS
|
||||
```
|
||||
|
||||
2. **Check firewall**: Temporarily disable firewall or add exception for port 3000
|
||||
|
||||
3. **Test connectivity from device**:
|
||||
- Open Chrome on your Android device
|
||||
- Navigate to `http://YOUR_IP:3000`
|
||||
- Should see your API response
|
||||
|
||||
4. **Verify server is listening on all interfaces**:
|
||||
```bash
|
||||
# Should show 0.0.0.0:3000, not 127.0.0.1:3000
|
||||
lsof -i :3000
|
||||
```
|
||||
|
||||
5. **Same network check**: Ensure phone Wi-Fi and computer are on the same network
|
||||
|
||||
### Installation Failed
|
||||
|
||||
**Symptom**: `adb install` fails with error
|
||||
|
||||
**Common errors and solutions**:
|
||||
|
||||
1. **INSTALL_FAILED_UPDATE_INCOMPATIBLE**:
|
||||
```bash
|
||||
# Uninstall existing app first
|
||||
adb uninstall app.timesafari.app
|
||||
adb install android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
2. **INSTALL_FAILED_INSUFFICIENT_STORAGE**:
|
||||
- Free up space on the device
|
||||
- Or install to SD card if available
|
||||
|
||||
3. **INSTALL_FAILED_USER_RESTRICTED**:
|
||||
- Enable "Install via USB" in Developer options
|
||||
- On some devices: Settings → Security → Unknown sources
|
||||
|
||||
4. **Signature mismatch**:
|
||||
```bash
|
||||
# Full clean reinstall
|
||||
adb uninstall app.timesafari.app
|
||||
npm run clean:android
|
||||
npm run build:android:debug
|
||||
adb install android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### App Crashes on Launch
|
||||
|
||||
**Symptom**: App opens briefly then closes
|
||||
|
||||
**Debug steps**:
|
||||
|
||||
1. **Check crash logs**:
|
||||
```bash
|
||||
adb logcat | grep -E "FATAL|AndroidRuntime|Exception"
|
||||
```
|
||||
|
||||
2. **Clear app data**:
|
||||
```bash
|
||||
adb shell pm clear app.timesafari.app
|
||||
```
|
||||
|
||||
3. **Reinstall clean**:
|
||||
```bash
|
||||
adb uninstall app.timesafari.app
|
||||
npm run clean:android
|
||||
npm run build:android:debug:run
|
||||
```
|
||||
|
||||
### Build Failures
|
||||
|
||||
**Symptom**: Build fails before APK is created
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Asset validation**:
|
||||
```bash
|
||||
npm run assets:validate:android
|
||||
```
|
||||
|
||||
2. **Clean and rebuild**:
|
||||
```bash
|
||||
npm run clean:android
|
||||
npm run build:android:debug
|
||||
```
|
||||
|
||||
3. **Check Gradle**:
|
||||
```bash
|
||||
cd android
|
||||
./gradlew clean --stacktrace
|
||||
./gradlew assembleDebug --stacktrace
|
||||
```
|
||||
|
||||
## Wireless Debugging (Optional)
|
||||
|
||||
Once initial USB connection is established, you can switch to wireless:
|
||||
|
||||
### Enable Wireless Debugging
|
||||
|
||||
```bash
|
||||
# 1. Connect via USB first
|
||||
adb devices
|
||||
|
||||
# 2. Enable TCP/IP mode on port 5555
|
||||
adb tcpip 5555
|
||||
|
||||
# 3. Find device IP (on device: Settings → About → IP address)
|
||||
# Or:
|
||||
adb shell ip addr show wlan0
|
||||
|
||||
# 4. Connect wirelessly (disconnect USB cable)
|
||||
adb connect 192.168.1.XXX:5555
|
||||
|
||||
# 5. Verify
|
||||
adb devices
|
||||
```
|
||||
|
||||
### Reconnect After Reboot
|
||||
|
||||
```bash
|
||||
# Device IP may have changed - check it first
|
||||
adb connect 192.168.1.XXX:5555
|
||||
```
|
||||
|
||||
### Return to USB Mode
|
||||
|
||||
```bash
|
||||
adb usb
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Keep device connected during development** for quick iteration
|
||||
|
||||
2. **Use test builds for most testing**:
|
||||
```bash
|
||||
npm run build:android:test:run
|
||||
```
|
||||
This avoids local server configuration hassles.
|
||||
|
||||
3. **Use Chrome DevTools** for JavaScript debugging - much easier than logcat
|
||||
|
||||
4. **Test on multiple devices** if possible - different Android versions behave differently
|
||||
|
||||
### Performance Testing
|
||||
|
||||
Physical devices give you real-world performance insights that emulators can't:
|
||||
|
||||
- **Battery consumption**: Monitor with Settings → Battery
|
||||
- **Network conditions**: Test on slow/unstable Wi-Fi
|
||||
- **Memory pressure**: Test with many apps open
|
||||
- **Touch responsiveness**: Actual finger input vs mouse clicks
|
||||
|
||||
### Before Release Testing
|
||||
|
||||
Always test on physical devices before any release:
|
||||
|
||||
1. Fresh install (not upgrade)
|
||||
2. Upgrade from previous version
|
||||
3. Test on lowest supported Android version
|
||||
4. Test on both phone and tablet if applicable
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
# Check connected devices
|
||||
adb devices
|
||||
|
||||
# Build and run (debug)
|
||||
npm run build:android:debug:run
|
||||
|
||||
# Build and run (test environment)
|
||||
npm run build:android:test:run
|
||||
|
||||
# Install existing APK
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
# Uninstall app
|
||||
adb uninstall app.timesafari.app
|
||||
|
||||
# Launch app
|
||||
adb shell am start -n app.timesafari.app/app.timesafari.MainActivity
|
||||
|
||||
# View logs
|
||||
adb logcat | grep TimeSafari
|
||||
|
||||
# Take screenshot
|
||||
adb exec-out screencap -p > screenshot.png
|
||||
|
||||
# Record screen
|
||||
adb shell screenrecord /sdcard/demo.mp4
|
||||
# (Ctrl+C to stop, then pull file)
|
||||
adb pull /sdcard/demo.mp4
|
||||
```
|
||||
|
||||
### Build Modes Quick Reference
|
||||
|
||||
| Command | Environment | API Servers |
|
||||
|---------|-------------|-------------|
|
||||
| `npm run build:android:dev` | Development | Local (your IP:3000) |
|
||||
| `npm run build:android:test` | Test | test-api.endorser.ch |
|
||||
| `npm run build:android:prod` | Production | api.endorser.ch |
|
||||
|
||||
## Conclusion
|
||||
|
||||
Physical device testing is essential for:
|
||||
- ✅ Real-world performance validation
|
||||
- ✅ Touch and gesture testing
|
||||
- ✅ Camera and hardware feature testing
|
||||
- ✅ Network condition testing
|
||||
- ✅ Battery and resource usage analysis
|
||||
|
||||
For emulator-based testing (useful for quick iteration), see [Android Emulator Deployment Guide](android-emulator-deployment-guide.md).
|
||||
|
||||
For questions or additional troubleshooting, refer to the main [BUILDING.md](../BUILDING.md) documentation.
|
||||
@@ -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,80 @@
|
||||
# Consuming app handoff: iOS native fetcher + chained dual (mirror)
|
||||
|
||||
**Canonical source:** `daily-notification-plugin` repo, `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` (same content as below for offline use).
|
||||
|
||||
---
|
||||
|
||||
## Implemented in this app
|
||||
|
||||
- **`ios/App/App/TimeSafariNativeFetcher.swift`** — Swift `NativeNotificationContentFetcher` mirroring `TimeSafariNativeFetcher.java` (`POST …/plansLastUpdatedBetween`, starred IDs from `daily_notification_timesafari.starredPlanIds`, JWT pool selection, pagination key `daily_notification_timesafari.last_acked_jwt_id`, aggregated copy).
|
||||
- **`AppDelegate.swift`** — `DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)` at launch **before** any JS `configureNativeFetcher`; foreground handler reads `scheduled_time` as `Int64`, `NSNumber`, or `Int` for `DailyNotificationDelivered`.
|
||||
|
||||
## Dependency
|
||||
|
||||
- **`@timesafari/daily-notification-plugin`** must be **≥ 3.0.0** (register native fetcher, chained dual, iOS `updateStarredPlans`). Declare it in `package.json` from the official remote (`git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git`, branch or tag as needed), then `npm install` so `package-lock.json` resolves the published tree.
|
||||
|
||||
## Bump / sync (after plugin version is resolved)
|
||||
|
||||
1. `npm install`
|
||||
2. `npx cap sync ios && npx cap sync android`
|
||||
3. `cd ios/App && pod install`
|
||||
4. Clean build in Xcode / Android Studio
|
||||
|
||||
## QA focus
|
||||
|
||||
- iOS: Fetcher registered before `configureNativeFetcher`; `updateStarredPlans` not `UNIMPLEMENTED`.
|
||||
- Both: New Activity fires **after** prefetch for that cycle where the plugin implements chaining.
|
||||
- Android: Existing `MainActivity.setNativeFetcher` unchanged; regression-test `cancelDualSchedule` vs Daily Reminder.
|
||||
|
||||
---
|
||||
|
||||
## Original handoff text (from plugin)
|
||||
|
||||
This document is for the **host app** repository (e.g. crowd-funder-for-time-pwa) after bumping `@timesafari/daily-notification-plugin` to a version that includes:
|
||||
|
||||
- **iOS** `NativeNotificationContentFetcher`–style registration (`DailyNotificationPlugin.registerNativeFetcher`)
|
||||
- **iOS** `updateStarredPlans` / `getStarredPlans` (parity with Android `daily_notification_timesafari` / `starredPlanIds` semantics)
|
||||
- **iOS** chained dual flow: user notification is **armed only after** prefetch completes (delay if fetch is late; max slip 15 minutes before fallback copy)
|
||||
- **Android** chained dual flow: exact **notify** alarm is scheduled **after** dual prefetch completes (no longer scheduled at initial `scheduleDualNotification` before fetch)
|
||||
|
||||
Material from `doc/new-activity-notifications-ios-android-parity.md` still applies; the plugin doc adds **app-side** steps not spelled out there.
|
||||
|
||||
### 1. iOS — register native fetcher before `configureNativeFetcher`
|
||||
|
||||
The plugin **rejects** `configureNativeFetcher` if no fetcher is registered (aligned with Android).
|
||||
|
||||
**In `AppDelegate` (or earliest app startup before Capacitor calls into the plugin):**
|
||||
|
||||
```swift
|
||||
import TimesafariDailyNotificationPlugin
|
||||
|
||||
DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)
|
||||
```
|
||||
|
||||
Implement **`TimeSafariNativeFetcher`** as a Swift type that:
|
||||
|
||||
- Conforms to `NativeNotificationContentFetcher`
|
||||
- Implements `fetchContent(context: FetchContext) async throws -> [NotificationContent]` with the same **Endorser** behavior as `TimeSafariNativeFetcher.java`
|
||||
- Implements `configure(apiBaseUrl:activeDid:jwtToken:jwtTokenPool:)` if the fetcher needs credentials pushed from TypeScript
|
||||
|
||||
**Starred plan IDs for the fetcher:** Read JSON array string from UserDefaults key **`daily_notification_timesafari.starredPlanIds`** (written by `updateStarredPlans` from JS).
|
||||
|
||||
### 2. iOS — `UNUserNotificationCenterDelegate` / rollover
|
||||
|
||||
Chained dual notifications set:
|
||||
|
||||
- `notification_id` = `org.timesafari.dailynotification.dual`
|
||||
- `scheduled_time` = `NSNumber` (fire time in ms)
|
||||
|
||||
Ensure **`DailyNotificationDelivered`** forwards **`notification_id`** and **`scheduled_time`** from **notification content `userInfo`**.
|
||||
|
||||
### 3. Android — no API change for `setNativeFetcher`
|
||||
|
||||
Host apps that already call `DailyNotificationPlugin.setNativeFetcher(TimeSafariNativeFetcher(...))` keep that flow.
|
||||
|
||||
**Behavior change:** the dual **notify** alarm is scheduled when **dual prefetch work finishes**, not at the initial `scheduleDualNotification` only.
|
||||
|
||||
### 4. Assumptions
|
||||
|
||||
- Swift host implements `TimeSafariNativeFetcher`; the plugin does **not** embed `plansLastUpdatedBetween` on iOS when a host fetcher is registered (mirrors Android).
|
||||
- Module import: `TimesafariDailyNotificationPlugin` (Pod `TimesafariDailyNotificationPlugin`).
|
||||
@@ -0,0 +1,105 @@
|
||||
# Daily Notification Plugin: Alignment Outline
|
||||
|
||||
**Purpose:** Checklist of changes/additions needed in this app to align with the test app (`daily-notification-plugin/test-apps/daily-notification-test`) so that:
|
||||
|
||||
1. **Rollover recovery** (and rollover itself) works.
|
||||
2. **Notifications show when the app is in the foreground** (not only background/closed).
|
||||
3. **Plugin loads at app launch** so recovery runs after reboot without the user opening notification UI.
|
||||
|
||||
**Reference:** In the **daily-notification-plugin** repository, the test app lives at `test-apps/daily-notification-test` (same repo as `https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin`).
|
||||
|
||||
---
|
||||
|
||||
## 1. iOS AppDelegate
|
||||
|
||||
**File:** `ios/App/App/AppDelegate.swift`
|
||||
|
||||
### 1.1 Add imports
|
||||
|
||||
- [ ] `import UserNotifications`
|
||||
- [ ] Import the Daily Notification plugin framework (Swift module name: **TimesafariDailyNotificationPlugin** per this app’s Podfile; test app uses **DailyNotificationPlugin**)
|
||||
|
||||
### 1.2 Conform to `UNUserNotificationCenterDelegate`
|
||||
|
||||
- [ ] Add `, UNUserNotificationCenterDelegate` to the class declaration:
|
||||
`class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate`
|
||||
|
||||
### 1.3 Force-load plugin at launch
|
||||
|
||||
- [ ] In `application(_:didFinishLaunchingWithOptions:)`, **before** other setup, add logic to force-load the plugin class (e.g. `_ = DailyNotificationPlugin.self` or the class exposed by the TimesafariDailyNotificationPlugin pod) so that the plugin’s `load()` (and thus `performRecovery()`) runs at app launch, not only when JS first calls the plugin.
|
||||
|
||||
### 1.4 Set notification center delegate
|
||||
|
||||
- [ ] In `didFinishLaunchingWithOptions`, set:
|
||||
`UNUserNotificationCenter.current().delegate = self`
|
||||
- [ ] In `applicationDidBecomeActive`, **re-set** the same delegate (in case Capacitor or another component clears it).
|
||||
|
||||
### 1.5 Implement `userNotificationCenter(_:willPresent:withCompletionHandler:)`
|
||||
|
||||
- [ ] When a notification is delivered (including in foreground), read `notification_id` and `scheduled_time` from `notification.request.content.userInfo`.
|
||||
- [ ] Post rollover event:
|
||||
`NotificationCenter.default.post(name: NSNotification.Name("DailyNotificationDelivered"), object: nil, userInfo: ["notification_id": id, "scheduled_time": scheduledTime])`
|
||||
- [ ] Call completion handler with presentation options so the notification is shown in foreground, e.g.
|
||||
`completionHandler([.banner, .sound, .badge])` (use `.alert` on iOS 13 if needed).
|
||||
|
||||
### 1.6 Implement `userNotificationCenter(_:didReceive:withCompletionHandler:)`
|
||||
|
||||
- [ ] Handle notification tap/interaction; call `completionHandler()` when done.
|
||||
|
||||
---
|
||||
|
||||
## 2. Android Manifest
|
||||
|
||||
**File:** `android/app/src/main/AndroidManifest.xml`
|
||||
|
||||
### 2.1 Fix receiver placement
|
||||
|
||||
- [ ] Move the two `<receiver>` elements (**DailyNotificationReceiver** and **BootReceiver**) **inside** the `<application>` block (e.g. after `<activity>...</activity>` and before `<provider>...</provider>`).
|
||||
- [ ] Remove the stray second `</application>` so there is a single `<application>...</application>` containing activity, receivers, and provider.
|
||||
|
||||
### 2.2 (Optional) Add NotifyReceiver
|
||||
|
||||
- [ ] If the plugin’s Android integration expects **NotifyReceiver** for alarm-based delivery, add a `<receiver>` for `org.timesafari.dailynotification.NotifyReceiver` inside `<application>` (see test app manifest for exact declaration).
|
||||
|
||||
### 2.3 (Optional) BootReceiver options
|
||||
|
||||
- [ ] Consider aligning with test app: add `android:directBootAware="true"`, `android:exported="true"`, and intent-filter actions `LOCKED_BOOT_COMPLETED`, `MY_PACKAGE_REPLACED`, `PACKAGE_REPLACED` if you need the same boot/update behavior.
|
||||
|
||||
---
|
||||
|
||||
## 3. Capacitor / JS startup (optional but recommended)
|
||||
|
||||
**File:** `src/main.capacitor.ts` (or the main entry used for native builds)
|
||||
|
||||
### 3.1 Load plugin at startup
|
||||
|
||||
- [ ] Add a top-level import or an early call that touches the Daily Notification plugin so the JS side loads it at app startup (e.g. `import "@timesafari/daily-notification-plugin"` or a small init that calls `getRebootRecoveryStatus()` or `configure()`).
|
||||
This ensures the plugin is loaded as soon as the app runs; together with the iOS force-load in AppDelegate, recovery runs at launch.
|
||||
|
||||
---
|
||||
|
||||
## 4. Plugin configuration (optional)
|
||||
|
||||
- [ ] If you use the native fetcher or need plugin config (db path, storage, etc.), call `DailyNotification.configure()` and/or `configureNativeFetcher()` when appropriate (e.g. after login or when notification UI is first used), similar to the test app’s `configureNativeFetcher()` in HomeView.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary table
|
||||
|
||||
| Area | Change / addition |
|
||||
|-------------------------|------------------------------------------------------------------------------------|
|
||||
| **iOS AppDelegate** | Conform to `UNUserNotificationCenterDelegate`; set delegate; force-load plugin; implement `willPresent` (post `DailyNotificationDelivered` + show in foreground) and `didReceive`. |
|
||||
| **Android manifest** | Move DailyNotificationReceiver and BootReceiver inside `<application>`; remove duplicate `</application>`; optionally add NotifyReceiver and BootReceiver options. |
|
||||
| **main.capacitor.ts** | Optionally import or call plugin at startup so it (and recovery) load at launch. |
|
||||
| **Plugin config** | Optionally call `configure()` / `configureNativeFetcher()` where appropriate. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification
|
||||
|
||||
After making the changes:
|
||||
|
||||
- [ ] **iOS:** Build and run; trigger a daily notification and confirm it appears when the app is in the foreground.
|
||||
- [ ] **iOS:** Confirm rollover (next day’s schedule) still occurs after a notification fires (check logs for `DNP-ROLLOVER` / `DailyNotificationDelivered`).
|
||||
- [ ] **iOS:** Restart the app (or reboot) and confirm recovery runs without opening the notification settings screen (e.g. logs show plugin load and recovery).
|
||||
- [ ] **Android:** Build and run; confirm receivers are registered (no manifest errors) and that notifications and boot recovery behave as expected.
|
||||
@@ -0,0 +1,251 @@
|
||||
# Daily Notification Plugin - Android Receiver Not Triggered by AlarmManager
|
||||
|
||||
**Date**: 2026-02-02
|
||||
**Status**: ✅ Resolved (2026-02-06)
|
||||
**Plugin**: @timesafari/daily-notification-plugin
|
||||
**Platform**: Android
|
||||
**Issue**: AlarmManager fires alarms but DailyNotificationReceiver is not receiving broadcasts
|
||||
|
||||
---
|
||||
|
||||
## Resolution (2026-02-06)
|
||||
|
||||
The bug was fixed in the plugin repository. The plugin now:
|
||||
|
||||
- Creates the PendingIntent with the receiver component explicitly set (`setComponent(ComponentName(context, DailyNotificationReceiver::class.java))`), so AlarmManager delivers the broadcast to the receiver.
|
||||
- Adds the schedule ID to the Intent extras (`intent.putExtra("id", scheduleId)`), resolving the `missing_id` error.
|
||||
|
||||
**In this app after pulling the fix:**
|
||||
|
||||
1. Run `npm install` to get the latest plugin from `#master`.
|
||||
2. Run `npx cap sync` so the Android (and iOS) native projects get the updated plugin code.
|
||||
3. Run `node scripts/restore-local-plugins.js` if you use local plugins (e.g. SafeArea, SharedImage).
|
||||
4. Rebuild and run on Android, then verify using the [Testing Steps for Plugin Fix](#testing-steps-for-plugin-fix) below.
|
||||
</think>
|
||||
|
||||
---
|
||||
|
||||
## Problem Summary
|
||||
|
||||
Alarms are being scheduled successfully and fire at the correct time, but the `DailyNotificationReceiver` is not being triggered when AlarmManager delivers the broadcast. Manual broadcasts to the receiver work correctly, indicating the receiver itself is functional.
|
||||
|
||||
---
|
||||
|
||||
## What Works ✅
|
||||
|
||||
1. **Receiver Registration**: The receiver is properly registered in AndroidManifest.xml with `exported="true"`
|
||||
2. **Manual Broadcasts**: Manually triggering the receiver via `adb shell am broadcast` successfully triggers it
|
||||
3. **Alarm Scheduling**: Alarms are successfully scheduled via `setAlarmClock()` and appear in `dumpsys alarm`
|
||||
4. **Alarm Firing**: Alarms fire at the scheduled time (confirmed by alarm disappearing from dumpsys)
|
||||
|
||||
---
|
||||
|
||||
## What Doesn't Work ❌
|
||||
|
||||
1. **Automatic Receiver Triggering**: When AlarmManager fires the alarm, the broadcast PendingIntent does not reach the receiver
|
||||
2. **No Logs on Alarm Fire**: No `DN|RECEIVE_START` logs appear when alarms fire automatically
|
||||
3. **Missing ID in Intent**: When manually tested, receiver shows `DN|RECEIVE_ERR missing_id` (separate issue but related)
|
||||
|
||||
---
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Receiver Configuration
|
||||
|
||||
**File**: `android/app/src/main/AndroidManifest.xml`
|
||||
|
||||
```xml
|
||||
<receiver
|
||||
android:name="org.timesafari.dailynotification.DailyNotificationReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="org.timesafari.daily.NOTIFICATION" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
```
|
||||
|
||||
- ✅ `exported="true"` is set (required for AlarmManager broadcasts)
|
||||
- ✅ Intent action matches: `org.timesafari.daily.NOTIFICATION`
|
||||
- ✅ Receiver is inside `<application>` tag
|
||||
|
||||
### Alarm Scheduling Evidence
|
||||
|
||||
From logs when scheduling (23:51:32):
|
||||
```
|
||||
I DNP-SCHEDULE: Scheduling OS alarm: variant=ALARM_CLOCK, action=org.timesafari.daily.NOTIFICATION, triggerTime=1770105300000, requestCode=44490, scheduleId=timesafari_daily_reminder
|
||||
I DNP-NOTIFY: Alarm clock scheduled (setAlarmClock): triggerAt=1770105300000, requestCode=44490
|
||||
```
|
||||
|
||||
From `dumpsys alarm` output:
|
||||
```
|
||||
RTC_WAKEUP #36: Alarm{7a8fb5e type 0 origWhen 1770148800000 whenElapsed 122488536 app.timesafari.app}
|
||||
tag=*walarm*:org.timesafari.daily.NOTIFICATION
|
||||
type=RTC_WAKEUP origWhen=2026-02-03 12:00:00.000 window=0 exactAllowReason=policy_permission
|
||||
operation=PendingIntent{6fce955: PendingIntentRecord{5856f6a app.timesafari.app broadcastIntent}}
|
||||
```
|
||||
|
||||
### Alarm Firing Evidence
|
||||
|
||||
- Alarm scheduled for 23:55:00 (timestamp: 1770105300000)
|
||||
- At 23:55:00, alarm is no longer in `dumpsys alarm` (confirmed it fired)
|
||||
- **No `DN|RECEIVE_START` log at 23:55:00** (receiver was not triggered)
|
||||
|
||||
### Manual Broadcast Test (Works)
|
||||
|
||||
```bash
|
||||
adb shell am broadcast -a org.timesafari.daily.NOTIFICATION -n app.timesafari.app/org.timesafari.dailynotification.DailyNotificationReceiver
|
||||
```
|
||||
|
||||
**Result**: ✅ Receiver triggered successfully
|
||||
```
|
||||
02-02 23:46:07.505 DailyNotificationReceiver D DN|RECEIVE_START action=org.timesafari.daily.NOTIFICATION
|
||||
02-02 23:46:07.506 DailyNotificationReceiver W DN|RECEIVE_ERR missing_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
The issue appears to be in how the PendingIntent is created when scheduling alarms. Possible causes:
|
||||
|
||||
### Hypothesis 1: PendingIntent Not Targeting Receiver Correctly
|
||||
|
||||
The PendingIntent may be created without explicitly specifying the component, causing Android to not match it to the receiver when the alarm fires.
|
||||
|
||||
**Expected Fix**: When creating the PendingIntent for AlarmManager, explicitly set the component:
|
||||
|
||||
```kotlin
|
||||
val intent = Intent("org.timesafari.daily.NOTIFICATION").apply {
|
||||
setComponent(ComponentName(context, DailyNotificationReceiver::class.java))
|
||||
putExtra("id", scheduleId) // Also fix missing_id issue
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
```
|
||||
|
||||
### Hypothesis 2: PendingIntent Flags Issue
|
||||
|
||||
The PendingIntent may be created with incorrect flags that prevent delivery when the app is in certain states.
|
||||
|
||||
**Check**: Ensure flags include:
|
||||
- `FLAG_UPDATE_CURRENT` or `FLAG_CANCEL_CURRENT`
|
||||
- `FLAG_IMMUTABLE` (required on Android 12+)
|
||||
|
||||
### Hypothesis 3: Package/Component Mismatch
|
||||
|
||||
The PendingIntent may be created with a different package name or component than what's registered in the manifest.
|
||||
|
||||
**Check**: Verify the package name in the Intent matches `app.timesafari.app` and the component matches the receiver class.
|
||||
|
||||
---
|
||||
|
||||
## Additional Issue: Missing ID in Intent
|
||||
|
||||
When the receiver IS triggered (manually), it shows:
|
||||
```
|
||||
DN|RECEIVE_ERR missing_id
|
||||
```
|
||||
|
||||
This indicates the Intent extras don't include the `scheduleId`. The plugin should add the ID to the Intent when creating the PendingIntent:
|
||||
|
||||
```kotlin
|
||||
intent.putExtra("id", scheduleId)
|
||||
// or
|
||||
intent.putExtra("scheduleId", scheduleId) // if receiver expects different key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Steps for Plugin Fix
|
||||
|
||||
1. **Verify PendingIntent Creation**:
|
||||
- Check the code that creates PendingIntent for AlarmManager
|
||||
- Ensure component is explicitly set
|
||||
- Ensure ID is added to Intent extras
|
||||
|
||||
2. **Test Alarm Delivery**:
|
||||
- Schedule an alarm for 1-2 minutes in the future
|
||||
- Monitor logs: `adb logcat | grep -E "DN|RECEIVE_START|DailyNotification"`
|
||||
- Verify `DN|RECEIVE_START` appears when alarm fires
|
||||
- Verify no `missing_id` error
|
||||
|
||||
3. **Test Different App States**:
|
||||
- App in foreground
|
||||
- App in background
|
||||
- App force-closed
|
||||
- Device in doze mode (if possible on emulator)
|
||||
|
||||
4. **Compare with Manual Broadcast**:
|
||||
- Manual broadcast works → receiver is fine
|
||||
- Alarm broadcast doesn't work → PendingIntent creation is the issue
|
||||
|
||||
---
|
||||
|
||||
## Files to Check in Plugin
|
||||
|
||||
1. **Alarm Scheduling Code**: Where `setAlarmClock()` or `setExact()` is called
|
||||
2. **PendingIntent Creation**: Where `PendingIntent.getBroadcast()` is called
|
||||
3. **Intent Creation**: Where the Intent for the alarm is created
|
||||
4. **Receiver Code**: Verify what Intent extras it expects (for missing_id fix)
|
||||
|
||||
---
|
||||
|
||||
## Related Configuration
|
||||
|
||||
### AndroidManifest.xml (App Side)
|
||||
- ✅ Receiver exported="true"
|
||||
- ✅ Correct intent action
|
||||
- ✅ Receiver inside application tag
|
||||
|
||||
### Permissions (App Side)
|
||||
- ✅ POST_NOTIFICATIONS
|
||||
- ✅ SCHEDULE_EXACT_ALARM
|
||||
- ✅ RECEIVE_BOOT_COMPLETED
|
||||
- ✅ WAKE_LOCK
|
||||
- ❌ USE_EXACT_ALARM -- must not use; see note below
|
||||
|
||||
> **Note on `USE_EXACT_ALARM`:** The `USE_EXACT_ALARM` permission is restricted
|
||||
> by Google on Android. Apps that declare it must be primarily dedicated to alarm
|
||||
> or calendar functionality. Google will reject apps from the Play Store that use
|
||||
> this permission for other purposes. This plugin uses `SCHEDULE_EXACT_ALARM`
|
||||
> instead, which is sufficient for scheduling daily notifications.
|
||||
|
||||
---
|
||||
|
||||
## Expected Behavior After Fix
|
||||
|
||||
When an alarm fires:
|
||||
1. AlarmManager delivers the broadcast
|
||||
2. `DailyNotificationReceiver.onReceive()` is called
|
||||
3. Log shows: `DN|RECEIVE_START action=org.timesafari.daily.NOTIFICATION`
|
||||
4. Receiver finds the ID in Intent extras (no `missing_id` error)
|
||||
5. Notification is displayed
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The `exported="true"` change in the app's manifest was necessary and correct
|
||||
- The issue is in the plugin's PendingIntent creation, not the app configuration
|
||||
- Manual broadcasts work, proving the receiver registration is correct
|
||||
- Alarms fire, proving AlarmManager scheduling is correct
|
||||
- The gap is in the PendingIntent → Receiver delivery
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Working Manual Test
|
||||
|
||||
```bash
|
||||
# This works - receiver is triggered
|
||||
adb shell am broadcast \
|
||||
-a org.timesafari.daily.NOTIFICATION \
|
||||
-n app.timesafari.app/org.timesafari.dailynotification.DailyNotificationReceiver \
|
||||
--es "id" "timesafari_daily_reminder"
|
||||
```
|
||||
|
||||
The plugin's PendingIntent should create an equivalent broadcast that AlarmManager can deliver.
|
||||
@@ -0,0 +1,312 @@
|
||||
# Daily Notification Plugin - Architecture Overview
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Vue Components │
|
||||
│ (PushNotificationPermission.vue, AccountViewView.vue, etc.) │
|
||||
└───────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ NotificationService (Factory) │
|
||||
│ - Platform detection via Capacitor API │
|
||||
│ - Singleton pattern │
|
||||
│ - Returns appropriate implementation │
|
||||
└───────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
┌───────────┴────────────┐
|
||||
▼ ▼
|
||||
┌───────────────────────────┐ ┌────────────────────────────┐
|
||||
│ NativeNotificationService │ │ WebPushNotificationService │
|
||||
│ │ │ │
|
||||
│ iOS/Android │ │ Web/PWA │
|
||||
│ - UNUserNotificationCenter│ │ - Web Push API │
|
||||
│ - NotificationManager │ │ - Service Workers │
|
||||
│ - AlarmManager │ │ - VAPID keys │
|
||||
│ - Background tasks │ │ - Push server │
|
||||
└─────────────┬─────────────┘ └────────────┬───────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ DailyNotificationPlugin│ │ Existing Web Push Logic │
|
||||
│ (Capacitor Plugin) │ │ (PushNotificationPermission)│
|
||||
│ │ │ │
|
||||
│ - Native iOS code │ │ - Service worker │
|
||||
│ - Native Android code │ │ - VAPID subscription │
|
||||
│ - SQLite storage │ │ - Push server integration │
|
||||
└─────────────────────────┘ └──────────────────────────────┘
|
||||
```
|
||||
|
||||
## Platform Decision Flow
|
||||
|
||||
```
|
||||
User Action: Schedule Notification
|
||||
│
|
||||
▼
|
||||
NotificationService.getInstance()
|
||||
│
|
||||
├──> Check: Capacitor.isNativePlatform()
|
||||
│
|
||||
┌────┴─────┐
|
||||
│ │
|
||||
YES NO
|
||||
│ │
|
||||
▼ ▼
|
||||
Native Web/PWA
|
||||
Service Service
|
||||
│ │
|
||||
▼ ▼
|
||||
Plugin Web Push
|
||||
```
|
||||
|
||||
## Data Flow Example: Scheduling a Notification
|
||||
|
||||
### Native Platform (iOS/Android)
|
||||
```
|
||||
1. User clicks "Enable Notifications"
|
||||
│
|
||||
2. PushNotificationPermission.vue
|
||||
│
|
||||
└─> NotificationService.getInstance()
|
||||
│
|
||||
└─> Returns NativeNotificationService (detected iOS/Android)
|
||||
│
|
||||
└─> nativeService.requestPermissions()
|
||||
│
|
||||
└─> DailyNotification.requestPermissions() [Capacitor Plugin]
|
||||
│
|
||||
└─> Native code requests OS permissions
|
||||
│
|
||||
└─> Returns: { granted: true/false }
|
||||
|
||||
3. User sets time & message
|
||||
│
|
||||
4. nativeService.scheduleDailyNotification({ time: '09:00', ... })
|
||||
│
|
||||
└─> DailyNotification.scheduleDailyReminder({ ... })
|
||||
│
|
||||
└─> Native code:
|
||||
- Stores in SQLite
|
||||
- Schedules AlarmManager (Android) or UNNotificationRequest (iOS)
|
||||
- Returns: success/failure
|
||||
|
||||
5. At 9:00 AM:
|
||||
- Android: AlarmManager triggers → DailyNotificationReceiver
|
||||
- iOS: UNUserNotificationCenter triggers notification
|
||||
- Notification appears even if app is closed
|
||||
```
|
||||
|
||||
### Web Platform
|
||||
```
|
||||
1. User clicks "Enable Notifications"
|
||||
│
|
||||
2. PushNotificationPermission.vue
|
||||
│
|
||||
└─> NotificationService.getInstance()
|
||||
│
|
||||
└─> Returns WebPushNotificationService (detected web)
|
||||
│
|
||||
└─> webService.requestPermissions()
|
||||
│
|
||||
└─> Notification.requestPermission() [Browser API]
|
||||
│
|
||||
└─> Returns: 'granted'/'denied'/'default'
|
||||
|
||||
3. User sets time & message
|
||||
│
|
||||
4. webService.scheduleDailyNotification({ ... })
|
||||
│
|
||||
└─> [TODO] Subscribe to push service with VAPID
|
||||
│
|
||||
└─> Send subscription to server with schedule time
|
||||
│
|
||||
└─> Server sends push at scheduled time
|
||||
│
|
||||
└─> Service worker receives → shows notification
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
src/
|
||||
├── plugins/
|
||||
│ └── DailyNotificationPlugin.ts [Plugin registration]
|
||||
│
|
||||
├── services/
|
||||
│ └── notifications/
|
||||
│ ├── index.ts [Barrel export]
|
||||
│ ├── NotificationService.ts [Factory + Interface]
|
||||
│ ├── NativeNotificationService.ts [iOS/Android impl]
|
||||
│ └── WebPushNotificationService.ts [Web impl stub]
|
||||
│
|
||||
├── components/
|
||||
│ └── PushNotificationPermission.vue [UI - to be updated]
|
||||
│
|
||||
└── views/
|
||||
└── AccountViewView.vue [Settings UI]
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. **Unified Interface**
|
||||
- Single `NotificationServiceInterface` for all platforms
|
||||
- Consistent API regardless of underlying implementation
|
||||
- Type-safe across TypeScript codebase
|
||||
|
||||
### 2. **Runtime Platform Detection**
|
||||
- No build-time configuration needed
|
||||
- Same code bundle for all platforms
|
||||
- Factory pattern selects implementation automatically
|
||||
|
||||
### 3. **Coexistence Strategy**
|
||||
- Web Push and Native run on different platforms
|
||||
- No conflicts - mutually exclusive at runtime
|
||||
- Allows gradual migration and testing
|
||||
|
||||
### 4. **Singleton Pattern**
|
||||
- One service instance per app lifecycle
|
||||
- Efficient resource usage
|
||||
- Consistent state management
|
||||
|
||||
## Permission Flow
|
||||
|
||||
### Android
|
||||
```
|
||||
App Launch
|
||||
↓
|
||||
Check if POST_NOTIFICATIONS granted (API 33+)
|
||||
│
|
||||
├─> YES: Ready to schedule
|
||||
│
|
||||
└─> NO: Request runtime permission
|
||||
↓
|
||||
Show system dialog
|
||||
↓
|
||||
User grants/denies
|
||||
↓
|
||||
Schedule notifications (if granted)
|
||||
```
|
||||
|
||||
### iOS
|
||||
```
|
||||
App Launch
|
||||
↓
|
||||
Check notification authorization status
|
||||
│
|
||||
├─> authorized: Ready to schedule
|
||||
│
|
||||
├─> notDetermined: Request permission
|
||||
│ ↓
|
||||
│ Show system dialog
|
||||
│ ↓
|
||||
│ User grants/denies
|
||||
│
|
||||
└─> denied: Guide user to Settings
|
||||
```
|
||||
|
||||
### Web
|
||||
```
|
||||
App Load
|
||||
↓
|
||||
Check Notification.permission
|
||||
│
|
||||
├─> "granted": Ready to subscribe
|
||||
│
|
||||
├─> "default": Request permission
|
||||
│ ↓
|
||||
│ Show browser prompt
|
||||
│ ↓
|
||||
│ User grants/denies
|
||||
│
|
||||
└─> "denied": Cannot show notifications
|
||||
```
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
```typescript
|
||||
// All methods return promises with success/failure
|
||||
try {
|
||||
const granted = await service.requestPermissions();
|
||||
if (granted) {
|
||||
const success = await service.scheduleDailyNotification({...});
|
||||
if (success) {
|
||||
// Show success message
|
||||
} else {
|
||||
// Show scheduling error
|
||||
}
|
||||
} else {
|
||||
// Show permission denied message
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error and show generic error message
|
||||
logger.error('Notification error:', error);
|
||||
}
|
||||
```
|
||||
|
||||
## Background Execution
|
||||
|
||||
### Native (iOS/Android)
|
||||
- ✅ Full background support
|
||||
- ✅ Survives app termination
|
||||
- ✅ Survives device reboot (with BootReceiver)
|
||||
- ✅ Exact alarm scheduling
|
||||
- ✅ Works offline
|
||||
|
||||
### Web/PWA
|
||||
- ⚠️ Limited background support
|
||||
- ⚠️ Requires active service worker
|
||||
- ⚠️ Browser/OS dependent
|
||||
- ❌ Needs network for delivery
|
||||
- ⚠️ iOS: Only on Home Screen PWAs (16.4+)
|
||||
|
||||
## Storage
|
||||
|
||||
### Native
|
||||
```
|
||||
DailyNotificationPlugin
|
||||
↓
|
||||
SQLite Database (Room/Core Data)
|
||||
↓
|
||||
Stores:
|
||||
- Schedule configurations
|
||||
- Content cache
|
||||
- Delivery history
|
||||
- Callback registrations
|
||||
```
|
||||
|
||||
### Web
|
||||
```
|
||||
Web Push
|
||||
↓
|
||||
IndexedDB (via Dexie)
|
||||
↓
|
||||
Stores:
|
||||
- Settings (notifyingNewActivityTime, etc.)
|
||||
- Push subscription info
|
||||
- VAPID keys
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Testing
|
||||
- Mock `Capacitor.isNativePlatform()` to test both paths
|
||||
- Test factory returns correct implementation
|
||||
- Test each service implementation independently
|
||||
|
||||
### Integration Testing
|
||||
- Test on actual devices (iOS/Android)
|
||||
- Test in browsers (Chrome, Safari, Firefox)
|
||||
- Verify notification delivery
|
||||
- Test permission flows
|
||||
|
||||
### E2E Testing
|
||||
- Schedule notification → Wait → Verify delivery
|
||||
- Test app restart scenarios
|
||||
- Test device reboot scenarios
|
||||
- Test permission denial recovery
|
||||
|
||||
---
|
||||
|
||||
**Key Takeaway**: The architecture provides a clean separation between platforms while maintaining a unified API for Vue components. Platform detection happens automatically at runtime, and the appropriate notification system is used transparently.
|
||||
@@ -0,0 +1,348 @@
|
||||
# Daily Notification Plugin - Integration Checklist
|
||||
|
||||
**Integration Date**: 2026-01-21
|
||||
**Plugin Version**: 1.0.11
|
||||
**Status**: Phase 1 Complete ✅
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Infrastructure Setup ✅ COMPLETE
|
||||
|
||||
### Code Files
|
||||
- [x] Created `src/plugins/DailyNotificationPlugin.ts`
|
||||
- [x] Created `src/services/notifications/NotificationService.ts`
|
||||
- [x] Created `src/services/notifications/NativeNotificationService.ts`
|
||||
- [x] Created `src/services/notifications/WebPushNotificationService.ts`
|
||||
- [x] Created `src/services/notifications/index.ts`
|
||||
|
||||
### Android Configuration
|
||||
|
||||
> **Note on `USE_EXACT_ALARM`:** The `USE_EXACT_ALARM` permission is restricted
|
||||
> by Google on Android. Apps that declare it must be primarily dedicated to alarm
|
||||
> or calendar functionality. Google will reject apps from the Play Store that use
|
||||
> this permission for other purposes. This plugin uses `SCHEDULE_EXACT_ALARM`
|
||||
> instead, which is sufficient for scheduling daily notifications.
|
||||
|
||||
- [x] Added permissions to `AndroidManifest.xml`:
|
||||
- [x] `POST_NOTIFICATIONS`
|
||||
- [x] `SCHEDULE_EXACT_ALARM`
|
||||
- [x] `RECEIVE_BOOT_COMPLETED`
|
||||
- [x] `WAKE_LOCK`
|
||||
- [ ] `USE_EXACT_ALARM` -- must avoid; see note above
|
||||
- [x] Registered receivers in `AndroidManifest.xml`:
|
||||
- [x] `DailyNotificationReceiver`
|
||||
- [x] `BootReceiver`
|
||||
- [x] Added dependencies to `build.gradle`:
|
||||
- [x] Room (`androidx.room:room-runtime:2.6.1`)
|
||||
- [x] WorkManager (`androidx.work:work-runtime-ktx:2.9.0`)
|
||||
- [x] Coroutines (`org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3`)
|
||||
- [x] Room Compiler (`androidx.room:room-compiler:2.6.1`)
|
||||
- [x] Registered plugin in `MainActivity.java`
|
||||
|
||||
### iOS Configuration
|
||||
- [x] Added to `Info.plist`:
|
||||
- [x] `UIBackgroundModes` (fetch, processing)
|
||||
- [x] `BGTaskSchedulerPermittedIdentifiers`
|
||||
- [x] `NSUserNotificationAlertStyle`
|
||||
- [ ] ⚠️ **MANUAL STEP**: Xcode capabilities (see Phase 5)
|
||||
|
||||
### Documentation
|
||||
- [x] Created `doc/daily-notification-plugin-integration.md`
|
||||
- [x] Created `doc/daily-notification-plugin-integration-summary.md`
|
||||
- [x] Created `doc/daily-notification-plugin-architecture.md`
|
||||
- [x] Created this checklist
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: UI Integration ⏳ TODO
|
||||
|
||||
### Update Components
|
||||
- [ ] Modify `PushNotificationPermission.vue`:
|
||||
- [ ] Import `NotificationService`
|
||||
- [ ] Replace direct web push calls with service methods
|
||||
- [ ] Add platform-aware messaging
|
||||
- [ ] Test permission flow
|
||||
- [ ] Test notification scheduling
|
||||
|
||||
### Update Views
|
||||
- [ ] Update `AccountViewView.vue`:
|
||||
- [ ] Use `NotificationService` for status checks
|
||||
- [ ] Add platform indicator
|
||||
- [ ] Test settings display
|
||||
|
||||
### Settings Integration
|
||||
- [ ] Verify settings save/load correctly:
|
||||
- [ ] `notifyingNewActivityTime` for native
|
||||
- [ ] `notifyingReminderMessage` for native
|
||||
- [ ] `notifyingReminderTime` for native
|
||||
- [ ] Existing web push settings preserved
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Web Push Integration ⏳ TODO
|
||||
|
||||
### Wire WebPushNotificationService
|
||||
- [ ] Extract subscription logic from `PushNotificationPermission.vue`
|
||||
- [ ] Implement `scheduleDailyNotification()` method
|
||||
- [ ] Implement `cancelDailyNotification()` method
|
||||
- [ ] Implement `getStatus()` method
|
||||
- [ ] Test web platform notification flow
|
||||
|
||||
### Server Integration
|
||||
- [ ] Verify web push server endpoints still work
|
||||
- [ ] Test subscription/unsubscription
|
||||
- [ ] Test scheduled message delivery
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Testing ⏳ TODO
|
||||
|
||||
### Desktop Development
|
||||
- [ ] Code compiles without errors
|
||||
- [ ] ESLint passes
|
||||
- [ ] TypeScript types are correct
|
||||
- [ ] Platform detection works in browser console
|
||||
|
||||
### Android Emulator
|
||||
- [ ] App builds successfully
|
||||
- [ ] Plugin loads without errors
|
||||
- [ ] Can open app and navigate
|
||||
- [ ] No JavaScript console errors
|
||||
|
||||
### Android Device (Real)
|
||||
- [ ] Request permissions dialog appears
|
||||
- [ ] Permissions can be granted
|
||||
- [ ] Schedule notification succeeds
|
||||
- [ ] Notification appears at scheduled time
|
||||
- [ ] Notification survives app close
|
||||
- [ ] Notification survives device reboot
|
||||
- [ ] Notification can be cancelled
|
||||
|
||||
### iOS Simulator
|
||||
- [ ] App builds successfully
|
||||
- [ ] Plugin loads without errors
|
||||
- [ ] Can open app and navigate
|
||||
- [ ] No JavaScript console errors
|
||||
|
||||
### iOS Device (Real)
|
||||
- [ ] Request permissions dialog appears
|
||||
- [ ] Permissions can be granted
|
||||
- [ ] Schedule notification succeeds
|
||||
- [ ] Notification appears at scheduled time
|
||||
- [ ] Background fetch works
|
||||
- [ ] Notification survives app close
|
||||
- [ ] Notification can be cancelled
|
||||
|
||||
### Web Browser
|
||||
- [ ] Existing web push still works
|
||||
- [ ] No JavaScript errors
|
||||
- [ ] Platform detection selects web service
|
||||
- [ ] Permission flow works
|
||||
- [ ] Subscription works
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: iOS Xcode Setup ⚠️ MANUAL REQUIRED
|
||||
|
||||
### Open Xcode Project
|
||||
```bash
|
||||
cd ios
|
||||
open App/App.xcodeproj
|
||||
```
|
||||
|
||||
### Configure Capabilities
|
||||
- [ ] Select "App" target in project navigator
|
||||
- [ ] Go to "Signing & Capabilities" tab
|
||||
- [ ] Click "+ Capability" button
|
||||
- [ ] Add "Background Modes":
|
||||
- [ ] Enable "Background fetch"
|
||||
- [ ] Enable "Background processing"
|
||||
- [ ] Click "+ Capability" button again
|
||||
- [ ] Add "Push Notifications" (if using remote notifications)
|
||||
|
||||
### Install CocoaPods
|
||||
```bash
|
||||
cd ios
|
||||
pod install
|
||||
cd ..
|
||||
```
|
||||
- [ ] Run `pod install` successfully
|
||||
- [ ] Verify `CapacitorDailyNotification` pod is installed
|
||||
|
||||
### Verify Configuration
|
||||
- [ ] Build succeeds in Xcode
|
||||
- [ ] No capability warnings
|
||||
- [ ] No pod errors
|
||||
- [ ] Can run on simulator
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Build & Deploy ⏳ TODO
|
||||
|
||||
### Sync Capacitor
|
||||
```bash
|
||||
npx cap sync
|
||||
```
|
||||
- [ ] Sync completes without errors
|
||||
- [ ] Plugin files copied to native projects
|
||||
|
||||
### Build Android
|
||||
```bash
|
||||
npm run build:android:debug
|
||||
```
|
||||
- [ ] Build succeeds
|
||||
- [ ] APK/AAB generated
|
||||
- [ ] Can install on device/emulator
|
||||
|
||||
### Build iOS
|
||||
```bash
|
||||
npm run build:ios:debug
|
||||
```
|
||||
- [ ] Build succeeds
|
||||
- [ ] IPA generated (if release)
|
||||
- [ ] Can install on device/simulator
|
||||
|
||||
### Test Production Builds
|
||||
- [ ] Android release build works
|
||||
- [ ] iOS release build works
|
||||
- [ ] Notifications work in production
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Checklist
|
||||
|
||||
### Android Issues
|
||||
|
||||
#### Notifications Not Appearing
|
||||
- [ ] Verified `DailyNotificationReceiver` is in AndroidManifest.xml
|
||||
- [ ] Checked logcat for errors: `adb logcat | grep DailyNotification`
|
||||
- [ ] Verified permissions granted in app settings
|
||||
- [ ] Checked "Exact alarms" permission (Android 12+)
|
||||
- [ ] Verified notification channel is created
|
||||
|
||||
#### Build Errors
|
||||
- [ ] Verified all dependencies in build.gradle
|
||||
- [ ] Ran `./gradlew clean` and rebuilt
|
||||
- [ ] Verified Kotlin version compatibility
|
||||
- [ ] Checked for conflicting dependencies
|
||||
|
||||
### iOS Issues
|
||||
|
||||
#### Notifications Not Appearing
|
||||
- [ ] Verified Background Modes enabled in Xcode
|
||||
- [ ] Checked Xcode console for errors
|
||||
- [ ] Verified permissions granted in Settings app
|
||||
- [ ] Tested on real device (not just simulator)
|
||||
- [ ] Checked BGTaskScheduler identifiers match Info.plist
|
||||
|
||||
#### Build Errors
|
||||
- [ ] Ran `pod install` successfully
|
||||
- [ ] Verified deployment target is iOS 13.0+
|
||||
- [ ] Checked for pod conflicts
|
||||
- [ ] Cleaned build folder (Xcode → Product → Clean Build Folder)
|
||||
|
||||
### Web Issues
|
||||
|
||||
#### Web Push Not Working
|
||||
- [ ] Verified service worker is registered
|
||||
- [ ] Checked browser console for errors
|
||||
- [ ] Verified VAPID keys are correct
|
||||
- [ ] Tested in supported browser (Chrome 42+, Firefox)
|
||||
- [ ] Checked push server is running
|
||||
|
||||
#### Permission Issues
|
||||
- [ ] Verified permissions not blocked in browser
|
||||
- [ ] Checked site settings in browser
|
||||
- [ ] Verified HTTPS connection (required for web push)
|
||||
|
||||
---
|
||||
|
||||
## Verification Commands
|
||||
|
||||
### Check Plugin is Installed
|
||||
```bash
|
||||
npm list @timesafari/daily-notification-plugin
|
||||
```
|
||||
|
||||
### Check Capacitor Sync
|
||||
```bash
|
||||
npx cap ls
|
||||
```
|
||||
|
||||
### Check Android Build
|
||||
```bash
|
||||
cd android
|
||||
./gradlew clean
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
### Check iOS Build
|
||||
```bash
|
||||
cd ios
|
||||
pod install
|
||||
xcodebuild -workspace App/App.xcworkspace -scheme App -configuration Debug build
|
||||
```
|
||||
|
||||
### Check TypeScript
|
||||
```bash
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
### Check Linting
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Immediate Actions
|
||||
|
||||
1. **Run Capacitor Sync**:
|
||||
```bash
|
||||
npx cap sync
|
||||
```
|
||||
|
||||
2. **For iOS Development**:
|
||||
```bash
|
||||
cd ios
|
||||
open App/App.xcodeproj
|
||||
# Enable Background Modes capability
|
||||
pod install
|
||||
cd ..
|
||||
```
|
||||
|
||||
3. **Test on Emulator/Simulator**:
|
||||
```bash
|
||||
npm run build:android:debug # For Android
|
||||
npm run build:ios:debug # For iOS
|
||||
```
|
||||
|
||||
4. **Update UI Components**:
|
||||
- Start with `PushNotificationPermission.vue`
|
||||
- Import and use `NotificationService`
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] **Phase 1**: All files created and configurations applied
|
||||
- [ ] **Phase 2**: Components use NotificationService
|
||||
- [ ] **Phase 3**: Web push integrated with service
|
||||
- [ ] **Phase 4**: All tests pass on all platforms
|
||||
- [ ] **Phase 5**: iOS capabilities configured in Xcode
|
||||
- [ ] **Phase 6**: Production builds work on real devices
|
||||
|
||||
---
|
||||
|
||||
## Questions or Issues?
|
||||
|
||||
See documentation:
|
||||
- Full guide: `doc/daily-notification-plugin-integration.md`
|
||||
- Architecture: `doc/daily-notification-plugin-architecture.md`
|
||||
- Summary: `doc/daily-notification-plugin-integration-summary.md`
|
||||
|
||||
Plugin docs: `node_modules/@timesafari/daily-notification-plugin/README.md`
|
||||
|
||||
---
|
||||
|
||||
**Current Status**: Ready for Phase 2 (UI Integration) 🚀
|
||||
@@ -0,0 +1,193 @@
|
||||
# Daily Notification Plugin Integration - Summary
|
||||
|
||||
**Date**: 2026-01-21
|
||||
**Status**: ✅ Phase 1 Complete
|
||||
**Next Phase**: UI Integration
|
||||
|
||||
---
|
||||
|
||||
## What Was Completed
|
||||
|
||||
### ✅ Plugin Infrastructure
|
||||
1. **Plugin Registration**: `src/plugins/DailyNotificationPlugin.ts`
|
||||
- Capacitor plugin registered with full TypeScript types
|
||||
- Native-only (iOS/Android)
|
||||
|
||||
2. **Service Abstraction**: `src/services/notifications/`
|
||||
- `NotificationService.ts` - Platform detection & factory
|
||||
- `NativeNotificationService.ts` - Native implementation
|
||||
- `WebPushNotificationService.ts` - Web stub (for future)
|
||||
- `index.ts` - Barrel export
|
||||
|
||||
3. **Android Configuration**:
|
||||
- ✅ Permissions added to `AndroidManifest.xml`
|
||||
- ✅ Receivers registered (DailyNotificationReceiver, BootReceiver)
|
||||
- ✅ Dependencies added to `build.gradle` (Room, WorkManager, Coroutines)
|
||||
- ✅ Plugin registered in `MainActivity.java`
|
||||
|
||||
4. **iOS Configuration**:
|
||||
- ✅ Background modes added to `Info.plist`
|
||||
- ✅ BGTaskScheduler identifiers configured
|
||||
- ⚠️ **Requires manual Xcode setup** (capabilities)
|
||||
|
||||
5. **Documentation**: `doc/daily-notification-plugin-integration.md`
|
||||
|
||||
---
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Notification System | Status |
|
||||
|----------|---------------------|--------|
|
||||
| **iOS** | Native (UNUserNotificationCenter) | ✅ Configured |
|
||||
| **Android** | Native (NotificationManager + AlarmManager) | ✅ Configured |
|
||||
| **Web/PWA** | Web Push (existing) | 🔄 Coexists, not yet wired |
|
||||
| **Electron** | Native (via Capacitor) | ✅ Ready |
|
||||
|
||||
**Key Feature**: Both systems coexist using runtime platform detection.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start Usage
|
||||
|
||||
```typescript
|
||||
import { NotificationService } from '@/services/notifications';
|
||||
|
||||
// Automatically uses native on iOS/Android, web push on web
|
||||
const service = NotificationService.getInstance();
|
||||
|
||||
// Request permissions
|
||||
const granted = await service.requestPermissions();
|
||||
|
||||
if (granted) {
|
||||
// Schedule daily notification at 9 AM
|
||||
await service.scheduleDailyNotification({
|
||||
time: '09:00',
|
||||
title: 'Daily Check-In',
|
||||
body: 'Time to check your TimeSafari activity'
|
||||
});
|
||||
}
|
||||
|
||||
// Check status
|
||||
const status = await service.getStatus();
|
||||
console.log('Notifications enabled:', status.enabled);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Immediate (Phase 2)
|
||||
1. **Update UI Components**:
|
||||
- Modify `PushNotificationPermission.vue` to use `NotificationService`
|
||||
- Add platform-aware messaging
|
||||
- Test on simulator/emulator
|
||||
|
||||
2. **iOS Xcode Setup** (Required):
|
||||
```bash
|
||||
cd ios
|
||||
open App/App.xcodeproj
|
||||
```
|
||||
- Enable "Background Modes" capability
|
||||
- Enable "Push Notifications" capability
|
||||
- Run `pod install`
|
||||
|
||||
### Short-term (Phase 3)
|
||||
3. **Wire Web Push**: Connect `WebPushNotificationService` to existing web push logic
|
||||
4. **Test on Devices**: Real iOS and Android devices
|
||||
5. **Update Settings**: Ensure notification preferences save correctly
|
||||
|
||||
---
|
||||
|
||||
## Build & Sync
|
||||
|
||||
```bash
|
||||
# Sync native projects with web code
|
||||
npx cap sync
|
||||
|
||||
# Build for Android
|
||||
npm run build:android:debug
|
||||
|
||||
# Build for iOS (after Xcode setup)
|
||||
cd ios && pod install && cd ..
|
||||
npm run build:ios:debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Important Notes
|
||||
|
||||
### ⚠️ Critical Requirements
|
||||
|
||||
**Android**:
|
||||
- `DailyNotificationReceiver` must be in AndroidManifest.xml (✅ done)
|
||||
- Runtime permissions needed for Android 13+ (API 33+)
|
||||
- Exact alarm permission for Android 12+ (API 31+)
|
||||
|
||||
**iOS**:
|
||||
- Background Modes capability must be enabled in Xcode (⚠️ manual)
|
||||
- BGTaskScheduler identifiers must match Info.plist (✅ done)
|
||||
- Test on real device (simulators have limitations)
|
||||
|
||||
**Web**:
|
||||
- Existing Web Push continues to work unchanged
|
||||
- No conflicts - platform detection ensures correct system
|
||||
|
||||
---
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### Created (8 files)
|
||||
- `src/plugins/DailyNotificationPlugin.ts`
|
||||
- `src/services/notifications/NotificationService.ts`
|
||||
- `src/services/notifications/NativeNotificationService.ts`
|
||||
- `src/services/notifications/WebPushNotificationService.ts`
|
||||
- `src/services/notifications/index.ts`
|
||||
- `doc/daily-notification-plugin-integration.md`
|
||||
- `doc/daily-notification-plugin-integration-summary.md`
|
||||
|
||||
### Modified (4 files)
|
||||
- `android/app/src/main/AndroidManifest.xml` - Permissions + Receivers
|
||||
- `android/app/build.gradle` - Dependencies
|
||||
- `android/app/src/main/java/app/timesafari/MainActivity.java` - Plugin registration
|
||||
- `ios/App/App/Info.plist` - Background modes + BGTaskScheduler
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Before Device Testing
|
||||
- [ ] Code compiles without errors
|
||||
- [ ] Platform detection logic verified
|
||||
- [ ] Service factory creates correct implementation
|
||||
|
||||
### Android Device
|
||||
- [ ] Request permissions (Android 13+)
|
||||
- [ ] Schedule notification
|
||||
- [ ] Notification appears at scheduled time
|
||||
- [ ] Notification survives app close
|
||||
- [ ] Notification survives device reboot
|
||||
|
||||
### iOS Device
|
||||
- [ ] Xcode capabilities enabled
|
||||
- [ ] Request permissions
|
||||
- [ ] Schedule notification
|
||||
- [ ] Notification appears at scheduled time
|
||||
- [ ] Background fetch works
|
||||
- [ ] Notification survives app close
|
||||
|
||||
### Web/PWA
|
||||
- [ ] Existing web push still works
|
||||
- [ ] No errors in console
|
||||
- [ ] Platform detection selects web implementation
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
See full documentation: `doc/daily-notification-plugin-integration.md`
|
||||
|
||||
Plugin README: `node_modules/@timesafari/daily-notification-plugin/README.md`
|
||||
|
||||
---
|
||||
|
||||
**Status**: Ready for Phase 2 (UI Integration) 🚀
|
||||
@@ -0,0 +1,237 @@
|
||||
# Daily Notification Plugin Integration
|
||||
|
||||
**Date**: 2026-01-21
|
||||
**Status**: ✅ Phase 1 Complete - Native Infrastructure
|
||||
**Integration Type**: Native + Web Coexistence
|
||||
|
||||
## Overview
|
||||
|
||||
The Daily Notification Plugin has been integrated to provide native notification functionality for iOS and Android while maintaining existing Web Push for web/PWA builds. The integration uses platform detection to automatically select the appropriate notification system at runtime.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### 1. **Plugin Registration** ✅
|
||||
- **File**: `src/plugins/DailyNotificationPlugin.ts`
|
||||
- Registered Capacitor plugin with proper TypeScript types
|
||||
- Native-only (no web implementation)
|
||||
|
||||
### 2. **Service Abstraction Layer** ✅
|
||||
Created unified notification service with platform-specific implementations:
|
||||
|
||||
- **`NotificationService.ts`**: Factory that selects implementation based on platform
|
||||
- **`NativeNotificationService.ts`**: Wraps DailyNotificationPlugin for iOS/Android
|
||||
- **`WebPushNotificationService.ts`**: Stub for future Web Push integration
|
||||
|
||||
**Location**: `src/services/notifications/`
|
||||
|
||||
**Key Features**:
|
||||
- Unified interface (`NotificationServiceInterface`)
|
||||
- Automatic platform detection via `Capacitor.isNativePlatform()`
|
||||
- Type-safe implementation
|
||||
- Singleton pattern for efficiency
|
||||
|
||||
### 3. **Android Configuration** ✅
|
||||
|
||||
**Modified Files**:
|
||||
- `android/app/src/main/AndroidManifest.xml`
|
||||
- `android/app/build.gradle`
|
||||
- `android/app/src/main/java/app/timesafari/MainActivity.java`
|
||||
|
||||
**Changes**:
|
||||
- ✅ Added notification permissions (POST_NOTIFICATIONS, SCHEDULE_EXACT_ALARM, etc.)
|
||||
- ✅ Registered `DailyNotificationReceiver` (critical for alarm delivery)
|
||||
- ✅ Registered `BootReceiver` (restores schedules after device restart)
|
||||
- ✅ Added Room, WorkManager, and Coroutines dependencies
|
||||
- ✅ Registered plugin in MainActivity
|
||||
|
||||
### 4. **iOS Configuration** ✅
|
||||
|
||||
**Modified Files**:
|
||||
- `ios/App/App/Info.plist`
|
||||
|
||||
**Changes**:
|
||||
- ✅ Added `UIBackgroundModes` (fetch, processing)
|
||||
- ✅ Added `BGTaskSchedulerPermittedIdentifiers` for background tasks
|
||||
- ✅ Added `NSUserNotificationAlertStyle` for alert-style notifications
|
||||
|
||||
**Still Required** (Manual in Xcode):
|
||||
- ⚠️ Enable "Background Modes" capability in Xcode
|
||||
- Background fetch
|
||||
- Background processing
|
||||
- ⚠️ Enable "Push Notifications" capability (if using remote notifications)
|
||||
|
||||
## Platform Behavior
|
||||
|
||||
| Platform | Implementation | Status |
|
||||
|----------|---------------|--------|
|
||||
| **iOS** | DailyNotificationPlugin (native) | ✅ Configured |
|
||||
| **Android** | DailyNotificationPlugin (native) | ✅ Configured |
|
||||
| **Web/PWA** | Web Push (existing) | 🔄 Not yet wired up |
|
||||
| **Electron** | Would use native | ✅ Ready |
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
import { NotificationService } from '@/services/notifications/NotificationService';
|
||||
|
||||
// Get the appropriate service for current platform
|
||||
const notificationService = NotificationService.getInstance();
|
||||
|
||||
// Check platform
|
||||
console.log('Platform:', NotificationService.getPlatform());
|
||||
console.log('Is native:', NotificationService.isNative());
|
||||
|
||||
// Request permissions
|
||||
const granted = await notificationService.requestPermissions();
|
||||
|
||||
if (granted) {
|
||||
// Schedule daily notification
|
||||
await notificationService.scheduleDailyNotification({
|
||||
time: '09:00',
|
||||
title: 'Daily Check-In',
|
||||
body: 'Time to check your TimeSafari activity',
|
||||
priority: 'normal'
|
||||
});
|
||||
}
|
||||
|
||||
// Check status
|
||||
const status = await notificationService.getStatus();
|
||||
console.log('Enabled:', status.enabled);
|
||||
console.log('Time:', status.scheduledTime);
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 2: UI Integration
|
||||
- [ ] Update `PushNotificationPermission.vue` to use `NotificationService`
|
||||
- [ ] Add platform-aware UI messaging
|
||||
- [ ] Update settings storage to work with both systems
|
||||
- [ ] Test notification scheduling UI
|
||||
|
||||
### Phase 3: Web Push Integration
|
||||
- [ ] Wire `WebPushNotificationService` to existing PushNotificationPermission logic
|
||||
- [ ] Extract web push subscription code into service methods
|
||||
- [ ] Test web platform notification flow
|
||||
|
||||
### Phase 4: Testing & Polish
|
||||
- [ ] Test on real iOS device
|
||||
- [ ] Test on real Android device (API 23+, API 33+)
|
||||
- [ ] Test permission flows
|
||||
- [ ] Test notification delivery
|
||||
- [ ] Test app restart/reboot scenarios
|
||||
- [ ] Verify background notification delivery
|
||||
|
||||
### Phase 5: Xcode Configuration (iOS Only)
|
||||
- [ ] Open `ios/App/App.xcodeproj` in Xcode
|
||||
- [ ] Select App target → Signing & Capabilities
|
||||
- [ ] Click "+ Capability" → Add "Background Modes"
|
||||
- Enable "Background fetch"
|
||||
- Enable "Background processing"
|
||||
- [ ] Click "+ Capability" → Add "Push Notifications" (if using remote)
|
||||
- [ ] Run `pod install` in `ios/` directory
|
||||
- [ ] Build and test on device
|
||||
|
||||
## Build Commands
|
||||
|
||||
### Sync Capacitor
|
||||
```bash
|
||||
npx cap sync
|
||||
# or
|
||||
npx cap sync android
|
||||
npx cap sync ios
|
||||
```
|
||||
|
||||
### Build Android
|
||||
```bash
|
||||
npm run build:android
|
||||
# or
|
||||
npm run build:android:debug
|
||||
```
|
||||
|
||||
### Build iOS
|
||||
```bash
|
||||
npm run build:ios
|
||||
# or after Xcode setup:
|
||||
cd ios && pod install && cd ..
|
||||
npm run build:ios:debug
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Android
|
||||
- **Critical**: `DailyNotificationReceiver` must be in AndroidManifest.xml
|
||||
- Android 12+ (API 31+) requires `SCHEDULE_EXACT_ALARM` permission
|
||||
- Android 13+ (API 33+) requires runtime `POST_NOTIFICATIONS` permission
|
||||
- BootReceiver restores schedules after device restart
|
||||
|
||||
### iOS
|
||||
- **Critical**: Background modes must be enabled in Xcode capabilities
|
||||
- iOS 13.0+ supported (already compatible with your deployment target)
|
||||
- Background tasks use `BGTaskScheduler`
|
||||
- User must grant notification permissions in Settings
|
||||
|
||||
### Web
|
||||
- Existing Web Push continues to work
|
||||
- No conflicts with native implementation
|
||||
- Platform detection ensures correct system is used
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Created
|
||||
- `src/plugins/DailyNotificationPlugin.ts`
|
||||
- `src/services/notifications/NotificationService.ts`
|
||||
- `src/services/notifications/NativeNotificationService.ts`
|
||||
- `src/services/notifications/WebPushNotificationService.ts`
|
||||
|
||||
### Modified
|
||||
- `android/app/src/main/AndroidManifest.xml`
|
||||
- `android/app/build.gradle`
|
||||
- `android/app/src/main/java/app/timesafari/MainActivity.java`
|
||||
- `ios/App/App/Info.plist`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Android: Notifications Not Appearing
|
||||
1. Check that `DailyNotificationReceiver` is registered in AndroidManifest.xml
|
||||
2. Verify permissions are requested at runtime (Android 13+)
|
||||
3. Check that notification channel is created
|
||||
4. Enable "Exact alarms" in app settings (Android 12+)
|
||||
|
||||
### iOS: Background Tasks Not Running
|
||||
1. Ensure Background Modes capability is enabled in Xcode
|
||||
2. Check that BGTaskScheduler identifiers match Info.plist
|
||||
3. Test on real device (simulator has limitations)
|
||||
4. Check iOS Settings → Notifications → TimeSafari
|
||||
|
||||
### Permission Issues
|
||||
1. Request permissions before scheduling: `requestPermissions()`
|
||||
2. Check permission status: `checkPermissions()`
|
||||
3. Guide users to system settings if denied
|
||||
|
||||
## Plugin Documentation
|
||||
|
||||
For complete plugin documentation, see:
|
||||
- Plugin README: `node_modules/@timesafari/daily-notification-plugin/README.md`
|
||||
- Plugin version: 1.0.11
|
||||
- Repository: https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Android: Notification appears at scheduled time
|
||||
- [ ] Android: Notification survives app close
|
||||
- [ ] Android: Notification survives device reboot
|
||||
- [ ] iOS: Notification appears at scheduled time
|
||||
- [ ] iOS: Background fetch works
|
||||
- [ ] iOS: Notification survives app close
|
||||
- [ ] Web: Existing web push still works
|
||||
- [ ] Platform detection works correctly
|
||||
- [ ] Permission requests work on all platforms
|
||||
- [ ] Status retrieval works correctly
|
||||
|
||||
## Current Status
|
||||
|
||||
✅ **Phase 1 Complete**: Native infrastructure configured
|
||||
🔄 **Phase 2 In Progress**: Ready for UI integration
|
||||
⏳ **Phase 3 Pending**: Web Push service integration
|
||||
⏳ **Phase 4 Pending**: Testing and validation
|
||||
⏳ **Phase 5 Pending**: Xcode capabilities setup
|
||||
@@ -80,7 +80,7 @@ installed by each developer. They are not automatically active.
|
||||
- Test files: `*.test.js`, `*.spec.ts`, `*.test.vue`
|
||||
- Scripts: `scripts/` directory
|
||||
- Test directories: `test-*` directories
|
||||
- Documentation: `docs/`, `*.md`, `*.txt`
|
||||
- Documentation: `doc/`, `*.md`, `*.txt`
|
||||
- Config files: `*.json`, `*.yml`, `*.yaml`
|
||||
- IDE files: `.cursor/` directory
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,152 @@
|
||||
# Options: expired JWT during background “New Activity” prefetch (mobile)
|
||||
|
||||
**Date:** 2026-03-26 17:29 PST
|
||||
**Audience:** TimeSafari / crowd-funder team; **Endorser server** maintainers (auth + API policy)
|
||||
**Context:** Android Capacitor app, `POST /api/v2/report/plansLastUpdatedBetween`, native `TimeSafariNativeFetcher` invoked from WorkManager at **T−5 minutes** before the daily notification.
|
||||
|
||||
---
|
||||
|
||||
## Problem (short)
|
||||
|
||||
New Activity notifications prefetch Endorser data in **background** (no JavaScript, no WebView). The HTTP client uses a **Bearer JWT** supplied earlier via `configureNativeFetcher` / `getHeaders(activeDid)`.
|
||||
|
||||
If the **access token’s `exp`** is **before** prefetch time, the API returns **400** with a body like:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "JWT failed verification: ... JWT has expired: exp: … < now: …",
|
||||
"code": "JWT_VERIFY_FAILED"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
We **cannot** rely on the user opening the app immediately before prefetch (T−5), so **client-only** mitigations (e.g. refresh JWT on app resume) **reduce** failures but **do not guarantee** a valid token for headless background work.
|
||||
|
||||
---
|
||||
|
||||
## Why this is different from normal in-app API calls
|
||||
|
||||
| In-app | Background prefetch |
|
||||
|--------|----------------------|
|
||||
| `getHeaders()` runs in JS when needed; user often recently active | WorkManager runs **without** Capacitor / passkey / session refresh |
|
||||
| Short TTL tokens are refreshed as the user uses the app | Same token may sit in native memory until **T−5** (or longer) |
|
||||
|
||||
So **server-side** and **architecture** choices matter for this feature.
|
||||
|
||||
---
|
||||
|
||||
## Options (for decision)
|
||||
|
||||
### 1. Increase access token TTL (Endorser / IdP)
|
||||
|
||||
**Idea:** Issue access JWTs with a longer `exp` so that **configure time → prefetch time** (often **5+ minutes**, sometimes **24h+** if the user rarely opens the app) usually still falls inside validity.
|
||||
|
||||
| Pros | Cons |
|
||||
|------|------|
|
||||
| Simple to explain; one policy change | Longer-lived bearer tokens increase risk if exfiltrated; mitigate with scope, rotation, monitoring |
|
||||
| No client protocol change | May not fit strict security posture without a dedicated scope |
|
||||
|
||||
**Endorser owner:** token lifetime, scopes, and whether a **dedicated** lifetime or scope for “mobile background read” is acceptable.
|
||||
|
||||
---
|
||||
|
||||
### 2. Scoped long-lived token for report reads only (Endorser)
|
||||
|
||||
**Idea:** Mint a **separate** access token (or sub-scope) valid only for **read-only report** endpoints (`plansLastUpdatedBetween`, etc.), with a **longer TTL** than the interactive session token.
|
||||
|
||||
| Pros | Cons |
|
||||
|------|------|
|
||||
| Limits blast radius vs “longer JWT for everything” | Requires auth model + issuance path; client must store/use this token only for prefetch |
|
||||
|
||||
**Endorser owner:** feasibility of **narrow scope** + **longer TTL** for this use case.
|
||||
|
||||
---
|
||||
|
||||
### 3. Refresh token or device grant (Endorser + mobile native)
|
||||
|
||||
**Idea:** Client stores a **refresh token** (or OAuth **device** grant) in **Android Keystore / iOS Keychain**. Before `plansLastUpdatedBetween`, **native** code (no JS) exchanges it for a **new access token**.
|
||||
|
||||
| Pros | Cons |
|
||||
|------|------|
|
||||
| Standard pattern; short TTL for access tokens remains | Endorser must support refresh (or equivalent); secure storage + rotation; **both** client and server work |
|
||||
| Works when app is backgrounded for days | Implementation cost on mobile |
|
||||
|
||||
**Endorser owner:** refresh endpoint, token rotation, revocation.
|
||||
**Mobile owner:** native fetch path, secure storage, failure handling.
|
||||
|
||||
---
|
||||
|
||||
### 4. Backend proxy / BFF (TimeSafari backend + Endorser)
|
||||
|
||||
**Idea:** Phone calls **your** backend with a **device session** (or FCM registration id); **server** uses **server-to-server** credentials or a **service account** to call Endorser. The device **never** sends an Endorser JWT for this path.
|
||||
|
||||
| Pros | Cons |
|
||||
|------|------|
|
||||
| No Endorser JWT lifetime problem on device | New service, auth, rate limits, privacy review |
|
||||
| Central place for logging, abuse control | Operational cost |
|
||||
|
||||
**Endorser owner:** partner / S2S auth model for the BFF.
|
||||
**Product team:** hosting and trust boundaries.
|
||||
|
||||
---
|
||||
|
||||
### 5. “Cron” or periodic jobs on the device to refresh JWT (JS)
|
||||
|
||||
**Idea:** Use something like a **cron** schedule to refresh tokens.
|
||||
|
||||
**Reality:** Scheduled **native** jobs can run, but **Capacitor / `getHeaders()` / passkey** do **not** run reliably in that context without waking the **WebView**. So **“cron”** only helps if refresh is **fully native** (see option 3) or you accept **unreliable** wake + JS.
|
||||
|
||||
**Not recommended** as the primary fix unless paired with **native refresh** or **server** changes.
|
||||
|
||||
---
|
||||
|
||||
### 6. Product / UX constraints (no server change)
|
||||
|
||||
**Idea:** Accept that **headless** API calls may fail if the session is stale; show **fallback** copy; or require “open app once per day” for best results.
|
||||
|
||||
| Pros | Cons |
|
||||
|------|------|
|
||||
| No Endorser change | Does not meet “API-driven notification” expectation for inactive users |
|
||||
|
||||
---
|
||||
|
||||
## Client-side mitigations already in play (not sufficient alone)
|
||||
|
||||
- **`configureNativeFetcherIfReady()`** after startup and when **Account** / identity is ready.
|
||||
- **`appStateChange` → `isActive`:** refresh native fetcher when the app returns to foreground (reduces staleness when the user **does** open the app).
|
||||
- **Error logging** of 400 bodies for diagnosis.
|
||||
|
||||
These **do not** guarantee a fresh JWT at **T−5** if the user never opens the app before prefetch.
|
||||
|
||||
---
|
||||
|
||||
## Suggested decision order
|
||||
|
||||
1. **Align on security posture:** Is a **longer TTL** or **scoped long-lived read token** acceptable for Endorser?
|
||||
2. If not, is **refresh token in native** (option 3) or **BFF** (option 4) on the roadmap?
|
||||
3. **Parallel:** UX fallback when API is unavailable (option 6) so the app never silently looks “broken.”
|
||||
|
||||
---
|
||||
|
||||
## References (this repo)
|
||||
|
||||
| Topic | Location |
|
||||
|--------|----------|
|
||||
| Native fetcher + JWT from `getHeaders` | `src/services/notifications/nativeFetcherConfig.ts` |
|
||||
| Android POST + errors | `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` |
|
||||
| Web `plansLastUpdatedBetween` + `afterId` | `src/libs/endorserServer.ts` (`getStarredProjectsWithChanges`) |
|
||||
| New Activity / dual schedule | `doc/notification-from-api-call.md`, `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` |
|
||||
|
||||
---
|
||||
|
||||
## Open questions for Endorser (server developer)
|
||||
|
||||
1. What is the **current access token TTL** and can it be **increased** for mobile clients, or **per-scope**?
|
||||
2. Is **refresh token** (or similar) available for **non-interactive** renewal?
|
||||
3. Would a **read-only** scope for `plansLastUpdatedBetween` with a **longer** lifetime be acceptable?
|
||||
4. Is there an existing **server-to-server** or **partner** path that a **BFF** could use instead of user JWT on device?
|
||||
|
||||
---
|
||||
|
||||
*This document is for internal planning and decision; update it when the team chooses an approach.*
|
||||
@@ -0,0 +1,139 @@
|
||||
# iOS Share Extension - Git Commit Guide
|
||||
|
||||
**Date:** 2025-01-27
|
||||
**Purpose:** Clarify which Xcode manual changes should be committed to the repository
|
||||
|
||||
## Quick Answer
|
||||
|
||||
**YES, most manual Xcode changes SHOULD be committed.** The only exceptions are user-specific settings that are already gitignored.
|
||||
|
||||
## What Gets Modified (and Should Be Committed)
|
||||
|
||||
When you create the Share Extension target and configure App Groups in Xcode, the following files are modified:
|
||||
|
||||
### 1. `ios/App/App.xcodeproj/project.pbxproj` ✅ **COMMIT THIS**
|
||||
|
||||
This is the main Xcode project file that tracks:
|
||||
- **New targets** (Share Extension target)
|
||||
- **File references** (which files belong to which targets)
|
||||
- **Build settings** (compiler flags, deployment targets, etc.)
|
||||
- **Build phases** (compile sources, link frameworks, etc.)
|
||||
- **Capabilities** (App Groups configuration)
|
||||
- **Target dependencies**
|
||||
|
||||
**This file IS tracked in git** (not in `.gitignore`), so changes should be committed.
|
||||
|
||||
### 2. Entitlements Files ✅ **COMMIT THESE**
|
||||
|
||||
When you enable App Groups capability, Xcode creates/modifies:
|
||||
- `ios/App/App/App.entitlements` (for main app)
|
||||
- `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` (for extension)
|
||||
|
||||
These files contain the App Group identifiers and should be committed.
|
||||
|
||||
### 3. Share Extension Source Files ✅ **ALREADY COMMITTED**
|
||||
|
||||
The following files are already in the repo:
|
||||
- `ios/App/TimeSafariShareExtension/ShareViewController.swift`
|
||||
- `ios/App/TimeSafariShareExtension/Info.plist`
|
||||
- `ios/App/App/ShareImageBridge.swift`
|
||||
|
||||
These should already be committed (they were created as part of the implementation).
|
||||
|
||||
## What Should NOT Be Committed
|
||||
|
||||
### 1. User-Specific Settings ❌ **ALREADY GITIGNORED**
|
||||
|
||||
These are in `ios/.gitignore`:
|
||||
- `xcuserdata/` - User-specific scheme selections, breakpoints, etc.
|
||||
- `*.xcuserstate` - User's current Xcode state
|
||||
|
||||
### 2. Signing Identities ❌ **USER-SPECIFIC**
|
||||
|
||||
While the **App Groups capability** should be committed (it's in `project.pbxproj` and entitlements), your **personal signing identity/team** is user-specific and Xcode handles this automatically per developer.
|
||||
|
||||
## What Happens When You Commit
|
||||
|
||||
When you commit the changes:
|
||||
|
||||
1. **Other developers** who pull the changes will:
|
||||
- ✅ Get the new Share Extension target automatically
|
||||
- ✅ Get the App Groups capability configuration
|
||||
- ✅ Get file references and build settings
|
||||
- ✅ See the Share Extension in their Xcode project
|
||||
|
||||
2. **They will still need to:**
|
||||
- Configure their own signing team/identity (Xcode prompts for this)
|
||||
- Build the project (which may trigger CocoaPods updates)
|
||||
- But they **won't** need to manually create the target or configure App Groups
|
||||
|
||||
## Step-by-Step: What to Commit
|
||||
|
||||
After completing the Xcode setup steps:
|
||||
|
||||
```bash
|
||||
# Check what changed
|
||||
git status
|
||||
|
||||
# You should see:
|
||||
# - ios/App/App.xcodeproj/project.pbxproj (modified)
|
||||
# - ios/App/App/App.entitlements (new or modified)
|
||||
# - ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements (new)
|
||||
# - Possibly other project-related files
|
||||
|
||||
# Review the changes
|
||||
git diff ios/App/App.xcodeproj/project.pbxproj
|
||||
|
||||
# Commit the changes
|
||||
git add ios/App/App.xcodeproj/project.pbxproj
|
||||
git add ios/App/App/App.entitlements
|
||||
git add ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements
|
||||
git commit -m "Add iOS Share Extension target and App Groups configuration"
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Merge Conflicts in project.pbxproj
|
||||
|
||||
The `project.pbxproj` file can have merge conflicts because:
|
||||
- It's auto-generated by Xcode
|
||||
- Multiple developers might modify it
|
||||
- It uses UUIDs that can conflict
|
||||
|
||||
**If you get merge conflicts:**
|
||||
1. Open the project in Xcode
|
||||
2. Xcode will often auto-resolve conflicts
|
||||
3. Or manually resolve by keeping both sets of changes
|
||||
4. Test that the project builds
|
||||
|
||||
### Team/Developer IDs
|
||||
|
||||
The `DEVELOPMENT_TEAM` setting in `project.pbxproj` might be user-specific:
|
||||
- Some teams commit this (if everyone uses the same team)
|
||||
- Some teams use `.xcconfig` files to override per developer
|
||||
- Check with your team's practices
|
||||
|
||||
If you see `DEVELOPMENT_TEAM = GM3FS5JQPH;` in the project file, this is already committed, so your team likely commits team IDs.
|
||||
|
||||
## Verification
|
||||
|
||||
After committing, verify that:
|
||||
1. The Share Extension target appears in Xcode for other developers
|
||||
2. App Groups capability is configured
|
||||
3. The project builds successfully
|
||||
4. No user-specific files were accidentally committed
|
||||
|
||||
## Summary
|
||||
|
||||
| Change Type | Commit? | Reason |
|
||||
|------------|---------|--------|
|
||||
| New target creation | ✅ Yes | Modifies `project.pbxproj` |
|
||||
| App Groups capability | ✅ Yes | Creates/modifies entitlements files |
|
||||
| File target membership | ✅ Yes | Modifies `project.pbxproj` |
|
||||
| Build settings | ✅ Yes | Modifies `project.pbxproj` |
|
||||
| Source files (Swift, plist) | ✅ Yes | Already in repo |
|
||||
| User scheme selections | ❌ No | In `xcuserdata/` (gitignored) |
|
||||
| Personal signing identity | ⚠️ Maybe | Depends on team practice |
|
||||
|
||||
**Bottom line:** Commit all the Xcode project configuration changes. Other developers will get the Share Extension target automatically when they pull, and they'll only need to configure their personal signing settings.
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
# iOS Share Extension Improvements
|
||||
|
||||
**Date:** 2025-11-24
|
||||
**Purpose:** Explore alternatives to improve user experience by eliminating interstitial UI and simplifying app launch mechanism
|
||||
|
||||
## Current Implementation Issues
|
||||
|
||||
1. **Interstitial UI**: Users see `SLComposeServiceViewController` with a "Post" button before the app opens
|
||||
2. **Deep Link Dependency**: App relies on deep link (`timesafari://shared-photo`) to detect shared images, even though data is already in App Group
|
||||
|
||||
## Improvement 1: Skip Interstitial UI
|
||||
|
||||
### Current Approach
|
||||
- Uses `SLComposeServiceViewController` which shows a UI with "Post" button
|
||||
- User must tap "Post" to proceed
|
||||
|
||||
### Alternative: Custom UIViewController (Headless Processing)
|
||||
|
||||
Replace `SLComposeServiceViewController` with a custom `UIViewController` that:
|
||||
- Processes the image immediately in `viewDidLoad`
|
||||
- Shows no UI (or minimal loading indicator)
|
||||
- Opens the app automatically
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```swift
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
private let appGroupIdentifier = "group.app.timesafari.share"
|
||||
private let sharedPhotoBase64Key = "sharedPhotoBase64"
|
||||
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Process image immediately without showing UI
|
||||
processAndOpenApp()
|
||||
}
|
||||
|
||||
private func processAndOpenApp() {
|
||||
guard let extensionContext = extensionContext,
|
||||
let inputItems = extensionContext.inputItems as? [NSExtensionItem] else {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
|
||||
processSharedImage(from: inputItems) { [weak self] success in
|
||||
guard let self = self else {
|
||||
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
|
||||
if success {
|
||||
self.openMainApp()
|
||||
}
|
||||
|
||||
// Complete immediately - no UI shown
|
||||
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
|
||||
// ... (same implementation as current)
|
||||
}
|
||||
|
||||
private func openMainApp() {
|
||||
guard let url = URL(string: "timesafari://shared-photo") else {
|
||||
return
|
||||
}
|
||||
|
||||
var responder: UIResponder? = self
|
||||
while responder != nil {
|
||||
if let application = responder as? UIApplication {
|
||||
application.open(url, options: [:], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
responder = responder?.next
|
||||
}
|
||||
|
||||
extensionContext?.open(url, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Info.plist Changes:**
|
||||
- Already configured correctly with `NSExtensionPrincipalClass`
|
||||
- No storyboard needed (already removed)
|
||||
|
||||
**Benefits:**
|
||||
- ✅ No interstitial UI - app opens immediately
|
||||
- ✅ Faster user experience
|
||||
- ✅ More seamless integration
|
||||
|
||||
**Considerations:**
|
||||
- ⚠️ User has less control (can't cancel easily)
|
||||
- ⚠️ No visual feedback during processing (could add minimal loading indicator)
|
||||
- ⚠️ Apple guidelines: Extensions should provide value even if they don't open the app
|
||||
|
||||
## Improvement 2: Direct App Launch Without Deep Link
|
||||
|
||||
### Current Approach
|
||||
- Share Extension stores data in App Group UserDefaults
|
||||
- Share Extension opens app via deep link (`timesafari://shared-photo`)
|
||||
- App receives deep link → checks App Group → processes image
|
||||
|
||||
### Alternative: App Lifecycle Detection
|
||||
|
||||
Instead of using deep links, the app can check for shared data when it becomes active:
|
||||
|
||||
**Option A: Check on App Activation**
|
||||
|
||||
```swift
|
||||
// In AppDelegate.swift
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Check for shared image from Share Extension
|
||||
if let sharedData = getSharedImageData() {
|
||||
// Store in temp file for JS to read
|
||||
writeSharedImageToTempFile(sharedData)
|
||||
|
||||
// Navigate to shared-photo route directly
|
||||
// This would need to be handled in JS layer
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Option B: Use Notification (More Reliable)**
|
||||
|
||||
```swift
|
||||
// In ShareViewController.swift (after storing data)
|
||||
private func openMainApp() {
|
||||
// Store a flag that image is ready
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
return
|
||||
}
|
||||
userDefaults.set(true, forKey: "sharedPhotoReady")
|
||||
userDefaults.synchronize()
|
||||
|
||||
// Open app (can use any URL scheme or even just launch the app)
|
||||
guard let url = URL(string: "timesafari://") else {
|
||||
return
|
||||
}
|
||||
|
||||
var responder: UIResponder? = self
|
||||
while responder != nil {
|
||||
if let application = responder as? UIApplication {
|
||||
application.open(url, options: [:], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
responder = responder?.next
|
||||
}
|
||||
}
|
||||
|
||||
// In AppDelegate.swift
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
let appGroupIdentifier = "group.app.timesafari.share"
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if shared photo is ready
|
||||
if userDefaults.bool(forKey: "sharedPhotoReady") {
|
||||
userDefaults.removeObject(forKey: "sharedPhotoReady")
|
||||
userDefaults.synchronize()
|
||||
|
||||
// Process shared image
|
||||
if let sharedData = getSharedImageData() {
|
||||
writeSharedImageToTempFile(sharedData)
|
||||
|
||||
// Trigger JS to check for shared image
|
||||
// This could be done via Capacitor App plugin or custom event
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Option C: Check on App Launch (Most Direct)**
|
||||
|
||||
```swift
|
||||
// In AppDelegate.swift
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Check for shared image immediately on launch
|
||||
checkForSharedImageOnLaunch()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Also check when app becomes active (in case it was already running)
|
||||
checkForSharedImageOnLaunch()
|
||||
}
|
||||
|
||||
private func checkForSharedImageOnLaunch() {
|
||||
if let sharedData = getSharedImageData() {
|
||||
writeSharedImageToTempFile(sharedData)
|
||||
|
||||
// Post a notification or use Capacitor to notify JS
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SharedPhotoReady"), object: nil)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**JavaScript Integration:**
|
||||
|
||||
```typescript
|
||||
// In main.capacitor.ts
|
||||
import { App } from '@capacitor/app';
|
||||
|
||||
// Listen for app becoming active
|
||||
App.addListener('appStateChange', async ({ isActive }) => {
|
||||
if (isActive) {
|
||||
// Check for shared image when app becomes active
|
||||
await checkAndStoreNativeSharedImage();
|
||||
}
|
||||
});
|
||||
|
||||
// Also check on initial load
|
||||
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'ios') {
|
||||
checkAndStoreNativeSharedImage().then(result => {
|
||||
if (result.success) {
|
||||
// Navigate to shared-photo route
|
||||
router.push('/shared-photo' + (result.fileName ? `?fileName=${result.fileName}` : ''));
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ No deep link routing needed
|
||||
- ✅ More direct data flow
|
||||
- ✅ App can detect shared content even if it was already running
|
||||
- ✅ Simpler URL scheme handling
|
||||
|
||||
**Considerations:**
|
||||
- ⚠️ Need to ensure app checks on both launch and activation
|
||||
- ⚠️ May need to handle race conditions (app launching vs. share extension writing)
|
||||
- ⚠️ Still need some way to open the app (minimal URL scheme still required)
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
**Best of Both Worlds:**
|
||||
|
||||
1. **Use Custom UIViewController** (Improvement 1) - Eliminates interstitial UI
|
||||
2. **Use App Lifecycle Detection** (Improvement 2, Option C) - Direct data flow
|
||||
|
||||
**Combined Implementation:**
|
||||
|
||||
```swift
|
||||
// ShareViewController.swift - Custom UIViewController
|
||||
class ShareViewController: UIViewController {
|
||||
// Process immediately in viewDidLoad
|
||||
// Store data in App Group
|
||||
// Open app with minimal URL (just "timesafari://")
|
||||
}
|
||||
|
||||
// AppDelegate.swift
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Check for shared image
|
||||
// If found, write to temp file and let JS handle navigation
|
||||
}
|
||||
```
|
||||
|
||||
**JavaScript:**
|
||||
```typescript
|
||||
// Check on app activation
|
||||
App.addListener('appStateChange', async ({ isActive }) => {
|
||||
if (isActive) {
|
||||
const result = await checkAndStoreNativeSharedImage();
|
||||
if (result.success) {
|
||||
router.push('/shared-photo' + (result.fileName ? `?fileName=${result.fileName}` : ''));
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
This approach:
|
||||
- ✅ No interstitial UI
|
||||
- ✅ No deep link routing complexity
|
||||
- ✅ Direct data flow via App Group
|
||||
- ✅ Works whether app is running or launching fresh
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# iOS Share Extension Setup Instructions
|
||||
|
||||
**Date:** 2025-01-27
|
||||
**Purpose:** Step-by-step instructions for setting up the iOS Share Extension in Xcode
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Xcode installed
|
||||
- iOS project already set up with Capacitor
|
||||
- Access to Apple Developer account (for App Groups)
|
||||
|
||||
## Step 1: Create Share Extension Target
|
||||
|
||||
1. Open `ios/App/App.xcodeproj` in Xcode
|
||||
2. In the Project Navigator, select the **App** project (top-level item)
|
||||
3. Click the **+** button at the bottom of the Targets list
|
||||
4. Select **iOS** → **Share Extension**
|
||||
5. Click **Next**
|
||||
6. Configure:
|
||||
- **Product Name:** `TimeSafariShareExtension`
|
||||
- **Bundle Identifier:** `app.timesafari.shareextension` (must match main app's bundle ID with `.shareextension` suffix)
|
||||
- **Language:** Swift
|
||||
7. Click **Finish**
|
||||
|
||||
## Step 2: Configure Share Extension Files
|
||||
|
||||
The following files have been created in `ios/App/TimeSafariShareExtension/`:
|
||||
|
||||
- `ShareViewController.swift` - Main extension logic
|
||||
- `Info.plist` - Extension configuration
|
||||
|
||||
**Verify these files exist and are added to the Share Extension target.**
|
||||
|
||||
## Step 3: Configure App Groups
|
||||
|
||||
App Groups allow the Share Extension and main app to share data.
|
||||
|
||||
### For Main App Target:
|
||||
|
||||
1. Select the **App** target in Xcode
|
||||
2. Go to **Signing & Capabilities** tab
|
||||
3. Click **+ Capability**
|
||||
4. Select **App Groups**
|
||||
5. Click **+** to add a new group
|
||||
6. Enter: `group.app.timesafari.share`
|
||||
7. Ensure it's checked/enabled
|
||||
|
||||
### For Share Extension Target:
|
||||
|
||||
1. Select the **TimeSafariShareExtension** target
|
||||
2. Go to **Signing & Capabilities** tab
|
||||
3. Click **+ Capability**
|
||||
4. Select **App Groups**
|
||||
5. Click **+** to add a new group
|
||||
6. Enter: `group.app.timesafari.share` (same as main app)
|
||||
7. Ensure it's checked/enabled
|
||||
|
||||
**Important:** Both targets must use the **exact same** App Group identifier.
|
||||
|
||||
## Step 4: Configure Share Extension Info.plist
|
||||
|
||||
The `Info.plist` file should already be configured, but verify:
|
||||
|
||||
1. Select `TimeSafariShareExtension/Info.plist` in Xcode
|
||||
2. Ensure it contains:
|
||||
- `NSExtensionPointIdentifier` = `com.apple.share-services`
|
||||
- `NSExtensionPrincipalClass` = `$(PRODUCT_MODULE_NAME).ShareViewController`
|
||||
- `NSExtensionActivationSupportsImageWithMaxCount` = `1`
|
||||
|
||||
## Step 5: Add ShareImageBridge to Main App
|
||||
|
||||
1. The file `ios/App/App/ShareImageBridge.swift` has been created
|
||||
2. Ensure it's added to the **App** target (not the Share Extension target)
|
||||
3. In Xcode, select the file and check the **Target Membership** in the File Inspector
|
||||
|
||||
## Step 6: Build and Test
|
||||
|
||||
1. Select the **App** scheme (not the Share Extension scheme)
|
||||
2. Build and run on a device or simulator
|
||||
3. Open Photos app
|
||||
4. Select an image
|
||||
5. Tap **Share** button
|
||||
6. Look for **TimeSafari Share** in the share sheet
|
||||
7. Select it
|
||||
8. The app should open and navigate to the shared photo view
|
||||
|
||||
## Step 7: Troubleshooting
|
||||
|
||||
### Share Extension doesn't appear in share sheet
|
||||
|
||||
- Verify the Share Extension target builds successfully
|
||||
- Check that `Info.plist` is correctly configured
|
||||
- Ensure the extension's bundle identifier follows the pattern: `{main-app-bundle-id}.shareextension`
|
||||
- Clean build folder (Product → Clean Build Folder)
|
||||
|
||||
### App Group access fails
|
||||
|
||||
- Verify both targets have the same App Group identifier
|
||||
- Check that App Groups capability is enabled for both targets
|
||||
- Ensure you're signed in with a valid Apple Developer account
|
||||
- For development, you may need to enable App Groups in your Apple Developer account
|
||||
|
||||
### Shared image not appearing
|
||||
|
||||
- Check Xcode console for errors
|
||||
- Verify `ShareViewController.swift` is correctly implemented
|
||||
- Ensure the deep link `timesafari://shared-photo` is being handled
|
||||
- Check that the native bridge method is being called
|
||||
|
||||
### Build errors
|
||||
|
||||
- Ensure Swift version matches between targets
|
||||
- Check that all required frameworks are linked
|
||||
- Verify deployment targets match between main app and extension
|
||||
|
||||
## Step 8: Native Bridge Implementation (TODO)
|
||||
|
||||
Currently, the JavaScript code needs a way to call the native `getSharedImageData()` method. This requires one of:
|
||||
|
||||
1. **Option A:** Create a minimal Capacitor plugin
|
||||
2. **Option B:** Use Capacitor's existing bridge mechanisms
|
||||
3. **Option C:** Expose the method via a custom URL scheme parameter
|
||||
|
||||
The current implementation in `main.capacitor.ts` has a placeholder that needs to be completed.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After the Share Extension is set up and working:
|
||||
|
||||
1. Complete the native bridge implementation to read from App Group
|
||||
2. Test end-to-end flow: Share image → Extension stores → App reads → Displays
|
||||
3. Implement Android version
|
||||
4. Add error handling and edge cases
|
||||
|
||||
## References
|
||||
|
||||
- [Apple Share Extensions Documentation](https://developer.apple.com/documentation/social)
|
||||
- [App Groups Documentation](https://developer.apple.com/documentation/xcode/configuring-app-groups)
|
||||
- [Capacitor Native Bridge](https://capacitorjs.com/docs/guides/building-plugins)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# iOS Share Extension Implementation Status
|
||||
|
||||
**Date:** 2025-01-27
|
||||
**Status:** In Progress - Native Code Complete, Bridge Pending
|
||||
|
||||
## Completed
|
||||
|
||||
✅ **Share Extension Files Created:**
|
||||
- `ios/App/TimeSafariShareExtension/ShareViewController.swift` - Handles image sharing
|
||||
- `ios/App/TimeSafariShareExtension/Info.plist` - Extension configuration
|
||||
|
||||
✅ **Native Bridge Created:**
|
||||
- `ios/App/App/ShareImageBridge.swift` - Native method to read from App Group
|
||||
|
||||
✅ **JavaScript Integration Started:**
|
||||
- `src/services/nativeShareHandler.ts` - Service to handle native shared images
|
||||
- `src/main.capacitor.ts` - Updated to check for native shared images on deep link
|
||||
|
||||
✅ **Documentation:**
|
||||
- `doc/native-share-target-implementation.md` - Complete implementation guide
|
||||
- `doc/ios-share-extension-setup.md` - Xcode setup instructions
|
||||
|
||||
## Pending
|
||||
|
||||
⚠️ **Xcode Configuration (Manual Steps Required):**
|
||||
1. Create Share Extension target in Xcode
|
||||
2. Configure App Groups for both main app and extension
|
||||
3. Add ShareImageBridge.swift to App target
|
||||
4. Build and test
|
||||
|
||||
⚠️ **JavaScript-Native Bridge:**
|
||||
The current implementation has a placeholder for calling the native `ShareImageBridge.getSharedImageData()` method from JavaScript. This needs to be completed using one of:
|
||||
|
||||
**Option A: Minimal Capacitor Plugin** (Recommended for Option 1)
|
||||
- Create a small plugin that exposes the method
|
||||
- Clean and maintainable
|
||||
- Follows Capacitor patterns
|
||||
|
||||
**Option B: Direct Bridge Call**
|
||||
- Use Capacitor's executePlugin or similar mechanism
|
||||
- Requires understanding Capacitor's internal bridge
|
||||
- Less maintainable
|
||||
|
||||
**Option C: AppDelegate Integration**
|
||||
- Have AppDelegate check on launch and expose via a different mechanism
|
||||
- Workaround approach
|
||||
- Less clean but functional
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Complete Xcode Setup:**
|
||||
- Follow `doc/ios-share-extension-setup.md`
|
||||
- Create Share Extension target
|
||||
- Configure App Groups
|
||||
- Build and verify extension appears in share sheet
|
||||
|
||||
2. **Implement JavaScript-Native Bridge:**
|
||||
- Choose one of the options above
|
||||
- Complete the `checkAndStoreNativeSharedImage()` function in `main.capacitor.ts`
|
||||
- Test end-to-end flow
|
||||
|
||||
3. **Testing:**
|
||||
- Share image from Photos app
|
||||
- Verify Share Extension appears
|
||||
- Verify app opens and displays shared image
|
||||
- Test "Record Gift" and "Save as Profile" flows
|
||||
|
||||
## Current Flow
|
||||
|
||||
1. ✅ User shares image → Share Extension receives
|
||||
2. ✅ Share Extension converts to base64
|
||||
3. ✅ Share Extension stores in App Group UserDefaults
|
||||
4. ✅ Share Extension opens app with `timesafari://shared-photo?fileName=...`
|
||||
5. ⚠️ App receives deep link (handled)
|
||||
6. ⚠️ App checks App Group UserDefaults (bridge needed)
|
||||
7. ⚠️ App stores in temp database (pending bridge)
|
||||
8. ✅ SharedPhotoView reads from temp database (already works)
|
||||
|
||||
## Code Locations
|
||||
|
||||
- **Share Extension:** `ios/App/TimeSafariShareExtension/`
|
||||
- **Native Bridge:** `ios/App/App/ShareImageBridge.swift`
|
||||
- **JavaScript Handler:** `src/services/nativeShareHandler.ts`
|
||||
- **Deep Link Integration:** `src/main.capacitor.ts`
|
||||
- **View Component:** `src/views/SharedPhotoView.vue` (already complete)
|
||||
|
||||
## Notes
|
||||
|
||||
- The Share Extension code is complete and ready to use
|
||||
- The main missing piece is the JavaScript-to-native bridge
|
||||
- Once the bridge is complete, the entire flow should work end-to-end
|
||||
- The existing `SharedPhotoView.vue` doesn't need changes - it already handles images from temp storage
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
# Android Local Notification Testing — Planning Analysis
|
||||
|
||||
**Created:** 2026-06-02
|
||||
**Source document:** [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md)
|
||||
**Status:** Planning snapshot from 2026-06-02. Several iOS-guide assumptions (app **Refresh Notifications**, Simulate WAKEUP, `applyNotificationRefreshPayload`, WAKEUP_PING → `/notifications/refresh` → `api_*`) are **retired**. For current Android procedure see [local-android-testing-ngrok.md](./local-android-testing-ngrok.md). Do not treat the reuse tables below as the live app path.
|
||||
|
||||
**Purpose:** Plan a future **Android** counterpart guide by mapping what can be reused from the iOS ngrok workflow and what must be written for Android-specific push, permissions, and OS behavior.
|
||||
|
||||
---
|
||||
|
||||
## Executive summary
|
||||
|
||||
The iOS guide’s **backend + ngrok + in-app debug panel** path is platform-agnostic. Most of sections **1–3**, **6**, **9** (with log tooling swapped), **10** (with `platform: "android"`), **12**, and parts of **11** can be copied or lightly edited.
|
||||
|
||||
Everything involving **APNs, Xcode, Apple Developer, iOS capabilities, and iOS background/silent-push caveats** must be replaced. Android adds **direct FCM delivery** (no APNs hop), **`google-services.json`**, **runtime notification permissions (API 33+)**, **Doze / battery optimization / OEM restrictions**, and different **force-stop / background** semantics.
|
||||
|
||||
Existing related docs to cross-link (not duplicate):
|
||||
|
||||
- [android-physical-device-guide.md](./android-physical-device-guide.md) — USB, `adb`, build/run commands
|
||||
- [notification-system-overview.md](./notification-system-overview.md)
|
||||
- [notification-from-api-call.md](./notification-from-api-call.md)
|
||||
- [notification-permissions-and-rollovers.md](./notification-permissions-and-rollovers.md)
|
||||
|
||||
---
|
||||
|
||||
## iOS guide structure (reference map)
|
||||
|
||||
| § | iOS doc heading | Reuse for Android |
|
||||
|---|-----------------|-------------------|
|
||||
| Intro | Architecture overview | **Adapt** — swap APNs leg for FCM→device |
|
||||
| — | Prerequisites | **Partial** — drop Xcode/APNs; add Android SDK/device |
|
||||
| 1 | Install and configure ngrok | **Reuse unchanged** |
|
||||
| 2 | Start the backend locally | **Reuse unchanged** |
|
||||
| 3 | Obtain and use ngrok HTTPS URL | **Reuse** — wording: “device” not “iPhone” |
|
||||
| 4 | Generate and open iOS workspace | **Rewrite** — Android Studio / Capacitor sync |
|
||||
| 5 | Firebase + APNs setup | **Rewrite** — Firebase Android only; no APNs |
|
||||
| 6 | Notification Debug Panel override | **Reuse unchanged** |
|
||||
| 7 | Firebase and Xcode checklist | **Rewrite** — Android manifest / Gradle checklist |
|
||||
| 8 | iOS-specific testing notes | **Rewrite** — Android delivery caveats |
|
||||
| 9 | Recommended debug workflow | **Reuse** — replace Xcode console with logcat |
|
||||
| 10 | Sample curl commands | **Reuse** — change `platform` to `android` |
|
||||
| 11 | Troubleshooting | **Partial** — keep ngrok/API rows; replace push rows |
|
||||
| 12 | Key source files | **Reuse unchanged** |
|
||||
| 13 | Related docs | **Extend** — link Android build/device guides |
|
||||
|
||||
---
|
||||
|
||||
## Sections reusable unchanged (or near-unchanged)
|
||||
|
||||
These blocks can be carried into `doc/local-android-testing-ngrok.md` (proposed name) with at most global find-replace (“iPhone” → “Android device”, “Mac” tunnel audience unchanged).
|
||||
|
||||
### notification-wakeup-service startup (iOS §1 Terminal A, §2)
|
||||
|
||||
- Clone **notification-wakeup-service**, `npm install`, `.env` from `.env.example`
|
||||
- `export PORT=3000` (or port from that repo’s README)
|
||||
- `npm run dev`
|
||||
- Local verify: `curl -sS http://localhost:3000/health`
|
||||
- Firebase **Admin** service account for the backend (`GOOGLE_APPLICATION_CREDENTIALS`) — same project can serve iOS and Android apps
|
||||
|
||||
### ngrok setup (iOS §1)
|
||||
|
||||
- `brew install ngrok/ngrok/ngrok` (or download)
|
||||
- `ngrok http 3000` in a second terminal
|
||||
- Use **HTTPS** forwarding URL; free tier URL rotation note
|
||||
- ngrok inspect UI at `http://127.0.0.1:4040`
|
||||
|
||||
### ngrok account creation (iOS §1 “Account and auth token”)
|
||||
|
||||
- Sign up at dashboard.ngrok.com
|
||||
- `ngrok config add-authtoken YOUR_AUTHTOKEN_HERE`
|
||||
|
||||
### Obtaining HTTPS URL (iOS §3)
|
||||
|
||||
- Copy `https://….ngrok-free.app` from Forwarding line
|
||||
- No trailing slash in debug panel
|
||||
- Mac-side tunnel test: `export NGROK_URL=…` and `curl "$NGROK_URL/health"`
|
||||
|
||||
### Backend override configuration (iOS §6)
|
||||
|
||||
- Non-production build required for Notification Debug Panel
|
||||
- Account → **Show All General Advanced Functions** → `/dev/notifications`
|
||||
- **Notification Backend URL**, **Save Backend URL**
|
||||
- `localStorage`: `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, `notificationDebug.bypassAuth`
|
||||
- Optional programmatic override via `@/services/notifications` (`setBackendBaseUrl`, `setTestMode`, `setBypassAuth`, `getNotificationApiBaseUrl`)
|
||||
|
||||
### Debug panel usage (iOS §6 table, §8 “Two Simulate WAKEUP_PING buttons”)
|
||||
|
||||
| Control | Android relevance |
|
||||
|---------|-------------------|
|
||||
| Notification Backend URL | Same |
|
||||
| Test Mode | Same (`testMode` in JSON body) |
|
||||
| Skip JWT Authentication | Same — explicit opt-in for unauthenticated local backends (default off) |
|
||||
| Register Token Now | Same (`POST /notifications/register`) |
|
||||
| Refresh Notifications | Same |
|
||||
| Simulate WAKEUP_PING (backend) | Same — isolates ngrok + refresh without FCM |
|
||||
| Wakeup Ping Simulator | Same — exercises `handleCapacitorPushNotificationReceived` path |
|
||||
| Event Log `[Notifications]` | Same |
|
||||
| Pending Notification Inspector | Same concept; confirm Android plugin inspector behavior in **daily-notification-plugin** |
|
||||
|
||||
### testMode usage (iOS §6, §10)
|
||||
|
||||
- Default-on when unset in storage (`NotificationDebugConfig.ts`)
|
||||
- Sent on register and refresh payloads
|
||||
- Backend/debug endpoints accept `testMode: true` for dev traffic
|
||||
|
||||
### Refresh endpoint testing (iOS §9 steps 5, §11 “Refresh endpoint unreachable”)
|
||||
|
||||
- Panel **Refresh Notifications** → expect Event Log + ngrok `POST /notifications/refresh`
|
||||
- **Simulate WAKEUP_PING** (backend button) for API-only path
|
||||
- Troubleshooting table for network error, 404, wrong port, stale URL
|
||||
|
||||
### curl examples (iOS §10)
|
||||
|
||||
Reuse structure; **only payload deltas** for Android doc:
|
||||
|
||||
```bash
|
||||
export BASE="https://abc123.ngrok-free.app"
|
||||
```
|
||||
|
||||
- `$BASE/health` — unchanged
|
||||
- `$BASE/notifications/register` — set `"platform": "android"`
|
||||
- `$BASE/notifications/refresh` — set `"platform": "android"`
|
||||
- `$BASE/debug/send-wakeup` — unchanged shape; confirm deviceId/token contract in **notification-wakeup-service** README
|
||||
|
||||
App still uses `Capacitor.getPlatform()` for `platform` in `NotificationService.ts` (`ios` | `android`).
|
||||
|
||||
### Shared architecture concepts (intro + silent wake sequence)
|
||||
|
||||
Reusable narrative (edit diagram only):
|
||||
|
||||
1. FCM **data** message with `data.type = "WAKEUP_PING"`
|
||||
2. Capacitor `pushNotificationReceived` → `handleCapacitorPushNotificationReceived()`
|
||||
3. `POST {backend}/notifications/refresh` with `testMode`
|
||||
4. `nextNotifications` → `applyNotificationRefreshPayload()` → **daily-notification-plugin** clear + schedule
|
||||
|
||||
Repos table (notification-wakeup-service, crowd-funder-for-time-pwa, daily-notification-plugin) — unchanged.
|
||||
|
||||
### Key source files (iOS §12)
|
||||
|
||||
Same files apply on Android Capacitor builds:
|
||||
|
||||
- `NotificationDebugConfig.ts`, `NotificationDebugEvents.ts`, `notificationLog.ts`
|
||||
- `NotificationService.ts`, `NativeNotificationService.ts`
|
||||
- `firebaseMessagingClient.ts`, `NotificationDebugPanel.vue`, `main.capacitor.ts`
|
||||
|
||||
### Recommended debug workflow (iOS §9) — reuse with tooling swap
|
||||
|
||||
Steps 1–5, 8–9 unchanged. Replace step 7:
|
||||
|
||||
- **iOS:** Xcode console → `[Notifications] pushNotificationReceived type=WAKEUP_PING`
|
||||
- **Android:** `adb logcat` filtered on app tag / `[Notifications]` (document exact filter in Android guide)
|
||||
|
||||
---
|
||||
|
||||
## iOS-specific sections — must rewrite for Android
|
||||
|
||||
### Architecture diagram (intro)
|
||||
|
||||
**iOS today:** Mac → ngrok → app; FCM → **APNs** → iPhone.
|
||||
|
||||
**Android doc:** FCM → **device directly** (no APNs). Update ASCII diagram and caption (“silent push” on Android is still FCM data; delivery rules differ).
|
||||
|
||||
### Prerequisites (intro list)
|
||||
|
||||
| iOS prerequisite | Android replacement |
|
||||
|------------------|---------------------|
|
||||
| Mac with **Xcode** | **Android Studio**, JDK 17+, `ANDROID_HOME`, `adb` — see [android-physical-device-guide.md](./android-physical-device-guide.md) |
|
||||
| Physical **iPhone** | Physical **Android** device (emulator possible for some steps but **not** representative for Doze/OEM/battery) |
|
||||
| Firebase with **APNs** for bundle ID | Firebase with **Android app** (`app.timesafari` package name) |
|
||||
| Non-production build | Same — e.g. `build:android:dev` / `build:android:test` |
|
||||
|
||||
Remove: “simulator is not sufficient for reliable silent push / **APNs**”.
|
||||
|
||||
Add: emulator vs physical device guidance for FCM and background limits.
|
||||
|
||||
### §4 — Generate and open the iOS workspace
|
||||
|
||||
**Replace entirely** with Android equivalent:
|
||||
|
||||
- `npm install`
|
||||
- `npm run build:android:dev` or `build:android:test` (non-production for debug panel)
|
||||
- `npx cap sync android` if needed
|
||||
- Open `android/` in Android Studio
|
||||
- Run on physical device (USB debugging)
|
||||
- `VITE_FIREBASE_*` in Capacitor web build
|
||||
- `initializeNativePushAndFirebaseMessaging()` in `main.capacitor.ts` — same entry point
|
||||
|
||||
Do **not** reference `.xcworkspace`, signing in Xcode, or `build:ios:*` except as cross-link to iOS doc.
|
||||
|
||||
### §5 — Firebase + APNs setup (first-time setup)
|
||||
|
||||
**Keep (Android-relevant portions only):**
|
||||
|
||||
- Firebase account / Spark plan sufficient for FCM
|
||||
- Create Firebase project
|
||||
- **Register Android app** in Firebase (package name `app.timesafari` from `capacitor.config.ts`)
|
||||
- Download **`google-services.json`** → `android/app/` (project may gitignore this file — document secure handling)
|
||||
- Firebase Admin service account for **notification-wakeup-service** — same as iOS §5 tail
|
||||
|
||||
**Remove entirely:**
|
||||
|
||||
- Register **iOS** app in Firebase (or move to “shared project” sidebar: one Firebase project, two apps)
|
||||
- **GoogleService-Info.plist** / Xcode drag-and-drop
|
||||
- **Create APNs Authentication Key** (.p8)
|
||||
- **Upload APNs key to Firebase**
|
||||
- **Enable iOS capabilities** (Push Notifications, Background Modes → Remote notifications)
|
||||
|
||||
**Add in Android guide (see next major section):**
|
||||
|
||||
- Gradle plugin / `google-services` classpath if not already in repo
|
||||
- `POST_NOTIFICATIONS` permission (API 33+)
|
||||
- Default notification channel / Capacitor Push Notifications Android setup
|
||||
- SHA-1/SHA-256 only if using Firebase features that require it (note whether wakeup testing needs Play App Signing keys)
|
||||
|
||||
### §5 verify checklist — iOS-only bullets
|
||||
|
||||
Replace:
|
||||
|
||||
- “Xcode without Firebase/plist errors” → Android Studio build; `google-services.json` present
|
||||
- “iOS push permission prompt” → Android 13+ notification permission + older grant model
|
||||
- “content-available style payload” → Android **high-priority data message** / FCM options as implemented by **notification-wakeup-service** (document actual payload; no APNs `content-available`)
|
||||
|
||||
### §7 — Firebase and Xcode checklist (iOS)
|
||||
|
||||
**Replace** with Android checklist, e.g.:
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| **Application ID** | `app.timesafari` in `capacitor.config.ts`, `android/app/build.gradle`, Firebase Android app |
|
||||
| **google-services.json** | In `android/app/`; not committed if gitignored — local copy per developer |
|
||||
| **Gradle** | Google services plugin applied (verify repo’s current `build.gradle`) |
|
||||
| **Permissions** | `POST_NOTIFICATIONS` (API 33+); manifest entries for FCM |
|
||||
| **FCM token** | Debug panel **Register Token Now** + ngrok `POST /notifications/register` |
|
||||
| **No APNs** | N/A on Android |
|
||||
|
||||
### §8 — iOS-specific testing notes
|
||||
|
||||
**Replace** with Android-specific sections (draft topics below). Do not port:
|
||||
|
||||
- APNs silent delivery / Simulator unreliability (iOS framing)
|
||||
- **Force-quit** via app switcher (iOS-specific policy)
|
||||
- **Low Power Mode** (iOS) — Android has different battery saver APIs
|
||||
- **Focus / Do Not Disturb** (iOS naming)
|
||||
|
||||
Port with Android wording:
|
||||
|
||||
- Two **Simulate WAKEUP_PING** buttons table — unchanged behavior
|
||||
|
||||
### §11 — Troubleshooting (partial)
|
||||
|
||||
**Reuse as-is:**
|
||||
|
||||
- Refresh endpoint unreachable (ngrok, URL, 404, CORS note)
|
||||
- Stale ngrok URL
|
||||
- Plugin / JWT errors after refresh
|
||||
|
||||
**Rewrite:**
|
||||
|
||||
| iOS troubleshooting | Android replacement |
|
||||
|----------------------|---------------------|
|
||||
| Push permission + `VITE_FIREBASE_*` + **Xcode** log | Permission (runtime POST_NOTIFICATIONS), logcat, Firebase Android config |
|
||||
| Silent push not waking — **backgrounded not force-quit**, **APNs key**, wait 30–120s | FCM high-priority data, **force-stop** (`STOP` from settings), **Doze**, battery optimization, OEM autostart, token mismatch |
|
||||
| Physical device + provisioning profile | USB debugging, correct build variant, Play vs debug signing if relevant |
|
||||
|
||||
### §13 — Related docs
|
||||
|
||||
Keep iOS-centric links as “see also”; add:
|
||||
|
||||
- [android-physical-device-guide.md](./android-physical-device-guide.md)
|
||||
- `BUILDING.md` — Android build commands (`build:android:*`)
|
||||
- **daily-notification-plugin** Android docs (exact alarm, pending inspector on Android)
|
||||
|
||||
---
|
||||
|
||||
## Android-Specific Topics Required
|
||||
|
||||
These sections do not exist in the iOS guide (or exist only by analogy) and must be written for the Android notification testing doc.
|
||||
|
||||
### Firebase project setup
|
||||
|
||||
- Use the **same** Firebase project as iOS when testing the same backend, or document a dedicated `timesafari-dev` project.
|
||||
- Add an **Android** app with package name **`app.timesafari`**.
|
||||
- Enable **Cloud Messaging** (default on new projects).
|
||||
- Download **`google-services.json`** and install under `android/app/`.
|
||||
- Note: `android/.gitignore` may exclude `google-services.json` — developers copy locally; never commit secrets.
|
||||
|
||||
### google-services.json
|
||||
|
||||
- Placement: `android/app/google-services.json`
|
||||
- Sync after add: `npx cap sync android`, rebuild in Android Studio
|
||||
- Verify build merges Firebase config (no “missing google-services” Gradle errors)
|
||||
- Relationship to `VITE_FIREBASE_*` for the web layer / Capacitor JS Firebase initialization
|
||||
|
||||
### Android notification permissions
|
||||
|
||||
- **Android 13+ (API 33):** `POST_NOTIFICATIONS` runtime permission — required for notification **display**; document interaction with **data-only** FCM wake (may still deliver to app code when permission denied — verify against current app behavior and document accurately).
|
||||
- **Android 12 and below:** install-time grant model; fewer runtime prompts.
|
||||
- App Settings → Notifications — manual enable path for testers.
|
||||
- Link [notification-permissions-and-rollovers.md](./notification-permissions-and-rollovers.md) for product-level permission UX.
|
||||
|
||||
### FCM token handling
|
||||
|
||||
- Token obtained via Capacitor Push Notifications + `firebaseMessagingClient.ts` (same JS path as iOS).
|
||||
- **Register Token Now** in debug panel → `POST /notifications/register` with `platform: "android"`.
|
||||
- Token rotation: when to re-register; duplicate skip behavior in panel.
|
||||
- Ensure **notification-wakeup-service** stores/sends to the token shown in the panel for `/debug/send-wakeup`.
|
||||
- Optional: `adb` cannot easily read FCM token — panel is source of truth (same as iOS).
|
||||
|
||||
### Android background delivery behavior
|
||||
|
||||
- FCM **data** messages handled in foreground/background per Capacitor plugin and `NativeNotificationService.ts`.
|
||||
- No APNs intermediary — document expected latency vs iOS.
|
||||
- **High-priority** FCM for wakeup testing (align with backend message options).
|
||||
- App in **background** vs **foreground** vs **killed** — different from iOS “swipe away” story:
|
||||
- **Force stop** (Settings → Force stop): delivery often blocked until user launches app again (stricter than iOS “backgrounded”).
|
||||
- **Recent apps swipe**: behavior varies by OEM/Android version — document “test with Home button background, not force stop.”
|
||||
- `pushNotificationReceived` / listener registration at startup (`main.capacitor.ts`).
|
||||
|
||||
### Doze Mode
|
||||
|
||||
- Device idle → deferred network and job execution.
|
||||
- Testing: use `adb shell dumpsys deviceidle` (document safe dev-only commands) or unplugged idle wait.
|
||||
- Explain why `/debug/send-wakeup` may succeed on server but device wakes late.
|
||||
- Whitelisting app for tests (developer settings) — use cautiously; note production users won’t do this.
|
||||
|
||||
### Battery optimization
|
||||
|
||||
- Settings → Apps → TimeSafari → Battery → **Unrestricted** vs **Optimized**.
|
||||
- Manufacturer “battery saver” modes that restrict background network.
|
||||
- Recommend **Unrestricted** (or equivalent) for local wakeup validation; warn that production users may remain optimized.
|
||||
|
||||
### OEM restrictions (Samsung, Xiaomi, Oppo, etc.)
|
||||
|
||||
- **Autostart** / **Background activity** / **Battery** menus on Samsung, Xiaomi (MIUI), Oppo/ColorOS, Huawei, OnePlus, etc.
|
||||
- Symptom: FCM works on Pixel but not on OEM device until autostart enabled.
|
||||
- Provide a short “if wake fails on OEM, check…” checklist without exhaustive per-OEM screenshots (link community docs if needed).
|
||||
- Physical device testing should include at least one **stock-ish** device (Pixel) and one **OEM** device when possible.
|
||||
|
||||
---
|
||||
|
||||
## Proposed outline for `doc/local-android-testing-ngrok.md`
|
||||
|
||||
Suggested section order mirroring iOS doc for easy maintenance:
|
||||
|
||||
1. Title, audience, goal (Android physical device + ngrok + wakeup service)
|
||||
2. Architecture overview (FCM direct to Android)
|
||||
3. Prerequisites (Android Studio, device, Firebase Android app, non-prod build)
|
||||
4. ngrok install, account, tunnel (**reuse iOS §1**)
|
||||
5. Start notification-wakeup-service (**reuse iOS §2**)
|
||||
6. ngrok HTTPS URL (**reuse iOS §3**)
|
||||
7. Build and open Android project (**new**, replaces iOS §4)
|
||||
8. Firebase setup for Android (**new**, replaces iOS §5 — no APNs)
|
||||
9. Notification Debug Panel (**reuse iOS §6**)
|
||||
10. Android configuration checklist (**new**, replaces iOS §7)
|
||||
11. Android-specific testing notes (**new**, replaces iOS §8)
|
||||
12. Recommended debug workflow (**reuse iOS §9** + logcat)
|
||||
13. Sample curl commands (**reuse iOS §10** + `platform: "android"`)
|
||||
14. Troubleshooting (**merge reusable + Android push rows**)
|
||||
15. Key source files (**reuse iOS §12**)
|
||||
16. Related docs (**iOS doc + Android device guide + BUILDING**)
|
||||
|
||||
---
|
||||
|
||||
## Wording and terminology substitutions
|
||||
|
||||
When adapting reused sections:
|
||||
|
||||
| iOS doc term | Android doc term |
|
||||
|--------------|------------------|
|
||||
| iPhone | Android phone / device |
|
||||
| Xcode console | logcat / Android Studio Logcat |
|
||||
| `build:ios:dev` / `test` | `build:android:dev` / `test` |
|
||||
| `GoogleService-Info.plist` | `google-services.json` |
|
||||
| APNs / silent push | FCM data message / high-priority data |
|
||||
| Bundle ID | Application ID / package name (`app.timesafari`) |
|
||||
| Physical iPhone required for APNs | Physical device strongly recommended for Doze/OEM/FCM realism |
|
||||
| `platform: "ios"` in curl | `platform: "android"` |
|
||||
|
||||
---
|
||||
|
||||
## Gaps to resolve while writing the Android guide
|
||||
|
||||
Research during authoring (code + **notification-wakeup-service** + **daily-notification-plugin**):
|
||||
|
||||
1. Exact FCM Android message priority and payload fields for `WAKEUP_PING` (parity with iOS data message).
|
||||
2. Whether `POST_NOTIFICATIONS` denial blocks data message delivery to JS listeners on API 33+.
|
||||
3. Gradle/Firebase plugin versions already in `android/` — document exact files to touch.
|
||||
4. Android **Pending Notification Inspector** parity with iOS panel section.
|
||||
5. Whether emulator with Google Play image is acceptable for minimal FCM smoke tests vs mandatory physical device for wakeup SLA testing.
|
||||
|
||||
---
|
||||
|
||||
## Document maintenance
|
||||
|
||||
| Document | Role |
|
||||
|----------|------|
|
||||
| [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md) | Canonical iOS + ngrok workflow (unchanged by this analysis) |
|
||||
| **This file** | Reuse vs rewrite matrix and Android topic backlog |
|
||||
| *Future* `local-android-testing-ngrok.md` | Operator guide for Android testers |
|
||||
|
||||
When backend or debug panel behavior changes, update **both** platform guides’ shared sections in lockstep (or extract shared “ngrok + debug panel” snippet later — out of scope unless requested).
|
||||
@@ -0,0 +1,970 @@
|
||||
# Local Android Testing with ngrok (notification-wakeup-service)
|
||||
|
||||
**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 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.
|
||||
|
||||
---
|
||||
|
||||
## Architecture overview
|
||||
|
||||
End-to-end flow when testing FCM registration and wakeup **delivery** on a physical Android phone:
|
||||
|
||||
```text
|
||||
┌─────────────────────┐ HTTPS ┌──────────────────────┐
|
||||
│ Mac (localhost) │ ◄───────────── │ ngrok edge │
|
||||
│ notification- │ tunnel │ (public HTTPS URL) │
|
||||
│ wakeup-service │ └──────────┬───────────┘
|
||||
└──────────┬──────────┘ │
|
||||
│ │ fetch
|
||||
│ │ POST /notifications/register
|
||||
│ ▼
|
||||
│ ┌──────────────────────┐
|
||||
│ │ crowd-funder-for- │
|
||||
│ │ time-pwa (Capacitor │
|
||||
│ │ Android on device) │
|
||||
│ └──────────┬───────────┘
|
||||
│ │
|
||||
│ FCM data message (WAKEUP_PING) │ daily-notification-plugin
|
||||
▼ ▼ (Daily Reminder / dual / fetcher)
|
||||
┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ Firebase Cloud │ ──FCM────────► │ Android device │
|
||||
│ Messaging │ direct │ app.timesafari.app │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
Unlike iOS, Android does **not** use APNs. FCM delivers directly to the app via Google Play services on the device.
|
||||
|
||||
### Repos and responsibilities
|
||||
|
||||
| Repo | Role |
|
||||
|------|------|
|
||||
| **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 (current)
|
||||
|
||||
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 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`).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Mac (or Linux) with Node.js 18+, **notification-wakeup-service** cloned and runnable
|
||||
- **Android Studio**, JDK 17+, `ANDROID_HOME`, and `adb` — see [android-physical-device-guide.md](./android-physical-device-guide.md)
|
||||
- Physical Android device with USB debugging (recommended for realistic FCM, Doze, and OEM behavior)
|
||||
- ngrok account (free tier is enough for dev)
|
||||
- Firebase project with an **Android** app registered for the Gradle **application ID**
|
||||
- Non-production app build (Notification Debug Panel is dev-only)
|
||||
- `google-services.json` in `android/app/` (not committed; see [`.gitignore`](../android/.gitignore))
|
||||
|
||||
---
|
||||
|
||||
## 1. Install and configure ngrok (macOS)
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
# Homebrew
|
||||
brew install ngrok/ngrok/ngrok
|
||||
```
|
||||
|
||||
Or download from [https://ngrok.com/download](https://ngrok.com/download).
|
||||
|
||||
### Create ngrok account and configure auth token
|
||||
|
||||
1. Sign up at [https://dashboard.ngrok.com/signup](https://dashboard.ngrok.com/signup).
|
||||
2. Copy your authtoken from **Your Authtoken** in the dashboard.
|
||||
3. Configure the CLI:
|
||||
|
||||
```bash
|
||||
ngrok config add-authtoken YOUR_AUTHTOKEN_HERE
|
||||
```
|
||||
|
||||
### Start a tunnel to the wakeup service
|
||||
|
||||
Assume the service listens on port **3000** (confirm in **notification-wakeup-service** `README` or `.env`).
|
||||
|
||||
```bash
|
||||
# Terminal A — backend
|
||||
cd /path/to/notification-wakeup-service
|
||||
|
||||
npm install
|
||||
|
||||
# one-time setup if needed
|
||||
cp .env.example .env
|
||||
|
||||
# configure Firebase service account etc. as required
|
||||
export PORT=3000
|
||||
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```bash
|
||||
# Terminal B — ngrok
|
||||
ngrok http 3000
|
||||
```
|
||||
|
||||
ngrok prints a forwarding URL, for example:
|
||||
|
||||
```text
|
||||
Forwarding https://abc123.ngrok-free.app -> http://localhost:3000
|
||||
```
|
||||
|
||||
Use the **HTTPS** URL (not `http://127.0.0.1:3000`). The phone cannot reach your Mac’s localhost without the tunnel.
|
||||
|
||||
> **Note:** Free ngrok URLs change every time you restart ngrok unless you use a reserved domain (paid). Update the app debug override whenever the URL changes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Start the backend locally
|
||||
|
||||
On first setup, copy `.env.example` to `.env` and set Firebase service account, `PORT`, and other variables per **notification-wakeup-service** docs.
|
||||
|
||||
If the backend is not already running from section 1:
|
||||
|
||||
```bash
|
||||
cd /path/to/notification-wakeup-service
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Verify locally before ngrok:
|
||||
|
||||
```bash
|
||||
curl -sS http://localhost:3000/health
|
||||
```
|
||||
|
||||
Expected: HTTP 200 and a JSON body indicating the service is up (exact shape depends on that repo).
|
||||
|
||||
### Configure Firebase Admin for the backend
|
||||
|
||||
**notification-wakeup-service** uses the Firebase Admin SDK to send FCM messages from your Mac.
|
||||
|
||||
1. Firebase Console → **Project settings** → **Service accounts**.
|
||||
2. Click **Generate new private key** and download the JSON file.
|
||||
3. Store the JSON outside the repo (do not commit it).
|
||||
4. Point the backend at it:
|
||||
|
||||
```bash
|
||||
export GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/to/service-account.json"
|
||||
```
|
||||
|
||||
Set the same variable (or the equivalent documented in **notification-wakeup-service**) in the shell where you run `npm run dev`, or add it to that repo’s `.env`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Obtain and use the ngrok HTTPS URL
|
||||
|
||||
1. Run `ngrok http <PORT>`.
|
||||
2. Copy the `https://….ngrok-free.app` host from the **Forwarding** line.
|
||||
3. Do **not** add a trailing slash when saving in the app (the debug config trims it).
|
||||
4. Optional: open `http://127.0.0.1:4040` (ngrok web UI) to inspect requests and responses while testing.
|
||||
|
||||
Test through the tunnel from your Mac:
|
||||
|
||||
```bash
|
||||
export NGROK_URL="https://abc123.ngrok-free.app"
|
||||
curl -sS "$NGROK_URL/health"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Build and deploy to a physical Android device
|
||||
|
||||
From **crowd-funder-for-time-pwa**, build a non-production Capacitor bundle and sync Android. Complete [section 5](#5-firebase-setup-for-android-first-time) before expecting FCM to work.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build:android:dev # or build:android:test — non-production for debug panel
|
||||
```
|
||||
|
||||
Or use the combined run targets from [android-physical-device-guide.md](./android-physical-device-guide.md):
|
||||
|
||||
```bash
|
||||
npm run build:android:debug:run
|
||||
# or
|
||||
npm run build:android:test:run
|
||||
```
|
||||
|
||||
Open `android/` in Android Studio if you need to inspect native logs or signing. Ensure `VITE_FIREBASE_*` variables are set for the build you use (see `.env` / [BUILDING.md](../BUILDING.md)).
|
||||
|
||||
Native push registration runs at startup via `initializeNativePushAndFirebaseMessaging()` in `main.capacitor.ts` once Firebase and `google-services.json` are in place.
|
||||
|
||||
---
|
||||
|
||||
## 5. Firebase setup for Android (first-time setup)
|
||||
|
||||
Complete this section once before your first physical-device push test. If Firebase is already configured for this Android app, skip to [section 6](#6-configure-the-notification-debug-panel-backend-override).
|
||||
|
||||
### Create or access a Firebase account
|
||||
|
||||
1. Sign in at [https://console.firebase.google.com/](https://console.firebase.google.com/).
|
||||
2. No paid Firebase plan is required. The free **Spark** plan supports **Firebase Cloud Messaging (FCM)** and local ngrok development.
|
||||
|
||||
### Create a Firebase project
|
||||
|
||||
1. In the Firebase Console, click **Add project** (or **Create a project**).
|
||||
2. Enter a project name (for example, `timesafari-dev`) and continue through the wizard.
|
||||
3. **Google Analytics** is optional for this workflow.
|
||||
4. When the project is created, open it. **Cloud Messaging** is available on all projects — you do not need a separate “enable FCM” step beyond registering the Android app.
|
||||
|
||||
### Add the Android app in Firebase
|
||||
|
||||
1. In the project overview, click the **Android** icon (**Add app** → Android).
|
||||
2. Enter the **Android package name**. It must **exactly** match the Gradle **applicationId**:
|
||||
|
||||
**`app.timesafari.app`**
|
||||
|
||||
(see `applicationId` in `android/app/build.gradle`. This differs from Capacitor `appId` in `capacitor.config.ts`, which is `app.timesafari`.)
|
||||
|
||||
3. App nickname and SHA-1/SHA-256 are optional for basic FCM wakeup testing; you may add debug keystore fingerprints later if Firebase Console prompts for them.
|
||||
4. Download **`google-services.json`** when prompted.
|
||||
|
||||
### Place google-services.json
|
||||
|
||||
1. Copy the file to:
|
||||
|
||||
```text
|
||||
crowd-funder-for-time-pwa/android/app/google-services.json
|
||||
```
|
||||
|
||||
2. The repo gitignores this file — each developer keeps a local copy; never commit it.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
npx cap sync android
|
||||
```
|
||||
|
||||
### Enable Firebase Cloud Messaging
|
||||
|
||||
FCM is enabled by default for Firebase projects. Confirm in **Project settings** → **Cloud Messaging**:
|
||||
|
||||
- **Cloud Messaging API** is available (legacy or HTTP v1 per your backend setup).
|
||||
- The Android app (`app.timesafari.app`) appears under your apps.
|
||||
|
||||
The **notification-wakeup-service** sends wakeup messages through Firebase Admin using the same project’s service account ([section 2](#2-start-the-backend-locally)).
|
||||
|
||||
### Web / Capacitor JS Firebase config
|
||||
|
||||
In addition to `google-services.json`, the Capacitor web build needs `VITE_FIREBASE_*` env vars (API key, project ID, messaging sender ID, app ID, and optionally `VITE_FIREBASE_VAPID_KEY`). These must refer to the **same Firebase project** as `google-services.json`.
|
||||
|
||||
### Verify Firebase configuration
|
||||
|
||||
Before ngrok end-to-end testing, confirm:
|
||||
|
||||
- [ ] App builds and installs without Gradle errors about `google-services.json`.
|
||||
- [ ] Push permission is **granted** (see [section 7](#7-android-notification-permissions)).
|
||||
- [ ] **Notification Debug Panel** shows an FCM token (after permission and registration).
|
||||
- [ ] **Register Token Now** succeeds and ngrok shows `POST /notifications/register`.
|
||||
- [ ] Backend health and `GOOGLE_APPLICATION_CREDENTIALS` are set so `/debug/send-wakeup` can run.
|
||||
|
||||
---
|
||||
|
||||
## 6. Configure the Notification Debug Panel backend override
|
||||
|
||||
The app normally calls `DEFAULT_NOTIFY_API_SERVER` (from `VITE_DEFAULT_NOTIFY_API_SERVER`, falling back to `AppString.PROD_NOTIFY_API_SERVER`). That is independent of `APP_SERVER`. For local wakeup testing, override the notification API base URL in the Debug Panel without rebuilding.
|
||||
|
||||
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
|
||||
|
||||
### Open the panel
|
||||
|
||||
1. Use a **non-production** build (e.g. `build:android:dev` or `build:android:test`).
|
||||
2. **Account** → enable **Show All General Advanced Functions**.
|
||||
3. Open **Notification Debug Panel** (route `/dev/notifications`).
|
||||
|
||||
### Backend Testing section
|
||||
|
||||
| Control | Purpose |
|
||||
|---------|---------|
|
||||
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) |
|
||||
| **Test Mode** | Sends `testMode: true/false` in register / 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"` |
|
||||
| **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`).
|
||||
|
||||
### Authentication vs backend URL
|
||||
|
||||
These settings are **independent**:
|
||||
|
||||
- **Backend URL** — which server receives `/notifications/*` and `/debug/*` calls.
|
||||
- **Test Mode** — `testMode` field in JSON request bodies only.
|
||||
- **Skip JWT Authentication** — whether `Authorization: Bearer …` is sent (via `getNotificationApiHeaders()`).
|
||||
|
||||
For a **hosted shared test server** (for example `https://test-notify-api.timesafari.app`): set the backend URL, keep **Test Mode** on if the server requires it, and leave **Skip JWT Authentication** **off**. Ensure an active DID and endorser session exist in the app.
|
||||
|
||||
For **local ngrok / localhost**: set the backend URL to your tunnel or `http://127.0.0.1:PORT`. Enable **Skip JWT Authentication** only if your local **notification-wakeup-service** intentionally accepts unauthenticated requests.
|
||||
|
||||
### testMode
|
||||
|
||||
When **Test Mode** is on (default if never saved), register and send-wakeup requests include `"testMode": true`. The backend can route dev traffic separately from production. Turn it off in the panel only if you intentionally want production-mode API behavior against your tunnel.
|
||||
|
||||
### WAKEUP_PING debug controls
|
||||
|
||||
**Send Real WAKEUP_PING** is the remaining panel control for wakeup. Mock refresh, Simulate WAKEUP_PING, Wakeup Ping Simulator, and Flood Test were removed with the retired refresh pipeline.
|
||||
|
||||
Use **Send Real WAKEUP_PING** to confirm backend → FCM → Capacitor listener delivery. Do not expect `/notifications/refresh` or `api_*` rescheduling.
|
||||
|
||||
### Send Real WAKEUP_PING
|
||||
|
||||
**Send Real WAKEUP_PING** is the in-app equivalent of calling **`POST /debug/send-wakeup`** from the Mac (see [Verification Checklist step 7](#7-manual-wakeup-endpoint-sends-fcm-successfully)). It is intended for **local testing and diagnostics** on non-production builds only.
|
||||
|
||||
**What it does:**
|
||||
|
||||
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()`, which logs `push handler ignored type=WAKEUP_PING`. There is no `refreshNotificationsWithDiagnostics` and no `applyNotificationRefreshPayload`.
|
||||
|
||||
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)).
|
||||
|
||||
#### Expected Logcat output
|
||||
|
||||
Filter logcat (prefix is always `[Notifications]`):
|
||||
|
||||
```bash
|
||||
adb logcat | grep -E '\[Notifications\].*(Real WAKEUP_PING|push handler ignored)'
|
||||
```
|
||||
|
||||
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] push handler ignored type=WAKEUP_PING
|
||||
```
|
||||
|
||||
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 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.
|
||||
|
||||
**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)
|
||||
|
||||
From Chrome DevTools attached to the WebView (`chrome://inspect` → your app):
|
||||
|
||||
```javascript
|
||||
import {
|
||||
setBackendBaseUrl,
|
||||
setTestMode,
|
||||
setBypassAuth,
|
||||
getNotificationApiBaseUrl,
|
||||
} from "@/services/notifications";
|
||||
|
||||
setBackendBaseUrl("https://abc123.ngrok-free.app");
|
||||
setTestMode(true);
|
||||
setBypassAuth(true); // local dev only — omit for hosted servers that require JWT
|
||||
getNotificationApiBaseUrl(); // → ngrok URL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Android notification permissions
|
||||
|
||||
### Android 13+ (API 33+)
|
||||
|
||||
`AndroidManifest.xml` declares:
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
```
|
||||
|
||||
On **Android 13 and above**, this is a **runtime** permission. The user must grant it before the system allows notification **display**. The OS shows a system dialog the first time the app requests it.
|
||||
|
||||
### How this app requests permission
|
||||
|
||||
At startup, `initializeNativePushAndFirebaseMessaging()` in `firebaseMessagingClient.ts` calls:
|
||||
|
||||
```typescript
|
||||
const perm = await PushNotifications.requestPermissions();
|
||||
```
|
||||
|
||||
If `perm.receive !== "granted"`, native push registration does not proceed and you will not get a reliable FCM token in the debug panel.
|
||||
|
||||
Daily notification flows can also request permission via **daily-notification-plugin** (`NativeNotificationService.requestPermissions()` → plugin `POST_NOTIFICATIONS`). See [notification-permissions-and-rollovers.md](./notification-permissions-and-rollovers.md).
|
||||
|
||||
### Android 12 and below
|
||||
|
||||
`POST_NOTIFICATIONS` does not exist as a runtime prompt on older APIs. Notifications are generally allowed at install time; fewer permission dialogs appear during testing.
|
||||
|
||||
### Manual check for testers
|
||||
|
||||
**Settings** → **Apps** → **TimeSafari** → **Notifications** → ensure notifications are allowed.
|
||||
|
||||
### Permission vs FCM data wake
|
||||
|
||||
**WAKEUP_PING** uses FCM **data** messages handled in `pushNotificationReceived`. For local testing:
|
||||
|
||||
- Grant notification permission so **Register Token Now** and token display in the debug panel work.
|
||||
- If permission is denied, fix permission before debugging FCM wakeup; do not assume data delivery will run the full refresh pipeline.
|
||||
|
||||
---
|
||||
|
||||
## 8. Android Platform Notes
|
||||
|
||||
### FCM vs iOS silent push
|
||||
|
||||
Android generally delivers **background FCM data messages** more reliably than iOS **silent APNs** (`content-available`), especially when the app is backgrounded (not force-stopped) and Google Play services is healthy. There is no separate APNs hop; FCM talks to the device directly.
|
||||
|
||||
Delivery is still **not guaranteed**. Treat wakeup as best-effort in tests and in production expectations.
|
||||
|
||||
### Force-stop vs backgrounding
|
||||
| State | Typical FCM behavior |
|
||||
|-------|----------------------|
|
||||
| **Foreground** | Data messages handled promptly; `pushNotificationReceived` fires. |
|
||||
| **Background** (Home) | Data messages usually delivered; handler may run with delay under load. |
|
||||
| **Force stop** (Settings → Force stop) | Delivery often **blocked** until the user launches the app again. Stricter than iOS “swipe away” in many cases. |
|
||||
| **Recents swipe** | Varies by OEM/Android version; less predictable than Home. Prefer **Home** for tests. |
|
||||
|
||||
Always validate wakeup with the app **backgrounded**, not force-stopped.
|
||||
|
||||
### Notification permissions (Android 13+)
|
||||
|
||||
On **API 33+**, `POST_NOTIFICATIONS` is a runtime permission ([section 7](#7-android-notification-permissions)). This app requests push permission via `PushNotifications.requestPermissions()` before `register()`. Denied permission blocks token registration in the debug panel and undermines end-to-end testing—fix permission before debugging FCM.
|
||||
|
||||
### Network connectivity
|
||||
|
||||
FCM needs network to Google’s endpoints. Registration needs the ngrok HTTPS URL for `POST /notifications/register`. Airplane mode, captive portals, VPNs, or flaky Wi‑Fi cause “server sent wakeup but app never logged ignored type” symptoms even when FCM eventually arrives.
|
||||
|
||||
### Physical device vs emulator
|
||||
|
||||
Doze, App Standby, and OEM battery menus are weak or absent on many emulators. Use a **physical device** for wakeup SLA testing; emulators are fine for panel-only register smoke tests.
|
||||
|
||||
### Logcat
|
||||
|
||||
```bash
|
||||
adb logcat | grep -E '\[Notifications\]|\[FirebaseMessaging\]|\[NativeNotificationService\]'
|
||||
```
|
||||
|
||||
Expect `push handler ignored type=WAKEUP_PING` after a successful wake. For a panel-driven test, use **Send Real WAKEUP_PING** ([§6](#send-real-wakeup_ping)) instead of manual `curl`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Battery Optimization Caveats
|
||||
|
||||
Android power management can **delay or batch** FCM delivery and background work even when the server returns success from `/debug/send-wakeup`.
|
||||
|
||||
### Doze Mode
|
||||
|
||||
When the device is **unplugged**, screen off, and idle, Android enters **Doze**. Network access and jobs are deferred to **maintenance windows**. Wakeup messages may arrive minutes late.
|
||||
|
||||
**Testing tip:** Keep the device on charger, or wake the screen briefly, or wait longer (2–5+ minutes) before failing a Doze test.
|
||||
|
||||
### App Standby
|
||||
|
||||
Apps used infrequently move to **standby** buckets with reduced background network. Frequent dev installs reset some of this; long-idle test devices do not.
|
||||
|
||||
**Testing tip:** Open the app before sending wakeup; register token again if the device was idle for days.
|
||||
|
||||
### Adaptive Battery
|
||||
|
||||
**Adaptive Battery** / per-app battery savers learn usage patterns and restrict background activity for “unused” apps.
|
||||
|
||||
**Testing tip:** **Settings** → **Apps** → **TimeSafari** → **Battery** → **Unrestricted** (or **Don’t optimize**) during local validation.
|
||||
|
||||
### Manufacturer-specific restrictions
|
||||
|
||||
OEMs add layers on top of AOSP. Aggressive battery management can delay **WAKEUP_PING** delivery.
|
||||
|
||||
| Vendor | Where to look (names vary by OS version) |
|
||||
|--------|------------------------------------------|
|
||||
| **Samsung** | Device care → Battery → Background usage limits; app → Battery → Unrestricted |
|
||||
| **Xiaomi** | Security app → Autostart; Battery saver → No restrictions |
|
||||
| **Oppo** / **Realme** | Battery → App battery management → Allow background activity |
|
||||
| **Vivo** | iManager / Battery → High background power consumption |
|
||||
| **Huawei** | App launch → Manage manually → enable Auto-launch, Secondary launch, Run in background |
|
||||
|
||||
If wakeup works on a **Pixel** but fails on an OEM phone, assume battery policy first—not a broken FCM payload.
|
||||
|
||||
### How this affects wakeup testing
|
||||
|
||||
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 → `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)).
|
||||
|
||||
---
|
||||
|
||||
## 10. Recommended local testing workflow
|
||||
|
||||
1. Start **notification-wakeup-service** on the Mac (`npm run dev`, `GOOGLE_APPLICATION_CREDENTIALS` set).
|
||||
2. Start **ngrok** and copy the HTTPS URL.
|
||||
3. Install a **non-production** Android build with `google-services.json` in place.
|
||||
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. 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.
|
||||
|
||||
---
|
||||
|
||||
## 11. Verification Checklist
|
||||
|
||||
Use this checklist during development or QA sign-off. Each step lists **actions**, then **expected outcome**. Prerequisites: non-production Android build, `google-services.json` installed, physical device (recommended), backend + ngrok running.
|
||||
|
||||
| # | Check | Pass criteria |
|
||||
|---|--------|----------------|
|
||||
| 1 | Backend via ngrok | §1 below |
|
||||
| 2 | Device → backend override | §2 |
|
||||
| 3 | FCM token registered | §3 |
|
||||
| 4 | Device record in wakeup service | §4 |
|
||||
| 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 delivered | §8 |
|
||||
| 9 | _(retired)_ Replace after refresh | skipped |
|
||||
| 10 | _(retired)_ Test mode frequent refreshes | skipped |
|
||||
|
||||
### 1. Backend reachable through ngrok
|
||||
|
||||
**Actions:**
|
||||
|
||||
```bash
|
||||
export BASE="https://YOUR-NGROK-HOST.ngrok-free.app"
|
||||
curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
|
||||
```
|
||||
|
||||
**Expected outcome:** HTTP **200**; JSON body indicates the service is healthy (exact fields per **notification-wakeup-service**). Mac `curl http://localhost:3000/health` must also pass before blaming ngrok.
|
||||
|
||||
---
|
||||
|
||||
### 2. Device can reach backend override URL
|
||||
|
||||
**Actions:**
|
||||
|
||||
1. Open **Notification Debug Panel** (`/dev/notifications`).
|
||||
2. Paste ngrok HTTPS URL (no trailing slash) → **Save Backend URL**.
|
||||
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 the default `DEFAULT_NOTIFY_API_SERVER`) for `/notifications/register` and `/debug/send-wakeup`.
|
||||
|
||||
---
|
||||
|
||||
### 3. FCM token successfully registered
|
||||
|
||||
**Actions:**
|
||||
|
||||
1. Grant notification permission when prompted (Android 13+).
|
||||
2. Tap **Register Token Now**.
|
||||
3. Watch Event Log and logcat (`[Notifications]`, `[FirebaseMessaging]`).
|
||||
|
||||
**Expected outcome:**
|
||||
|
||||
- **Current FCM Token** shows a non-empty token (Copy works).
|
||||
- Event Log: `[Notifications] Token registration success` (not failure / auth unavailable).
|
||||
- ngrok inspect: `POST /notifications/register` with **200**; body includes `platform: "android"`, `testMode: true` (if Test Mode on), `deviceId`, and `fcmToken`.
|
||||
|
||||
---
|
||||
|
||||
### 4. Device record appears in notification-wakeup-service
|
||||
|
||||
**Actions:**
|
||||
|
||||
1. In ngrok inspect (`http://127.0.0.1:4040`), open the **register** request → copy `deviceId` and `fcmToken` from the JSON body.
|
||||
2. In **notification-wakeup-service**, confirm persistence per that repo (server logs, database, admin/list endpoint, or debug CLI).
|
||||
|
||||
**Expected outcome:** The `deviceId` and `fcmToken` from step 3 are stored and retrievable by the service. `/debug/send-wakeup` can target that `deviceId` (or token, per service contract). If register returned 200 but no record exists, fix the wakeup service store/config before FCM tests.
|
||||
|
||||
> **Note:** `deviceId` is created in-app (`Preferences` key `stable_device_id` in `deviceId.ts`). It is not shown in the debug panel; use ngrok request body or logcat `[DeviceId]` lines.
|
||||
|
||||
---
|
||||
|
||||
### 5. Refresh endpoint (backend only; app does not call this)
|
||||
|
||||
**Actions:**
|
||||
|
||||
Mac-side curl against **notification-wakeup-service** if you need to confirm the backend still answers. The app has no **Refresh Notifications** button and does not schedule from this payload.
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/notifications/refresh" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"platform":"android","testMode":true}'
|
||||
```
|
||||
|
||||
**Expected outcome:** HTTP **200** from the **service** if that route is still deployed. Do **not** expect Event Log `Refresh completed` or `api_*` schedules in the app.
|
||||
|
||||
---
|
||||
|
||||
### 6. Local notifications are scheduled (not from refresh)
|
||||
|
||||
**Actions:**
|
||||
|
||||
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 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.
|
||||
|
||||
---
|
||||
|
||||
### 7. Manual wakeup endpoint sends FCM successfully
|
||||
|
||||
**Actions:**
|
||||
|
||||
1. Use `deviceId` from step 4.
|
||||
2. Either:
|
||||
- **On device:** background the app (Home), open **Notification Debug Panel**, tap **Send Real WAKEUP_PING**; or
|
||||
- **From the Mac:**
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/debug/send-wakeup" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"deviceId":"YOUR_DEVICE_ID","testMode":true}'
|
||||
```
|
||||
|
||||
3. Check **notification-wakeup-service** logs for FCM send success (no Admin SDK / token errors).
|
||||
|
||||
**Expected outcome:** HTTP **200** (or documented success code) from `/debug/send-wakeup`; Event Log / logcat: `Real WAKEUP_PING success` when using the panel button; server logs indicate FCM message enqueued/sent with `data.type = "WAKEUP_PING"`. This step alone does not prove device delivery (see step 8 and [Send Real WAKEUP_PING vs delivery success](#send-real-wakeup_ping-vs-delivery-success)).
|
||||
|
||||
---
|
||||
|
||||
### 8. WAKEUP_PING is delivered (no refresh)
|
||||
|
||||
**Actions:**
|
||||
|
||||
1. **Background** the app (Home — not force-stop).
|
||||
2. Run step 7 again (panel **Send Real WAKEUP_PING** or Mac `curl`), or wait for a server-driven wakeup.
|
||||
3. Filter logcat:
|
||||
|
||||
```bash
|
||||
adb logcat | grep -E 'WAKEUP_PING|push handler ignored'
|
||||
```
|
||||
|
||||
**Expected outcome (within ~30–120s, longer under Doze/OEM):**
|
||||
|
||||
1. `Real WAKEUP_PING success` (panel path)
|
||||
2. `push handler ignored type=WAKEUP_PING`
|
||||
3. ngrok: **no** app-initiated `POST /notifications/refresh`
|
||||
|
||||
Full expected logcat for **Send Real WAKEUP_PING**: [§6](#expected-logcat-output).
|
||||
|
||||
---
|
||||
|
||||
### 9. Existing notifications are replaced after refresh
|
||||
|
||||
**Retired.** The app no longer replaces local schedules from `/notifications/refresh`. Pending list changes come from Daily Reminder / dual / native fetcher, or from Phase 4 `clearApiNotifications()`.
|
||||
|
||||
---
|
||||
|
||||
### 10. Test mode produces frequent notification refreshes
|
||||
|
||||
**Retired.** `testMode` still goes on register and send-wakeup bodies. It does not drive app-side `api_*` refresh cadences.
|
||||
|
||||
---
|
||||
|
||||
## 12. End-to-End Test
|
||||
|
||||
Single scripted run from cold start to scheduled local notification. Time: ~15–30 minutes (plus optional wait for a near `testMode` fire).
|
||||
|
||||
### Phase A — Mac backend and tunnel
|
||||
|
||||
1. Terminal A:
|
||||
|
||||
```bash
|
||||
cd /path/to/notification-wakeup-service
|
||||
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"
|
||||
export PORT=3000
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. Verify: `curl -sS http://localhost:3000/health` → **200**.
|
||||
|
||||
3. Terminal B: `ngrok http 3000` → copy `https://….ngrok-free.app` → `export BASE=…`.
|
||||
|
||||
4. Verify: `curl -sS "$BASE/health"` → **200**.
|
||||
|
||||
### Phase B — Android app
|
||||
|
||||
5. Build and install dev/test APK (`npm run build:android:dev` or `build:android:debug:run`); `google-services.json` in `android/app/`.
|
||||
|
||||
6. Launch app; accept **notification permission**.
|
||||
|
||||
7. **Account** → **Show All General Advanced Functions** → **Notification Debug Panel**.
|
||||
|
||||
8. Paste `BASE` → **Save Backend URL**; enable **Test Mode**.
|
||||
|
||||
### 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).
|
||||
|
||||
### Phase D — FCM wakeup delivery
|
||||
|
||||
11. Background app (Home).
|
||||
|
||||
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. Within 30–120s (longer if unplugged/Doze): logcat shows `push handler ignored type=WAKEUP_PING`; ngrok does **not** show an app `POST /notifications/refresh`.
|
||||
|
||||
### Phase E — Optional local notification proof
|
||||
|
||||
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
|
||||
|
||||
| Phase | Pass |
|
||||
|-------|------|
|
||||
| A | Health OK local + ngrok |
|
||||
| B | Override URL + testMode active |
|
||||
| C | Register succeeded |
|
||||
| D | Wakeup curl/panel OK → logcat ignored-type line |
|
||||
| E | Optional visible Daily Reminder / New Activity notification |
|
||||
|
||||
---
|
||||
|
||||
## 13. Sample curl commands
|
||||
|
||||
Set your tunnel base URL:
|
||||
|
||||
```bash
|
||||
export BASE="https://abc123.ngrok-free.app"
|
||||
```
|
||||
|
||||
### Health
|
||||
|
||||
```bash
|
||||
curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
|
||||
```
|
||||
|
||||
### Register device (mirror app payload)
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/notifications/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"deviceId": "00000000-0000-4000-8000-000000000001",
|
||||
"fcmToken": "YOUR_FCM_TOKEN_FROM_DEBUG_PANEL",
|
||||
"platform": "android",
|
||||
"testMode": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Refresh (backend only; app does not consume)
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/notifications/refresh" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"platform": "android",
|
||||
"testMode": true
|
||||
}'
|
||||
```
|
||||
|
||||
This exercises **notification-wakeup-service** only. The app does **not** call `applyNotificationRefreshPayload` or schedule `api_*` from the response.
|
||||
|
||||
### Send wakeup push (debug)
|
||||
|
||||
Exact path and body depend on **notification-wakeup-service**; typical pattern:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/debug/send-wakeup" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"deviceId": "00000000-0000-4000-8000-000000000001",
|
||||
"testMode": true
|
||||
}'
|
||||
```
|
||||
|
||||
Confirm parameters (token vs `deviceId`, auth headers) in that repo’s README or OpenAPI spec. The server may still send FCM data including `type: "WAKEUP_PING"`; the app logs it as ignored and does not refresh.
|
||||
|
||||
---
|
||||
|
||||
## 14. Troubleshooting
|
||||
|
||||
Structured checks for local ngrok + FCM testing. Each item lists **symptoms**, **likely causes**, **verification**, and **fixes**.
|
||||
|
||||
### Token registration failures
|
||||
|
||||
**Symptoms:** Event Log shows token registration failure; no FCM token in debug panel; ngrok has no `POST /notifications/register`; curl register fails.
|
||||
|
||||
**Likely causes:** Notification permission denied; missing `google-services.json`; Firebase package mismatch (`app.timesafari.app`); `VITE_FIREBASE_*` not in build; JWT auth failure or **Skip JWT Authentication** mismatch with server expectations; duplicate-token skip.
|
||||
|
||||
**Verification:**
|
||||
|
||||
1. Settings → app → Notifications → allowed.
|
||||
2. Logcat: `[FirebaseMessaging] Push permission not granted` or registration errors.
|
||||
3. Panel shows a token string before **Register Token Now**.
|
||||
4. ngrok inspect UI (`http://127.0.0.1:4040`) for register request status body.
|
||||
|
||||
**Fixes:** Grant permission and cold-start app; add `android/app/google-services.json` and rebuild; align Firebase Android app ID with Gradle `applicationId`; rebuild with correct `.env`; tap **Register Token Now** (forces re-register); for JWT-required servers, ensure active DID/endorser session and **Skip JWT Authentication** off; for local unauthenticated backends, enable **Skip JWT Authentication** (see [notification-debug-panel.md](./notification-debug-panel.md)).
|
||||
|
||||
---
|
||||
|
||||
### Backend unreachable
|
||||
|
||||
**Symptoms:** Backend Status unhealthy in panel; register/refresh network errors; Mac `curl localhost:3000/health` fails.
|
||||
|
||||
**Likely causes:** `notification-wakeup-service` not running; wrong `PORT`; firewall; typo in saved backend URL (not ngrok).
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
curl -sS http://localhost:3000/health
|
||||
```
|
||||
|
||||
Compare with panel **Backend Status** and Event Log error text.
|
||||
|
||||
**Fixes:** Start backend with `npm run dev` and `GOOGLE_APPLICATION_CREDENTIALS`; match `PORT` to ngrok target; paste full ngrok **HTTPS** URL into panel (no trailing slash).
|
||||
|
||||
---
|
||||
|
||||
### Stale ngrok URL
|
||||
|
||||
**Symptoms:** Worked yesterday; today all API calls fail or hit wrong host; ngrok shows no requests.
|
||||
|
||||
**Likely causes:** Free ngrok URL changed after tunnel restart; old URL still in `notificationDebug.backendBaseUrl`.
|
||||
|
||||
**Verification:** Compare panel URL to current `ngrok http` **Forwarding** line; `curl -sS "$NEW_URL/health"`.
|
||||
|
||||
**Fixes:** Copy new HTTPS URL → **Save Backend URL** in panel; or clear override only if intentionally returning to `DEFAULT_NOTIFY_API_SERVER`.
|
||||
|
||||
---
|
||||
|
||||
### Refresh endpoint failures
|
||||
|
||||
**Symptoms:** Mac `curl` of `/notifications/refresh` fails; ngrok missing that path. This is a **backend** issue. The app does not POST refresh.
|
||||
|
||||
**Verification:** `curl` health and refresh from the Mac; confirm the service still ships that route.
|
||||
|
||||
**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 `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 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. 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.
|
||||
|
||||
---
|
||||
|
||||
### Notification permission denied
|
||||
|
||||
**Symptoms:** No permission dialog or user denied; logcat `Push permission not granted`; empty FCM token; register skipped.
|
||||
|
||||
**Likely causes:** User denied prompt; permission revoked in Settings; testing on API 33+ without `POST_NOTIFICATIONS` grant.
|
||||
|
||||
**Verification:** Settings → Apps → TimeSafari → Notifications; logcat after cold start for `requestPermissions` result.
|
||||
|
||||
**Fixes:** Enable notifications in Settings; reinstall to re-prompt if needed; cold-start app so `initializeNativePushAndFirebaseMessaging()` runs again.
|
||||
|
||||
---
|
||||
|
||||
### Notifications not appearing
|
||||
|
||||
**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 for Daily Reminder / dual identifiers (not `api_*` after Phase 4 cleanup).
|
||||
|
||||
**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.
|
||||
|
||||
**Likely causes:** Separate Daily Reminder vs New Activity schedules; duplicate dual config; leftover `api_*` before Phase 4 cleanup.
|
||||
|
||||
**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:** Confirm Daily Reminder vs dual settings. WAKEUP_PING no longer stacks `api_*` refreshes.
|
||||
|
||||
---
|
||||
|
||||
### Device enters battery-saving mode
|
||||
|
||||
**Symptoms:** Intermittent wakeup; long delays; works on charger but not unplugged; OEM “battery saver” icon on.
|
||||
|
||||
**Likely causes:** Doze; Adaptive Battery; manufacturer saver (Samsung, Xiaomi, Oppo, Vivo, Huawei).
|
||||
|
||||
**Verification:** Settings → Battery; OEM battery app; reproduce unplugged screen-off vs charging.
|
||||
|
||||
**Fixes:** For dev testing set app battery to **Unrestricted**; enable **Autostart** / background activity on OEM; wait for maintenance window or wake device before concluding FCM failure.
|
||||
|
||||
---
|
||||
|
||||
### Gradle: Push Notifications won't work
|
||||
|
||||
**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 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.
|
||||
|
||||
---
|
||||
|
||||
## 15. Key source files (crowd-funder-for-time-pwa)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/services/notifications/NotificationDebugConfig.ts` | Backend URL, testMode, and bypassAuth overrides |
|
||||
| `src/services/notifications/NotificationDebugEvents.ts` | Panel event log + `logNotification()` |
|
||||
| `src/services/notifications/notificationLog.ts` | Structured log helpers |
|
||||
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
|
||||
| `src/services/notifications/NativeNotificationService.ts` | 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 |
|
||||
| `android/app/build.gradle` | `applicationId`, conditional `google-services` plugin |
|
||||
| `android/app/src/main/AndroidManifest.xml` | `POST_NOTIFICATIONS` and plugin permissions |
|
||||
|
||||
---
|
||||
|
||||
## 16. Related docs
|
||||
|
||||
- [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md) — iOS + APNs equivalent
|
||||
- [local-android-testing-analysis.md](./local-android-testing-analysis.md) — reuse matrix used to author this guide
|
||||
- [android-physical-device-guide.md](./android-physical-device-guide.md) — USB, `adb`, build commands
|
||||
- [notification-system-overview.md](./notification-system-overview.md)
|
||||
- [notification-from-api-call.md](./notification-from-api-call.md)
|
||||
- [notification-permissions-and-rollovers.md](./notification-permissions-and-rollovers.md)
|
||||
- [BUILDING.md](../BUILDING.md) — Android build commands
|
||||
- [notification-debug-panel.md](./notification-debug-panel.md) — panel controls, authentication, troubleshooting
|
||||
- [Notification Debug Panel (README)](../README.md#notification-debug-panel-dev-builds)
|
||||
|
||||
For plugin-native behavior (exact alarms, Android pending inspector), see **daily-notification-plugin** documentation. For FCM payload format and `/debug/send-wakeup` contract, see **notification-wakeup-service**.
|
||||
@@ -0,0 +1,525 @@
|
||||
# Local iOS Testing with ngrok (notification-wakeup-service)
|
||||
|
||||
**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 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 FCM registration and wakeup **delivery** on a physical iPhone:
|
||||
|
||||
```text
|
||||
┌─────────────────────┐ HTTPS ┌──────────────────────┐
|
||||
│ Mac (localhost) │ ◄───────────── │ ngrok edge │
|
||||
│ notification- │ tunnel │ (public HTTPS URL) │
|
||||
│ wakeup-service │ └──────────┬───────────┘
|
||||
└──────────┬──────────┘ │
|
||||
│ │ fetch
|
||||
│ │ POST /notifications/register
|
||||
│ ▼
|
||||
│ ┌──────────────────────┐
|
||||
│ │ crowd-funder-for- │
|
||||
│ │ time-pwa (Capacitor │
|
||||
│ │ iOS on iPhone) │
|
||||
│ └──────────┬───────────┘
|
||||
│ │
|
||||
│ FCM data message (WAKEUP_PING) │ daily-notification-plugin
|
||||
▼ ▼ (Daily Reminder / dual / fetcher)
|
||||
┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ Firebase Cloud │ ──APNs──────► │ iPhone (physical) │
|
||||
│ Messaging │ silent push │ app.timesafari │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
### Repos and responsibilities
|
||||
|
||||
| Repo | Role |
|
||||
|------|------|
|
||||
| **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 (current)
|
||||
|
||||
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. 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`).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Mac with Xcode, Node.js 18+, and the **notification-wakeup-service** repo cloned and runnable
|
||||
- Physical iPhone (USB or wireless debugging) — **simulator is not sufficient** for reliable silent push / APNs behavior
|
||||
- ngrok account (free tier is enough for dev)
|
||||
- Firebase project with APNs configured for the iOS app bundle ID
|
||||
- Non-production app build (Notification Debug Panel is dev-only)
|
||||
|
||||
---
|
||||
|
||||
## 1. Install and configure ngrok (macOS)
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
# Homebrew
|
||||
brew install ngrok/ngrok/ngrok
|
||||
```
|
||||
|
||||
Or download from [https://ngrok.com/download](https://ngrok.com/download).
|
||||
|
||||
### Account and auth token
|
||||
|
||||
1. Sign up at [https://dashboard.ngrok.com/signup](https://dashboard.ngrok.com/signup).
|
||||
2. Copy your authtoken from **Your Authtoken** in the dashboard.
|
||||
3. Configure the CLI:
|
||||
|
||||
```bash
|
||||
ngrok config add-authtoken YOUR_AUTHTOKEN_HERE
|
||||
```
|
||||
|
||||
### Start a tunnel to the wakeup service
|
||||
|
||||
Assume the service listens on port **3000** (confirm in **notification-wakeup-service** `README` or `.env`).
|
||||
|
||||
If the service already defaults to port 3000 internally, you may not need to export PORT manually.
|
||||
|
||||
```bash
|
||||
# Terminal A — backend
|
||||
cd /path/to/notification-wakeup-service
|
||||
|
||||
npm install
|
||||
|
||||
# one-time setup if needed
|
||||
cp .env.example .env
|
||||
|
||||
# configure Firebase/service account/etc as required
|
||||
export PORT=3000
|
||||
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```bash
|
||||
# Terminal B — ngrok
|
||||
ngrok http 3000
|
||||
```
|
||||
|
||||
The backend only needs to be started once. The dedicated backend section below exists for verification and troubleshooting details, not as a second startup step.
|
||||
|
||||
ngrok prints a forwarding URL, for example:
|
||||
|
||||
```text
|
||||
Forwarding https://abc123.ngrok-free.app -> http://localhost:3000
|
||||
```
|
||||
|
||||
Use the **HTTPS** URL (not `http://127.0.0.1:3000`). The iPhone cannot reach your Mac’s localhost without the tunnel.
|
||||
|
||||
> **Note:** Free ngrok URLs change every time you restart ngrok unless you use a reserved domain (paid). Update the app debug override whenever the URL changes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Start the backend locally
|
||||
|
||||
Example (adjust to match **notification-wakeup-service**). On first setup, copy `.env.example` to `.env` and set Firebase service account, `PORT`, and other variables per that repo's docs.
|
||||
|
||||
If the backend is not already running from section 1:
|
||||
|
||||
```bash
|
||||
# If not already running from the previous step:
|
||||
cd /path/to/notification-wakeup-service
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Verify locally before ngrok:
|
||||
|
||||
```bash
|
||||
curl -sS http://localhost:3000/health
|
||||
```
|
||||
|
||||
Expected: HTTP 200 and a JSON body indicating the service is up (exact shape depends on that repo).
|
||||
|
||||
---
|
||||
|
||||
## 3. Obtain and use the ngrok HTTPS URL
|
||||
|
||||
1. Run `ngrok http <PORT>`.
|
||||
2. Copy the `https://….ngrok-free.app` host from the **Forwarding** line.
|
||||
3. Do **not** add a trailing slash when saving in the app (the debug config trims it).
|
||||
4. Optional: open `http://127.0.0.1:4040` (ngrok web UI) to inspect requests and responses while testing.
|
||||
|
||||
Test through the tunnel from your Mac:
|
||||
|
||||
```bash
|
||||
export NGROK_URL="https://abc123.ngrok-free.app"
|
||||
curl -sS "$NGROK_URL/health"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Generate and open the iOS workspace
|
||||
|
||||
From **crowd-funder-for-time-pwa**, generate the Capacitor iOS project and open it in Xcode. **[Section 5](#5-firebase--apns-setup-first-time-setup) (Firebase + APNs)** needs this workspace—for example to add `GoogleService-Info.plist` and enable Push Notifications in the app target. The app does not need Firebase or push fully configured yet; the goal here is a buildable Xcode project on your Mac.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build:ios:dev # or build:ios:test — non-production for debug panel
|
||||
```
|
||||
|
||||
Open the generated Xcode workspace (for example `ios/App/App.xcworkspace`), select your **physical iPhone**, enable signing, and Run when you are ready to verify the app launches.
|
||||
|
||||
Ensure `VITE_FIREBASE_*` variables are set for the Capacitor build you use (see `.env` / build docs). Native push registration runs at startup via `initializeNativePushAndFirebaseMessaging()` in `main.capacitor.ts` once Firebase is configured in the next section.
|
||||
|
||||
---
|
||||
|
||||
## 5. Firebase + APNs setup (first-time setup)
|
||||
|
||||
Complete this section once before your first physical-device push test. If Firebase and APNs are already configured for this app, skip to [section 6](#6-configure-the-notification-debug-panel-backend-override).
|
||||
|
||||
### Create or access a Firebase account
|
||||
|
||||
1. Sign in with a Google account at [https://console.firebase.google.com/](https://console.firebase.google.com/).
|
||||
|
||||
2. If this is your first time using Firebase:
|
||||
- Accept the Firebase terms.
|
||||
- Create a new Firebase account/workspace when prompted.
|
||||
|
||||
3. No paid Firebase plan is required for local iOS notification testing. The free **Spark** plan is sufficient for:
|
||||
- Firebase Cloud Messaging (FCM)
|
||||
- APNs silent push testing
|
||||
- local ngrok-based development
|
||||
|
||||
### Create a Firebase project
|
||||
|
||||
1. In the [Firebase Console](https://console.firebase.google.com/), click **Add project** (or **Create a project**).
|
||||
2. Enter a project name (for example, `timesafari-dev`) and continue through the wizard.
|
||||
3. **Google Analytics** is optional for this workflow; you can disable it for a simpler dev project.
|
||||
4. When the project is created, open it. **Cloud Messaging** is available on all projects — you do not need a separate enable step for FCM.
|
||||
|
||||
### Register the iOS app in Firebase
|
||||
|
||||
1. In the project overview, click the **iOS** icon (**Add app** → iOS).
|
||||
2. Enter the **Apple bundle ID**. It must **exactly** match the Capacitor / Xcode app ID:
|
||||
- **`app.timesafari`** (see `appId` in `capacitor.config.ts` and the Xcode target **Bundle Identifier**).
|
||||
3. App nickname and App Store ID are optional for local testing; continue.
|
||||
4. Download **`GoogleService-Info.plist`** when prompted and keep it handy for the next step.
|
||||
|
||||
### Add GoogleService-Info.plist to Xcode
|
||||
|
||||
1. Open the iOS workspace you generated in [section 4](#4-generate-and-open-the-ios-workspace) (for example `ios/App/App.xcworkspace`).
|
||||
2. In the Project Navigator, drag **`GoogleService-Info.plist`** into the **App** folder (the same one that contains AppDelegate.swift and Info.plist).
|
||||
3. In the dialog that appears:
|
||||
- Check **Copy items if needed** (so the file is copied into the project tree).
|
||||
- Under **Add to targets**, ensure the main app target (not only the share extension) is checked.
|
||||
4. Confirm the file appears under the app target in Xcode and is listed in **Build Phases** → **Copy Bundle Resources** if your project uses that phase for plists.
|
||||
|
||||
### Create an APNs Authentication Key
|
||||
|
||||
Apple uses APNs to deliver pushes to devices; Firebase needs an APNs key to talk to Apple on your behalf.
|
||||
|
||||
1. Sign in to [Apple Developer](https://developer.apple.com/account/) → **Certificates, Identifiers & Profiles**.
|
||||
2. Open **Keys** → **+** (create a new key).
|
||||
3. Name the key (for example, `Timesafari APNs Dev`).
|
||||
4. Enable **Apple Push Notifications service (APNs)** and continue.
|
||||
5. Register the key, then **Download** the `.p8` file. **You can download it only once** — store it securely.
|
||||
6. Note:
|
||||
- **Key ID** (shown on the key detail page)
|
||||
- **Team ID** (top right of the developer portal, or **Membership** details)
|
||||
|
||||
### Upload APNs key to Firebase
|
||||
|
||||
1. Firebase Console → your project → **Project settings** (gear icon).
|
||||
2. Open the **Cloud Messaging** tab.
|
||||
3. Under **Apple app configuration**, select your iOS app (`app.timesafari`) if prompted.
|
||||
4. Under **APNs Authentication Key**, click **Upload**.
|
||||
5. Select the `.p8` file and enter:
|
||||
- **Key ID**
|
||||
- **Team ID**
|
||||
6. Save. Firebase can now send FCM messages through APNs to your iOS app.
|
||||
|
||||
### Enable iOS capabilities in Xcode
|
||||
|
||||
1. Select the **App** target → **Signing & Capabilities**.
|
||||
2. Click **+ Capability** and add **Push Notifications**.
|
||||
3. Click **+ Capability** again and add **Background Modes**.
|
||||
4. Under Background Modes, enable **Remote notifications**.
|
||||
|
||||
These match what silent / data wake flows expect for background delivery.
|
||||
|
||||
### Configure Firebase Admin for the backend
|
||||
|
||||
**notification-wakeup-service** uses the Firebase Admin SDK to send FCM (and thus APNs) messages from your Mac.
|
||||
|
||||
1. Firebase Console → **Project settings** → **Service accounts**.
|
||||
2. Click **Generate new private key** and confirm download of the JSON file.
|
||||
3. Store the JSON outside the repo (do not commit it).
|
||||
4. Point the backend at it, for example:
|
||||
|
||||
```bash
|
||||
export GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/to/service-account.json"
|
||||
```
|
||||
|
||||
The backend uses this credential to authenticate with Firebase when calling endpoints such as `/debug/send-wakeup`. Set the same variable (or the equivalent env var documented in **notification-wakeup-service**) in the shell where you run `npm run dev`, or add it to that repo’s `.env` per its README.
|
||||
|
||||
### Verify Firebase configuration
|
||||
|
||||
Before ngrok end-to-end testing, confirm:
|
||||
|
||||
- [ ] App builds and launches on a **physical** iPhone without Firebase/plist errors in Xcode.
|
||||
- [ ] iOS shows the push **permission** prompt (or Settings → app → Notifications is enabled).
|
||||
- [ ] **Notification Debug Panel** shows an FCM token (after permission).
|
||||
- [ ] **Register Token Now** succeeds and ngrok (or local backend) shows `POST /notifications/register`.
|
||||
- [ ] Backend health and Firebase Admin env are set so `/debug/send-wakeup` can run when you reach that step in the workflow below.
|
||||
|
||||
---
|
||||
|
||||
## 6. Configure the Notification Debug Panel backend override
|
||||
|
||||
|
||||
The app normally calls `DEFAULT_NOTIFY_API_SERVER` (from `VITE_DEFAULT_NOTIFY_API_SERVER`, falling back to `AppString.PROD_NOTIFY_API_SERVER`). That is independent of `APP_SERVER`. For local wakeup testing, override the notification API base URL in the Debug Panel without rebuilding.
|
||||
|
||||
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
|
||||
|
||||
### Open the panel
|
||||
|
||||
1. Use a **non-production** bundle (e.g. dev/test build).
|
||||
2. **Account** → enable **Show All General Advanced Functions**.
|
||||
3. Open **Notification Debug Panel** (route `/dev/notifications`).
|
||||
|
||||
### Backend Testing section
|
||||
|
||||
| Control | Purpose |
|
||||
|---------|---------|
|
||||
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) |
|
||||
| **Test Mode** | Sends `testMode: true/false` in register / 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 |
|
||||
| **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`).
|
||||
|
||||
### Authentication vs backend URL
|
||||
|
||||
These settings are **independent**:
|
||||
|
||||
- **Backend URL** — which server receives notification API calls.
|
||||
- **Test Mode** — `testMode` field in JSON request bodies only.
|
||||
- **Skip JWT Authentication** — whether JWT `Authorization` headers are sent.
|
||||
|
||||
For a **hosted shared test server**: set the backend URL, keep **Test Mode** on if required, leave **Skip JWT Authentication** **off**, and ensure an active DID exists.
|
||||
|
||||
For **local ngrok**: set the backend URL; enable **Skip JWT Authentication** only if your local backend accepts unauthenticated requests.
|
||||
|
||||
### Programmatic override (optional)
|
||||
|
||||
From Safari Web Inspector or a dev console attached to the WebView:
|
||||
|
||||
```javascript
|
||||
import {
|
||||
setBackendBaseUrl,
|
||||
setTestMode,
|
||||
setBypassAuth,
|
||||
getNotificationApiBaseUrl,
|
||||
} from "@/services/notifications";
|
||||
|
||||
setBackendBaseUrl("https://abc123.ngrok-free.app");
|
||||
setTestMode(true);
|
||||
setBypassAuth(true); // local dev only — omit for hosted servers that require JWT
|
||||
getNotificationApiBaseUrl(); // → ngrok URL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Firebase and Xcode checklist (iOS)
|
||||
|
||||
This section is a quick verification checklist for the detailed Firebase/APNs setup steps above.
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| **Bundle ID** | Match Capacitor `appId` (`app.timesafari` in `capacitor.config.ts`) to Firebase iOS app and Xcode target |
|
||||
| **APNs auth key** | Firebase Console → Project Settings → Cloud Messaging → upload **APNs Authentication Key** (.p8) or certificates |
|
||||
| **Push Notifications** | Xcode target → **Signing & Capabilities** → **+ Capability** → **Push Notifications** |
|
||||
| **Background Modes** | Enable **Remote notifications** (and any others required by your plugin docs) |
|
||||
| **GoogleService-Info.plist** | Present in the iOS target if using Firebase iOS SDK paths in your build |
|
||||
| **FCM token** | Confirm **Register Token Now** succeeds in the debug panel and ngrok shows `POST /notifications/register` |
|
||||
|
||||
Silent/data pushes used for wake typically use a **content-available** style payload; confirm **notification-wakeup-service** and Firebase message format. The app logs `WAKEUP_PING` as ignored and does not refresh.
|
||||
|
||||
---
|
||||
|
||||
## 8. iOS-specific testing notes
|
||||
|
||||
### Physical device required
|
||||
|
||||
- APNs silent delivery and background wake behavior are **not** representative on the iOS Simulator.
|
||||
- Always validate on a plugged-in or trusted wireless device with a development provisioning profile.
|
||||
|
||||
### Silent push is best-effort
|
||||
|
||||
- iOS may **delay or coalesce** background pushes, especially on battery saver or under load.
|
||||
- A successful `/debug/send-wakeup` from the server does not guarantee immediate app wake.
|
||||
|
||||
### Force-quit limitations
|
||||
|
||||
- If the user **swipes the app away** from the app switcher, iOS often **will not** deliver background notifications until the user launches the app again.
|
||||
- Test with the app **backgrounded** (home button / gesture), not force-quit, when validating wake.
|
||||
|
||||
### Low Power Mode and Focus
|
||||
|
||||
- **Low Power Mode** can reduce background execution.
|
||||
- **Focus / Do Not Disturb** may affect notification presentation (separate from silent data wake, but confusing during tests).
|
||||
|
||||
### Send Real WAKEUP_PING (FCM/APNs diagnostic)
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommended debug workflow
|
||||
|
||||
1. Start **notification-wakeup-service** on the Mac.
|
||||
2. Start **ngrok** and copy the HTTPS URL.
|
||||
3. Set URL + **Test Mode** in the Notification Debug Panel; confirm **Backend Status**.
|
||||
4. Tap **Register Token Now** → confirm ngrok request and `[Notifications] Token registration success`.
|
||||
5. 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.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sample curl commands
|
||||
|
||||
Set your tunnel base URL:
|
||||
|
||||
```bash
|
||||
export BASE="https://abc123.ngrok-free.app"
|
||||
```
|
||||
|
||||
### Health
|
||||
|
||||
```bash
|
||||
curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
|
||||
```
|
||||
|
||||
### Register device (mirror app payload)
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/notifications/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"deviceId": "00000000-0000-4000-8000-000000000001",
|
||||
"fcmToken": "YOUR_FCM_TOKEN_FROM_DEBUG_PANEL",
|
||||
"platform": "ios",
|
||||
"testMode": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Refresh (backend only; app does not consume)
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/notifications/refresh" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"platform": "ios",
|
||||
"testMode": true
|
||||
}'
|
||||
```
|
||||
|
||||
This exercises **notification-wakeup-service** only. The app does **not** call `applyNotificationRefreshPayload` or schedule `api_*` from the response.
|
||||
|
||||
### Send wakeup push (debug)
|
||||
|
||||
Exact path and body depend on **notification-wakeup-service**; typical pattern:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/debug/send-wakeup" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"deviceId": "00000000-0000-4000-8000-000000000001",
|
||||
"testMode": true
|
||||
}'
|
||||
```
|
||||
|
||||
Confirm parameters (token vs deviceId, auth headers) in that repo’s README or OpenAPI spec.
|
||||
|
||||
---
|
||||
|
||||
## 11. Troubleshooting
|
||||
|
||||
### Refresh endpoint unreachable
|
||||
|
||||
| Symptom | Checks |
|
||||
|---------|--------|
|
||||
| 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 |
|
||||
|
||||
### Token registration failures
|
||||
|
||||
- Push permission granted on the device?
|
||||
- Firebase `VITE_FIREBASE_*` env vars baked into the build?
|
||||
- `[Notifications] Token registration failure` in Xcode — read HTTP status in ngrok inspect
|
||||
- Duplicate token skip: panel may show “skipped (duplicate)”; use **Register Token Now** to force re-register
|
||||
|
||||
### Silent push not waking the app
|
||||
|
||||
- App **backgrounded**, not force-quit
|
||||
- Physical device, correct provisioning profile
|
||||
- APNs key uploaded to Firebase; bundle ID matches
|
||||
- FCM message includes `data.type = "WAKEUP_PING"` (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
|
||||
- Confirm Xcode shows `push handler ignored type=WAKEUP_PING` (there is no Simulate WAKEUP_PING / refresh API in the app)
|
||||
|
||||
### Notifications duplicating
|
||||
|
||||
- Daily Reminder vs New Activity both scheduling — see `doc/notification-new-activity-lay-of-the-land.md`
|
||||
- Leftover `api_*` before Phase 4 cleanup (startup `clearApiNotifications()`)
|
||||
|
||||
### Stale ngrok URL
|
||||
|
||||
- After restarting ngrok, update **Notification Backend URL** in the panel and tap **Save**
|
||||
- Or clear override (empty field + Save) only if you intend to hit `DEFAULT_NOTIFY_API_SERVER` again
|
||||
|
||||
### Plugin / JWT errors after refresh
|
||||
|
||||
- Refresh calls `configureNativeFetcherIfReady()` before scheduling — ensure an **active DID** and endorser API settings exist in the app DB
|
||||
- See `doc/notification-from-api-call.md` and `nativeFetcherConfig.ts`
|
||||
|
||||
---
|
||||
|
||||
## 12. Key source files (crowd-funder-for-time-pwa)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/services/notifications/NotificationDebugConfig.ts` | Backend URL, testMode, and bypassAuth overrides |
|
||||
| `src/services/notifications/NotificationDebugEvents.ts` | Panel event log + `logNotification()` |
|
||||
| `src/services/notifications/notificationLog.ts` | Structured log helpers |
|
||||
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
|
||||
| `src/services/notifications/NativeNotificationService.ts` | 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 |
|
||||
|
||||
---
|
||||
|
||||
## 13. Related docs
|
||||
|
||||
- [notification-debug-panel.md](./notification-debug-panel.md) — panel controls, authentication, troubleshooting
|
||||
- [Notification Debug Panel (README)](../README.md#notification-debug-panel-dev-builds)
|
||||
- [notification-system-overview.md](./notification-system-overview.md)
|
||||
- [notification-from-api-call.md](./notification-from-api-call.md)
|
||||
- [notification-new-activity-lay-of-the-land.md](./notification-new-activity-lay-of-the-land.md)
|
||||
- [BUILDING.md](../BUILDING.md) — iOS build commands
|
||||
|
||||
For plugin-native behavior (exact alarm, iOS pending inspector), see **daily-notification-plugin** documentation. For FCM payload format and `/debug/send-wakeup` contract, see **notification-wakeup-service**.
|
||||
@@ -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**
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
# Native Share Target Implementation Guide
|
||||
|
||||
**Date:** 2025-01-27
|
||||
**Purpose:** Enable TimeSafari native iOS and Android apps to receive shared images from other apps
|
||||
|
||||
## Current State
|
||||
|
||||
The app currently supports **PWA/web share target** functionality:
|
||||
- Service worker intercepts POST to `/share-target`
|
||||
- Images stored in temp database as base64
|
||||
- `SharedPhotoView.vue` processes and displays shared images
|
||||
|
||||
**This does NOT work for native iOS/Android builds** because:
|
||||
- Service workers don't run in native app contexts
|
||||
- Native platforms use different sharing mechanisms (Share Extensions on iOS, Intent Filters on Android)
|
||||
|
||||
## Required Changes
|
||||
|
||||
### 1. iOS Implementation
|
||||
|
||||
#### 1.1 Create Share Extension Target
|
||||
|
||||
1. Open `ios/App/App.xcodeproj` in Xcode
|
||||
2. File → New → Target
|
||||
3. Select "Share Extension" template
|
||||
4. Name it "TimeSafariShareExtension"
|
||||
5. Bundle Identifier: `app.timesafari.shareextension`
|
||||
6. Language: Swift
|
||||
|
||||
#### 1.2 Configure Share Extension Info.plist
|
||||
|
||||
Add to `ios/App/TimeSafariShareExtension/Info.plist`:
|
||||
|
||||
```xml
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
```
|
||||
|
||||
#### 1.3 Implement ShareViewController
|
||||
|
||||
Create `ios/App/TimeSafariShareExtension/ShareViewController.swift`:
|
||||
|
||||
```swift
|
||||
import UIKit
|
||||
import Social
|
||||
import MobileCoreServices
|
||||
import Capacitor
|
||||
|
||||
class ShareViewController: SLComposeServiceViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
self.title = "Share to TimeSafari"
|
||||
}
|
||||
|
||||
override func isContentValid() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func didSelectPost() {
|
||||
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||
let itemProvider = extensionItem.attachments?.first else {
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle image sharing
|
||||
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
|
||||
itemProvider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let url = item as? URL {
|
||||
// Handle file URL
|
||||
self.handleSharedImage(url: url)
|
||||
} else if let image = item as? UIImage {
|
||||
// Handle UIImage directly
|
||||
self.handleSharedImage(image: image)
|
||||
} else if let data = item as? Data {
|
||||
// Handle image data
|
||||
self.handleSharedImage(data: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSharedImage(url: URL? = nil, image: UIImage? = nil, data: Data? = nil) {
|
||||
var imageData: Data?
|
||||
var fileName: String?
|
||||
|
||||
if let url = url {
|
||||
imageData = try? Data(contentsOf: url)
|
||||
fileName = url.lastPathComponent
|
||||
} else if let image = image {
|
||||
imageData = image.jpegData(compressionQuality: 0.8)
|
||||
fileName = "shared-image.jpg"
|
||||
} else if let data = data {
|
||||
imageData = data
|
||||
fileName = "shared-image.jpg"
|
||||
}
|
||||
|
||||
guard let imageData = imageData else {
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
let base64String = imageData.base64EncodedString()
|
||||
|
||||
// Store in shared UserDefaults (accessible by main app)
|
||||
let userDefaults = UserDefaults(suiteName: "group.app.timesafari.share")
|
||||
userDefaults?.set(base64String, forKey: "sharedPhotoBase64")
|
||||
userDefaults?.set(fileName ?? "shared-image.jpg", forKey: "sharedPhotoFileName")
|
||||
userDefaults?.synchronize()
|
||||
|
||||
// Open main app with deep link
|
||||
let url = URL(string: "timesafari://shared-photo?fileName=\(fileName?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "shared-image.jpg")")!
|
||||
var responder = self as UIResponder?
|
||||
while responder != nil {
|
||||
if let application = responder as? UIApplication {
|
||||
application.open(url, options: [:], completionHandler: nil)
|
||||
break
|
||||
}
|
||||
responder = responder?.next
|
||||
}
|
||||
|
||||
// Close share extension
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
}
|
||||
|
||||
override func configurationItems() -> [Any]! {
|
||||
return []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4 Configure App Groups
|
||||
|
||||
1. In Xcode, select main app target → Signing & Capabilities
|
||||
2. Add "App Groups" capability
|
||||
3. Create group: `group.app.timesafari.share`
|
||||
4. Repeat for Share Extension target with same group name
|
||||
|
||||
#### 1.5 Update Main App to Read from App Group
|
||||
|
||||
The main app needs to check for shared images on launch. This should be added to `AppDelegate.swift` or handled in JavaScript.
|
||||
|
||||
### 2. Android Implementation
|
||||
|
||||
#### 2.1 Update AndroidManifest.xml
|
||||
|
||||
Add intent filter to `MainActivity` in `android/app/src/main/AndroidManifest.xml`:
|
||||
|
||||
```xml
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
... existing attributes ...>
|
||||
|
||||
... existing intent filters ...
|
||||
|
||||
<!-- Share Target Intent Filter -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Multiple images support (optional) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
```
|
||||
|
||||
#### 2.2 Handle Intent in MainActivity
|
||||
|
||||
Update `android/app/src/main/java/app/timesafari/MainActivity.java`:
|
||||
|
||||
```java
|
||||
package app.timesafari;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import com.getcapacitor.Plugin;
|
||||
import java.io.InputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
private static final String TAG = "MainActivity";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
handleShareIntent(getIntent());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
handleShareIntent(intent);
|
||||
}
|
||||
|
||||
private void handleShareIntent(Intent intent) {
|
||||
if (intent == null) return;
|
||||
|
||||
String action = intent.getAction();
|
||||
String type = intent.getType();
|
||||
|
||||
if (Intent.ACTION_SEND.equals(action) && type != null && type.startsWith("image/")) {
|
||||
Uri imageUri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
if (imageUri != null) {
|
||||
handleSharedImage(imageUri, intent.getStringExtra(Intent.EXTRA_TEXT));
|
||||
}
|
||||
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action) && type != null && type.startsWith("image/")) {
|
||||
// Handle multiple images (optional - for now just take first)
|
||||
java.util.ArrayList<Uri> imageUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
||||
if (imageUris != null && !imageUris.isEmpty()) {
|
||||
handleSharedImage(imageUris.get(0), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSharedImage(Uri imageUri, String fileName) {
|
||||
try {
|
||||
// Read image data
|
||||
InputStream inputStream = getContentResolver().openInputStream(imageUri);
|
||||
if (inputStream == null) {
|
||||
Log.e(TAG, "Failed to open input stream for shared image");
|
||||
return;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] data = new byte[8192];
|
||||
int nRead;
|
||||
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
|
||||
buffer.write(data, 0, nRead);
|
||||
}
|
||||
buffer.flush();
|
||||
byte[] imageBytes = buffer.toByteArray();
|
||||
|
||||
// Convert to base64
|
||||
String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP);
|
||||
|
||||
// Extract filename from URI or use default
|
||||
String actualFileName = fileName;
|
||||
if (actualFileName == null || actualFileName.isEmpty()) {
|
||||
String path = imageUri.getPath();
|
||||
if (path != null) {
|
||||
int lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < path.length() - 1) {
|
||||
actualFileName = path.substring(lastSlash + 1);
|
||||
}
|
||||
}
|
||||
if (actualFileName == null || actualFileName.isEmpty()) {
|
||||
actualFileName = "shared-image.jpg";
|
||||
}
|
||||
}
|
||||
|
||||
// Store in SharedPreferences (accessible by JavaScript via Capacitor)
|
||||
android.content.SharedPreferences prefs = getSharedPreferences("TimeSafariShared", MODE_PRIVATE);
|
||||
android.content.SharedPreferences.Editor editor = prefs.edit();
|
||||
editor.putString("sharedPhotoBase64", base64String);
|
||||
editor.putString("sharedPhotoFileName", actualFileName);
|
||||
editor.apply();
|
||||
|
||||
// Trigger JavaScript event or navigate to shared-photo route
|
||||
// This will be handled by JavaScript checking for shared data on app launch
|
||||
Log.d(TAG, "Shared image stored, filename: " + actualFileName);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error handling shared image", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 Add Required Permissions
|
||||
|
||||
Ensure `AndroidManifest.xml` has:
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <!-- Android 13+ -->
|
||||
```
|
||||
|
||||
### 3. JavaScript Layer Updates
|
||||
|
||||
#### 3.1 Create Native Share Handler
|
||||
|
||||
Create `src/services/nativeShareHandler.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Native Share Handler
|
||||
* Handles shared images from native iOS and Android platforms
|
||||
*/
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { App } from "@capacitor/app";
|
||||
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
||||
import { logger } from "../utils/logger";
|
||||
import { SHARED_PHOTO_BASE64_KEY } from "../libs/util";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
|
||||
/**
|
||||
* Check for shared images from native platforms and store in temp database
|
||||
*/
|
||||
export async function checkForNativeSharedImage(
|
||||
platformService: InstanceType<typeof PlatformServiceMixin>
|
||||
): Promise<boolean> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Capacitor.getPlatform() === "ios") {
|
||||
return await checkIOSSharedImage(platformService);
|
||||
} else if (Capacitor.getPlatform() === "android") {
|
||||
return await checkAndroidSharedImage(platformService);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error checking for native shared image:", error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for shared image on iOS (from App Group UserDefaults)
|
||||
*/
|
||||
async function checkIOSSharedImage(
|
||||
platformService: InstanceType<typeof PlatformServiceMixin>
|
||||
): Promise<boolean> {
|
||||
// iOS uses App Groups to share data between extension and main app
|
||||
// We need to use a Capacitor plugin or native code to read from App Group
|
||||
// For now, this is a placeholder - requires native plugin implementation
|
||||
|
||||
// Option 1: Use Capacitor plugin to read from App Group
|
||||
// Option 2: Use native code bridge
|
||||
|
||||
logger.debug("Checking for iOS shared image (not yet implemented)");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for shared image on Android (from SharedPreferences)
|
||||
*/
|
||||
async function checkAndroidSharedImage(
|
||||
platformService: InstanceType<typeof PlatformServiceMixin>
|
||||
): Promise<boolean> {
|
||||
// Android stores in SharedPreferences
|
||||
// We need a Capacitor plugin to read from SharedPreferences
|
||||
// For now, this is a placeholder - requires native plugin implementation
|
||||
|
||||
logger.debug("Checking for Android shared image (not yet implemented)");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store shared image in temp database
|
||||
*/
|
||||
async function storeSharedImage(
|
||||
base64Data: string,
|
||||
fileName: string,
|
||||
platformService: InstanceType<typeof PlatformServiceMixin>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const existing = await platformService.$getTemp(SHARED_PHOTO_BASE64_KEY);
|
||||
|
||||
if (existing) {
|
||||
await platformService.$updateEntity(
|
||||
"temp",
|
||||
{ blobB64: base64Data },
|
||||
"id = ?",
|
||||
[SHARED_PHOTO_BASE64_KEY]
|
||||
);
|
||||
} else {
|
||||
await platformService.$insertEntity(
|
||||
"temp",
|
||||
{ id: SHARED_PHOTO_BASE64_KEY, blobB64: base64Data },
|
||||
["id", "blobB64"]
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("Stored shared image in temp database");
|
||||
} catch (error) {
|
||||
logger.error("Error storing shared image:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Update main.capacitor.ts
|
||||
|
||||
Add check for shared images on app launch:
|
||||
|
||||
```typescript
|
||||
// In main.capacitor.ts, after app mount:
|
||||
|
||||
import { checkForNativeSharedImage } from "./services/nativeShareHandler";
|
||||
|
||||
// Check for shared images when app becomes active
|
||||
App.addListener("appStateChange", async (state) => {
|
||||
if (state.isActive) {
|
||||
// Check for native shared images
|
||||
const hasSharedImage = await checkForNativeSharedImage(/* platformService */);
|
||||
if (hasSharedImage) {
|
||||
// Navigate to shared-photo view
|
||||
await router.push({
|
||||
name: "shared-photo",
|
||||
query: { source: "native" }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Also check on initial launch
|
||||
App.getLaunchUrl().then((result) => {
|
||||
if (result?.url) {
|
||||
// Handle deep link
|
||||
} else {
|
||||
// Check for shared image
|
||||
checkForNativeSharedImage(/* platformService */).then((hasShared) => {
|
||||
if (hasShared) {
|
||||
router.push({ name: "shared-photo", query: { source: "native" } });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### 3.3 Update SharedPhotoView.vue
|
||||
|
||||
The existing `SharedPhotoView.vue` should work as-is, but we may want to add detection for native vs web sources.
|
||||
|
||||
### 4. Alternative Approach: Capacitor Plugin
|
||||
|
||||
Instead of implementing native code directly, consider creating a Capacitor plugin:
|
||||
|
||||
1. **Create plugin**: `@capacitor-community/share-target` or custom plugin
|
||||
2. **Plugin methods**:
|
||||
- `checkForSharedImage()`: Returns shared image data if available
|
||||
- `clearSharedImage()`: Clears shared image data after processing
|
||||
|
||||
This would be cleaner and more maintainable.
|
||||
|
||||
### 5. Testing Checklist
|
||||
|
||||
- [ ] Test sharing image from Photos app on iOS
|
||||
- [ ] Test sharing image from Gallery app on Android
|
||||
- [ ] Test sharing from other apps (Safari, Chrome, etc.)
|
||||
- [ ] Verify image appears in SharedPhotoView
|
||||
- [ ] Test "Record Gift" flow with shared image
|
||||
- [ ] Test "Save as Profile" flow with shared image
|
||||
- [ ] Test cancel flow
|
||||
- [ ] Verify temp storage cleanup
|
||||
- [ ] Test app launch with shared image pending
|
||||
- [ ] Test app already running when image is shared
|
||||
|
||||
### 6. Implementation Priority
|
||||
|
||||
**Phase 1: Android (Simpler)**
|
||||
1. Update AndroidManifest.xml
|
||||
2. Implement MainActivity intent handling
|
||||
3. Create JavaScript handler
|
||||
4. Test end-to-end
|
||||
|
||||
**Phase 2: iOS (More Complex)**
|
||||
1. Create Share Extension target
|
||||
2. Implement ShareViewController
|
||||
3. Configure App Groups
|
||||
4. Create JavaScript handler
|
||||
5. Test end-to-end
|
||||
|
||||
### 7. Notes
|
||||
|
||||
- **App Groups (iOS)**: Required for sharing data between Share Extension and main app
|
||||
- **SharedPreferences (Android)**: Standard way to share data between app components
|
||||
- **Base64 Encoding**: Both platforms convert images to base64 for JavaScript compatibility
|
||||
- **File Size Limits**: Consider large image handling and memory management
|
||||
- **Permissions**: Android 13+ requires `READ_MEDIA_IMAGES` instead of `READ_EXTERNAL_STORAGE`
|
||||
|
||||
### 8. References
|
||||
|
||||
- [iOS Share Extensions](https://developer.apple.com/documentation/social)
|
||||
- [Android Share Targets](https://developer.android.com/training/sharing/receive)
|
||||
- [Capacitor App Plugin](https://capacitorjs.com/docs/apis/app)
|
||||
- [Capacitor Native Bridge](https://capacitorjs.com/docs/guides/building-plugins)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# New Activity Notifications: iOS Parity with Android
|
||||
|
||||
**Purpose:** Describe what is required for **iOS** to match **Android** for the daily-notification-plugin **API-driven “New Activity”** flow (`scheduleDualNotification` / `cancelDualSchedule`, with prefetch and Endorser-backed content). The canonical product behavior is documented in `doc/notification-from-api-call.md` and `doc/notification-new-activity-lay-of-the-land.md`.
|
||||
|
||||
**Plugin source of truth:** The Capacitor package is `@timesafari/daily-notification-plugin`, pulled from the official remote in `package.json` (`git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git`). Plugin development happens in that repository; this app bumps the dependency and runs `npm install` / `npx cap sync` after releases.
|
||||
|
||||
---
|
||||
|
||||
## 1. What “parity” means here
|
||||
|
||||
| Concern | Intended behavior |
|
||||
|--------|---------------------|
|
||||
| **Scheduling** | Dual schedule: prefetch job **before** notify time (app uses cron T−5 minutes), then user-visible notification at the chosen time. |
|
||||
| **API content** | Prefetch calls the **same Endorser semantics** as the Android host: **`plansLastUpdatedBetween`** (POST) with **starred plan IDs**, JWT auth, aggregated titles/bodies consistent with `TimeSafariNativeFetcher`. |
|
||||
| **Starred plans** | `updateStarredPlans({ planIds })` from the app must affect what the native prefetch queries. |
|
||||
| **Configure** | `configureNativeFetcher({ apiBaseUrl, activeDid, jwtToken, … })` supplies credentials the native layer uses for prefetch. |
|
||||
| **Lifecycle** | `cancelDualSchedule()` removes the dual prefetch + notify schedule without breaking the separate Daily Reminder. |
|
||||
|
||||
Platform differences (iOS **BGTaskScheduler** is opportunistic; Android **alarms/WorkManager** can be more exact) mean **timing** may never be identical, but **API behavior and user-visible copy** should align.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state: Android (this app)
|
||||
|
||||
- **Host native fetcher:** `android/.../TimeSafariNativeFetcher.java` implements the plugin’s `NativeNotificationContentFetcher` and calls **`POST …/api/v2/report/plansLastUpdatedBetween`** using starred plan IDs (via plugin storage from `updateStarredPlans`).
|
||||
- **Registration:** `MainActivity` calls `DailyNotificationPlugin.setNativeFetcher(new TimeSafariNativeFetcher(this))`.
|
||||
- **Plugin (Android) — older notes:** Prior dual-schedule issues (native fetcher / fetch cron) are addressed in **plugin ≥ 3.0.0** (chained dual: notify after prefetch). Historical analysis: `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Current state: iOS (this app + bundled plugin)
|
||||
|
||||
### 3.1 This repository
|
||||
|
||||
- **iOS native fetcher:** `ios/App/App/TimeSafariNativeFetcher.swift` implements `NativeNotificationContentFetcher` (Endorser `plansLastUpdatedBetween`, same prefs keys as Java). **`AppDelegate`** calls `DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)` at launch **before** any `configureNativeFetcher` from JS (see plugin `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` and **`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`**).
|
||||
- **JS/TS is already shared:** `nativeFetcherConfig.ts`, `dualScheduleConfig.ts`, `syncStarredPlansToNativePlugin.ts`, and `AccountViewView.vue` call the same APIs on both platforms.
|
||||
- **Info.plist** already lists `UIBackgroundModes` (fetch, processing) and `BGTaskSchedulerPermittedIdentifiers` for the plugin’s task IDs. Xcode **Signing & Capabilities** should still enable **Background fetch** and **Background processing** (see `doc/daily-notification-plugin-integration.md`).
|
||||
- **AppDelegate** posts `DailyNotificationDelivered` for foreground presentation—aligned with plugin rollover behavior.
|
||||
|
||||
### 3.2 Bundled plugin (`node_modules/@timesafari/daily-notification-plugin`, iOS)
|
||||
|
||||
Requires **plugin ≥ 3.0.0** (register native fetcher, chained dual, iOS `updateStarredPlans`). Version pinned in `ios/App/Podfile.lock` after `pod install`.
|
||||
|
||||
- **`scheduleDualNotification` / `cancelDualSchedule`** — see plugin release notes; clean sync + `pod install` if you see `UNIMPLEMENTED` (`doc/plugin-feedback-ios-scheduleDualNotification.md`).
|
||||
- **`configureNativeFetcher`** — **requires** `DailyNotificationPlugin.registerNativeFetcher` first; the host Swift fetcher performs **`plansLastUpdatedBetween`** (plugin does not use in-plugin `offers` GET when a fetcher is registered—mirrors Android).
|
||||
- **`updateStarredPlans`** — implemented on iOS in current plugin; persists **`daily_notification_timesafari.starredPlanIds`** for the host fetcher.
|
||||
- **Chained dual** — user notification is armed **after** prefetch for that cycle (plugin); iOS remains subject to BG scheduling limits; see **§3.3**.
|
||||
|
||||
### 3.3 Prefetch before notify (ordering, not cron)
|
||||
|
||||
iOS has no system cron; the app/plugin may still **parse** cron to compute “next run” times. The hard part is **ordering**: if **prefetch** is driven by **`BGTaskScheduler`** (opportunistic) and **notify** by **`UNUserNotificationCenter`** at a fixed time **T**, those are **independent**. The OS can deliver the local notification at **T** while prefetch runs **after** **T** or not at all—so the awkward case (notify first, prefetch later, stale or fallback content) **can** happen. Two peer timers do **not** imply “fetch always completes before **T**.”
|
||||
|
||||
To **enforce** prefetch-before-notify as a rule, use **chaining**, not two unrelated schedules:
|
||||
|
||||
- After prefetch for that cycle **finishes** (success or explicit timeout policy), **then** schedule or **replace** the pending `UNNotificationRequest` for time **T** with the resolved title/body (or fallback). Until then, do not arm a user-visible notification that claims fresh API content.
|
||||
- **Tradeoffs:** If prefetch is late, the notification may be **late**; if prefetch never runs before a deadline, use **fallback** copy at **T** or skip—product choice.
|
||||
- **Parsing cron** remains useful to compute **T** and to decide when to **submit** BG work; **ordering** is a **pipeline** decision (fetch → cache → arm notify), not “BG at T−5 and UN at **T** both scheduled up front.”
|
||||
|
||||
Plugin work item **§4A.3** should reflect this: document the chosen strategy (chained arm vs best-effort dual timer) and how it interacts with `relationship.contentTimeout` / fallback.
|
||||
|
||||
---
|
||||
|
||||
## 4. Work breakdown
|
||||
|
||||
### 4A. Plugin (`daily-notification-plugin`) — status (v3.x)
|
||||
|
||||
Items below were the original gap list; **plugin ≥ 3.0.0** ships **iOS** `updateStarredPlans`, **`registerNativeFetcher`**, **chained dual** on iOS and Android, and Android dual-path fixes. Remaining work is **release coordination** (bump, sync, QA), not greenfield plugin implementation.
|
||||
|
||||
1. **`updateStarredPlans` on iOS** — shipped in current plugin.
|
||||
|
||||
2. **iOS `plansLastUpdatedBetween` / host fetcher** — shipped: host registers **`TimeSafariNativeFetcher`** (Swift); plugin does not duplicate Endorser logic when a fetcher is registered.
|
||||
|
||||
3. **Dual schedule / chaining** — shipped (notify after prefetch; see plugin release notes and **§3.3**).
|
||||
|
||||
4. **Android dual path** — chained dual + native fetcher alignment in current plugin (see `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` for historical context).
|
||||
|
||||
5. **JWT pool / expiry (Phase B)**
|
||||
- **App:** Phase B is already implemented: `configureNativeFetcherIfReady()` passes `jwtTokens` from `mintBackgroundJwtTokenPool` on **both** iOS and Android (`src/services/notifications/nativeFetcherConfig.ts`).
|
||||
- **Android:** `TimeSafariNativeFetcher` selects a bearer from the pool for background requests (`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`).
|
||||
- **iOS:** The bundled plugin’s `configureNativeFetcher` **already accepts and persists** `jwtTokens` / `jwtTokenPoolJson`, and the in-plugin fetch path uses a bearer from the primary token or pool. What is **not** yet at parity with Android is **which API** that token is used for (`offers` GET vs `plansLastUpdatedBetween` + starred plans)—that falls under **§4A.2**, not “waiting for Phase B on iOS.”
|
||||
- **Expiry:** Re-calling `configureNativeFetcherIfReady` on foreground / Account (see `notification-from-api-call.md`) remains relevant on both platforms.
|
||||
|
||||
### 4B. This app (crowd-funder-for-time-pwa) — after or alongside plugin changes
|
||||
|
||||
1. **Bump `@timesafari/daily-notification-plugin`** to **≥ 3.0.0** via the git dependency in `package.json`, run `npm install`, `npx cap sync ios`, `cd ios/App && pod install`, clean build (`doc/plugin-feedback-ios-scheduleDualNotification.md`, **`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`**).
|
||||
2. **iOS native fetcher** — **Done:** `TimeSafariNativeFetcher.swift` + `registerNativeFetcher` in `AppDelegate` (see handoff doc).
|
||||
3. **Re-test** `syncStarredPlansToNativePlugin` on iOS; the helper may still catch `UNIMPLEMENTED` for older plugin binaries.
|
||||
4. **Xcode:** Confirm Background Modes capabilities match `Info.plist`.
|
||||
5. **QA:** Full matrix in `doc/notification-from-api-call.md` (enable/disable, empty starred list, JWT expiry, foreground/background); chained dual timing (notify after prefetch).
|
||||
|
||||
### 4C. Related product bug (both platforms)
|
||||
|
||||
- **`PushNotificationPermission.vue` vs New Activity:** Enabling New Activity can still schedule the **single** daily reminder by mistake; turning New Activity off may not cancel that reminder. See `doc/notification-new-activity-lay-of-the-land.md`. Fixing this is orthogonal to iOS/Android API parity but affects perceived “notifications behavior.”
|
||||
|
||||
---
|
||||
|
||||
## 5. Reference map (this repo)
|
||||
|
||||
| Topic | Document |
|
||||
|-------|-----------|
|
||||
| Plugin post-bump handoff (iOS fetcher + chained dual) | `doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md` |
|
||||
| Feature plan & file list | `doc/notification-from-api-call.md` |
|
||||
| Dual vs Daily Reminder confusion | `doc/notification-new-activity-lay-of-the-land.md` |
|
||||
| iOS `UNIMPLEMENTED` / PluginHeaders | `doc/plugin-feedback-ios-scheduleDualNotification.md` |
|
||||
| Android dual schedule + native fetcher | `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` |
|
||||
| Integration & Xcode | `doc/daily-notification-plugin-integration.md` |
|
||||
| Android host fetcher | `android/.../TimeSafariNativeFetcher.java`, `MainActivity.java` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Handoff to plugin repo (Cursor / isolated workspace)
|
||||
|
||||
Use this section when **daily-notification-plugin** is open **without** the TimeSafari app tree, so implementers do not depend on paths that only exist in crowd-funder-for-time-pwa.
|
||||
|
||||
### 6.1 Bring reference material into scope
|
||||
|
||||
| Source (this app repo) | Why |
|
||||
|------------------------|-----|
|
||||
| `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` | **Canonical Endorser behavior** for New Activity: POST body, pagination, aggregation copy, prefs keys for starred IDs and `last_acked_jwt_id`. Copy or open alongside the plugin when implementing iOS fetch or `setNativeFetcher`. |
|
||||
| `src/services/notifications/dualScheduleConfig.ts` | Shape the app sends to `scheduleDualNotification` (`buildDualScheduleConfig`). |
|
||||
| `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` | Android plugin: dual path must call native fetcher at fetch cron. |
|
||||
| `doc/plugin-feedback-ios-scheduleDualNotification.md` | iOS `UNIMPLEMENTED` / PluginHeaders troubleshooting. |
|
||||
|
||||
In the plugin repo itself, align with **`src/definitions.ts`** (`DualScheduleConfiguration`, `configureNativeFetcher`, `updateStarredPlans`) and **INTEGRATION_GUIDE** if present.
|
||||
|
||||
### 6.2 HTTP / storage contract (match `TimeSafariNativeFetcher`)
|
||||
|
||||
Implementations on **iOS** (in-plugin Swift or host `NativeNotificationContentFetcher`) should match this **unless** product explicitly changes:
|
||||
|
||||
- **Method & path:** `POST` `{apiBaseUrl}/api/v2/report/plansLastUpdatedBetween` (no trailing slash mismatch on `apiBaseUrl`).
|
||||
- **Headers:** `Content-Type: application/json`, `Authorization: Bearer {token}` (token from `jwtToken` or **JWT pool** selection—see Java `selectBearerTokenForRequest`: UTC day mod pool size).
|
||||
- **JSON body:** `planIds` (array of strings, possibly empty), `afterId` (string; use `"0"` if none stored).
|
||||
- **Starred plans:** Android: SharedPreferences **`daily_notification_timesafari`** + key **`starredPlanIds`**. iOS (plugin + host): `UserDefaults.standard` key **`daily_notification_timesafari.starredPlanIds`** (JSON array string).
|
||||
- **Pagination:** After a successful response with non-empty `data`, update **`last_acked_jwt_id`** from the last row’s `jwtId` (item or nested `plan.jwtId`)—see Java `updateLastAckedJwtIdFromResponse`. iOS host (`TimeSafariNativeFetcher.swift`) persists **`daily_notification_timesafari.last_acked_jwt_id`** in `UserDefaults.standard`.
|
||||
- **Empty `data`:** Return **no** notification items (empty list); do not synthesize a “no updates” push from an empty result—Java returns empty `contents` when `data` is absent or empty.
|
||||
- **Non-empty `data`:** One aggregated `NotificationContent`: titles **Starred Project Update** / **Starred Project Updates**, bodies use typographic quotes around first project name and **has been updated.** / **+ N more have been updated.** (see Java `parseApiResponse`).
|
||||
|
||||
### 6.3 Likely plugin touchpoints (maintenance / debugging)
|
||||
|
||||
- **iOS:** `ios/Plugin/DailyNotificationPlugin.swift`, `DailyNotificationScheduleHelper.swift`, native fetcher registry, BG / UN paths.
|
||||
- **Android:** `DailyNotificationPlugin.kt`, fetch workers / `ScheduleHelper`—see dual-schedule feedback doc for history.
|
||||
|
||||
### 6.4 Suggested order (plugin shipped ≥ 3.0.0)
|
||||
|
||||
1. Tag / publish **`@timesafari/daily-notification-plugin`**.
|
||||
2. **Consuming app:** bump, `npm install`, `npx cap sync`, `pod install`, QA (`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Acceptance checklist (iOS vs Android product intent)
|
||||
|
||||
- [ ] Prefetch uses **plansLastUpdatedBetween** (or host fetcher with identical behavior), not only `offers` GET.
|
||||
- [ ] **Starred plan IDs** from settings change what is queried (`updateStarredPlans` works on iOS).
|
||||
- [ ] Notification title/body match the **same rules** as Android for “starred project updates” (including empty updates).
|
||||
- [ ] `configureNativeFetcher` + JWT refresh story documented; re-config on foreground if needed (`notification-from-api-call.md`).
|
||||
- [ ] `cancelDualSchedule` clears dual prefetch/notify without leaving orphan schedules.
|
||||
- [ ] Understand and document **iOS timing** limitations vs Android for support/Help copy.
|
||||
- [ ] **Prefetch vs notify ordering** on iOS: chosen strategy (chained arm vs independent BG + UN) documented; avoids claiming fresh API content when prefetch has not run yet (**§3.3**).
|
||||
@@ -0,0 +1,229 @@
|
||||
# Notification Debug Panel
|
||||
|
||||
**Created:** 2026-07-07
|
||||
**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 FCM token registration, AlertSearch authorization upload, FCM wakeup delivery diagnostics (`/debug/send-wakeup`), and local schedule inspection. It does not schedule notifications from `WAKEUP_PING`. The production `WAKEUP_PING` → `/notifications/refresh` → `api_*` path was retired; leftover `api_*` schedules are cleared once at startup (Phase 4).
|
||||
|
||||
---
|
||||
|
||||
## Notification API base URL
|
||||
|
||||
Notification HTTP calls (`/notifications/register`, `/notifications/alert-authorization`, `/debug/send-wakeup`, etc.) do **not** use `APP_SERVER`. They use a dedicated Notification API host, resolved at runtime by `getNotificationApiBaseUrl()` in `NotificationDebugConfig.ts`. The app does not call `/notifications/refresh`.
|
||||
|
||||
### Configuration constants
|
||||
|
||||
| Symbol | Location | Purpose |
|
||||
|--------|----------|---------|
|
||||
| `VITE_DEFAULT_NOTIFY_API_SERVER` | `.env.development` / `.env.test` / `.env.production` | Build-time default Notification API URL for that Vite mode (same pattern as other `VITE_DEFAULT_*` backends) |
|
||||
| `DEFAULT_NOTIFY_API_SERVER` | `src/constants/app.ts` | Runtime constant: `import.meta.env.VITE_DEFAULT_NOTIFY_API_SERVER \|\| AppString.PROD_NOTIFY_API_SERVER` |
|
||||
| `AppString.PROD_NOTIFY_API_SERVER` | `src/constants/app.ts` | Hardcoded production fallback: `https://notify-api.timesafari.app` |
|
||||
| `AppString.TEST_NOTIFY_API_SERVER` | `src/constants/app.ts` | Hardcoded test host: `https://test-notify-api.timesafari.app` (for explicit UI/debug use; not the automatic fallback) |
|
||||
|
||||
Production, test, and development builds get different Notification API URLs from their respective `.env.*` files. Runtime request code always goes through `DEFAULT_NOTIFY_API_SERVER` (via `getNotificationApiBaseUrl()`), not by reading the env var directly at each call site.
|
||||
|
||||
Typical values today:
|
||||
|
||||
| Build / env file | `VITE_DEFAULT_NOTIFY_API_SERVER` |
|
||||
|------------------|----------------------------------|
|
||||
| `.env.production` | `https://notify-api.timesafari.app` |
|
||||
| `.env.test` | `https://test-notify-api.timesafari.app` |
|
||||
| `.env.development` | `https://test-notify-api.timesafari.app` |
|
||||
|
||||
### URL resolution order
|
||||
|
||||
`getNotificationApiBaseUrl()` selects the base URL in this order:
|
||||
|
||||
1. **Debug Panel backend override** — `localStorage` key `notificationDebug.backendBaseUrl` (set via **Save Backend URL** or `setBackendBaseUrl()`)
|
||||
2. **`VITE_DEFAULT_NOTIFY_API_SERVER`** — baked into the build as part of `DEFAULT_NOTIFY_API_SERVER`
|
||||
3. **`AppString.PROD_NOTIFY_API_SERVER`** — hardcoded fallback when the env var is unset (`https://notify-api.timesafari.app`)
|
||||
|
||||
Clearing the Debug Panel override (empty field + Save) returns the app to step 2 / 3 (`DEFAULT_NOTIFY_API_SERVER`). The override never changes auth behavior by itself.
|
||||
|
||||
`APP_SERVER` / `VITE_APP_SERVER` remain for deep links and the main app web host only — not for notification API traffic.
|
||||
|
||||
---
|
||||
|
||||
## Access
|
||||
|
||||
1. Use a **non-production** build (for example `build:android:dev`, `build:ios:dev`, or `vite dev` with a non-`production` mode).
|
||||
2. Open **Account** → enable **Show All General Advanced Functions**.
|
||||
3. Open **Notification Debug Panel** (route `/dev/notifications`).
|
||||
|
||||
On native platforms, grant notification permission when prompted so FCM token registration and the debug actions work.
|
||||
|
||||
---
|
||||
|
||||
## Configuration (Backend Testing)
|
||||
|
||||
Settings persist in `localStorage` via `NotificationDebugConfig.ts`:
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `notificationDebug.backendBaseUrl` | *(unset — use `DEFAULT_NOTIFY_API_SERVER`)* | Override which notification server receives API calls |
|
||||
| `notificationDebug.testMode` | `true` | Sent in JSON request bodies (`testMode: true/false`) |
|
||||
| `notificationDebug.bypassAuth` | `false` | When `true`, omit JWT `Authorization` headers on notification API calls |
|
||||
|
||||
All notification API requests (`/notifications/register`, `/notifications/alert-authorization`, `/debug/send-wakeup`, etc.) obtain headers through `getNotificationApiHeaders()` in `notificationApiAuth.ts`.
|
||||
|
||||
### Notification Backend URL
|
||||
|
||||
Paste a base URL (no trailing slash) and tap **Save Backend URL**. This changes **only** which server the app calls (`getNotificationApiBaseUrl()`). It does **not** disable JWT authentication.
|
||||
|
||||
Leave empty to use the configured build default (`DEFAULT_NOTIFY_API_SERVER`, from `VITE_DEFAULT_NOTIFY_API_SERVER` or `AppString.PROD_NOTIFY_API_SERVER`). The Debug Panel override still wins whenever a non-empty URL is saved.
|
||||
|
||||
### Test Mode
|
||||
|
||||
When enabled (default if never saved), register and 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.
|
||||
|
||||
### Skip JWT Authentication (Local Development Only)
|
||||
|
||||
When **off** (default), the app resolves the active DID and sends `Authorization: Bearer …` on notification API calls.
|
||||
|
||||
When **on**, requests include only `Content-Type: application/json` — for local servers (localhost or ngrok) that intentionally accept unauthenticated notification requests during development.
|
||||
|
||||
Enable this **only** for local development backends that do not require JWT. Hosted shared test servers that require normal app authentication should leave this **off**.
|
||||
|
||||
The panel **Backend Status** section shows the active URL, `testMode`, and `bypassAuth` values.
|
||||
|
||||
---
|
||||
|
||||
## Recommended settings
|
||||
|
||||
### Hosted test server
|
||||
|
||||
Example: `https://test-notify-api.timesafari.app`
|
||||
|
||||
On development and test builds, this host is already the default via `VITE_DEFAULT_NOTIFY_API_SERVER`. You can leave **Notification Backend URL** empty, or paste the same URL explicitly.
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| **Notification Backend URL** | Empty (use default) or `https://test-notify-api.timesafari.app` |
|
||||
| **Test Mode** | **ON** (if the server expects `testMode: true`) |
|
||||
| **Skip JWT Authentication** | **OFF** |
|
||||
|
||||
Ensure the app has an **active identity (DID)** with a valid endorser session so JWT headers can be built.
|
||||
|
||||
### Local localhost / ngrok development
|
||||
|
||||
Example: `https://abc123.ngrok-free.app` or `http://127.0.0.1:3000`
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| **Notification Backend URL** | Your local or ngrok URL |
|
||||
| **Test Mode** | **ON** or **OFF** — match what your local **notification-wakeup-service** expects |
|
||||
| **Skip JWT Authentication** | **ON** only if the local server accepts unauthenticated requests; **OFF** if it validates JWT like production |
|
||||
|
||||
---
|
||||
|
||||
## Backend Testing actions
|
||||
|
||||
| Action | What it does |
|
||||
|--------|----------------|
|
||||
| **Register Token Now** | `POST {backend}/notifications/register` with current FCM token, `deviceId`, `platform`, and `testMode`. Forces re-registration (bypasses duplicate-token skip). |
|
||||
| **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).
|
||||
|
||||
---
|
||||
|
||||
## Other panel sections
|
||||
|
||||
| Section | Purpose |
|
||||
|---------|---------|
|
||||
| **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. |
|
||||
|
||||
---
|
||||
|
||||
## Programmatic override (optional)
|
||||
|
||||
From a WebView dev console (`chrome://inspect` on Android, Safari Web Inspector on iOS):
|
||||
|
||||
```javascript
|
||||
import {
|
||||
setBackendBaseUrl,
|
||||
setTestMode,
|
||||
setBypassAuth,
|
||||
getNotificationApiBaseUrl,
|
||||
} from "@/services/notifications";
|
||||
|
||||
setBackendBaseUrl("https://abc123.ngrok-free.app");
|
||||
setTestMode(true);
|
||||
setBypassAuth(true); // local dev only
|
||||
getNotificationApiBaseUrl();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 401 Unauthorized (`registerToken failed: unauthorized`)
|
||||
|
||||
**Likely causes:** JWT required but **Skip JWT Authentication** is off and the session is missing or expired; or JWT sent but the server rejected it.
|
||||
|
||||
**Checks:**
|
||||
|
||||
1. Panel **Backend Status** → `bypassAuth: false` for hosted servers.
|
||||
2. App has an active DID and endorser login.
|
||||
3. Event Log: look for `Using authenticated notification request` vs `Using debug unauthenticated notification request`.
|
||||
4. For hosted test server: keep **Skip JWT Authentication** **OFF**.
|
||||
|
||||
**Fixes:** Sign in / restore identity; refresh endorser session; for local ngrok without JWT support, enable **Skip JWT Authentication**.
|
||||
|
||||
### `registerToken auth unavailable` / `Waiting for auth before registration`
|
||||
|
||||
The app deferred registration because JWT could not be built (no active DID or empty token) and **Skip JWT Authentication** is **off**.
|
||||
|
||||
**Fixes:** Complete identity setup in the app, or enable **Skip JWT Authentication** only for an intentionally unauthenticated local backend.
|
||||
|
||||
### Failed to fetch / network error
|
||||
|
||||
**Likely causes:** Backend down, wrong URL, stale ngrok tunnel, device offline, or TLS/certificate issues.
|
||||
|
||||
**Checks:** Panel **Backend Status** URL; `curl -sS "$URL/health"` from your machine; ngrok inspect UI for incoming requests.
|
||||
|
||||
**Fixes:** Restart backend and ngrok; **Save Backend URL** with the current HTTPS forwarding URL (no trailing slash).
|
||||
|
||||
### Backend unreachable / no requests in ngrok
|
||||
|
||||
Same as above. Confirm the **Active** URL in the panel matches your running tunnel or local server port.
|
||||
|
||||
### Register succeeds but Send Real WAKEUP_PING does not show delivery
|
||||
|
||||
**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; Firebase / Play services available.
|
||||
|
||||
See platform-specific guides for extended ngrok and FCM workflows:
|
||||
|
||||
- [local-android-testing-ngrok.md](./local-android-testing-ngrok.md)
|
||||
- [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md)
|
||||
|
||||
---
|
||||
|
||||
## Key source files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/constants/app.ts` | `DEFAULT_NOTIFY_API_SERVER`, `PROD_NOTIFY_API_SERVER`, `TEST_NOTIFY_API_SERVER` |
|
||||
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
|
||||
| `src/services/notifications/NotificationDebugConfig.ts` | Base URL resolution, testMode, bypassAuth persistence |
|
||||
| `src/services/notifications/notificationApiAuth.ts` | JWT vs unauthenticated headers |
|
||||
| `src/services/notifications/notificationApiDebugMode.ts` | Auth bypass gate |
|
||||
| `src/services/notifications/NotificationDebugService.ts` | Panel action handlers |
|
||||
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
|
||||
| `src/services/notifications/NativeNotificationService.ts` | Push delivery hook (logs ignored types; no refresh) |
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
- [notification-system-overview.md](./notification-system-overview.md)
|
||||
- [notification-from-api-call.md](./notification-from-api-call.md)
|
||||
- [local-android-testing-ngrok.md](./local-android-testing-ngrok.md)
|
||||
- [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md)
|
||||
@@ -0,0 +1,108 @@
|
||||
# New Activity Notification (API-Driven Daily Message)
|
||||
|
||||
**Purpose:** Integrate the daily-notification-plugin’s second feature—the **daily, API-driven message**—into the crowd-funder (TimeSafari) app. The first feature (daily static reminder) is already integrated; this document covers the plan, completed work, and remaining tasks for the API-driven flow.
|
||||
|
||||
**References:**
|
||||
|
||||
- Plugin: `daily-notification-plugin` (INTEGRATION_GUIDE.md, definitions.ts)
|
||||
- Alignment outline: `doc/daily-notification-alignment-outline.md`
|
||||
- Help copy: `HelpNotificationTypesView.vue` (“New Activity Notifications”)
|
||||
|
||||
---
|
||||
|
||||
## Plan Summary
|
||||
|
||||
The API-driven flow:
|
||||
|
||||
1. **Prefetch** – Shortly before the user’s chosen time, the plugin runs a background job that calls the Endorser.ch API (e.g. `plansLastUpdatedBetween`, and optionally offers endpoints) using credentials supplied by the app.
|
||||
2. **Cache** – Fetched content is stored in the plugin’s cache.
|
||||
3. **Notify** – At the chosen time, the user sees a notification whose title/body come from that content (or a fallback).
|
||||
|
||||
The app must:
|
||||
|
||||
- **Configure the native fetcher** with `apiBaseUrl`, `activeDid`, and a JWT so the plugin’s background workers can call the API.
|
||||
- **Implement the native fetcher** (or register an implementation) so the plugin can perform the actual HTTP requests and parse responses into notification content.
|
||||
- **Sync starred plan IDs** to the plugin via `updateStarredPlans` so the fetcher knows which plans to query.
|
||||
- **Expose UI** to enable/disable the “New Activity” notification and choose a time, and call `scheduleDualNotification` / `cancelDualSchedule` accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Tasks Finished
|
||||
|
||||
- **Configure native fetcher on startup and identity**
|
||||
- Added `configureNativeFetcherIfReady()` in `src/services/notifications/nativeFetcherConfig.ts` (reads `activeDid` and `apiServer` from DB, gets JWT via `getHeaders(did)`, calls `DailyNotification.configureNativeFetcher()`).
|
||||
- Called from `main.capacitor.ts` after the 2s delay (with deep link registration).
|
||||
- Called from `AccountViewView.initializeState()` when on native and `activeDid` is set; when New Activity is enabled, also calls `updateStarredPlans(settings.starredPlanHandleIds)`.
|
||||
- **Implement real API calls in Android native fetcher**
|
||||
- `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` implements `NativeNotificationContentFetcher`: POST to `/api/v2/report/plansLastUpdatedBetween` with `planIds` (from SharedPreferences `daily_notification_timesafari` / `starredPlanIds`) and `afterId`; when `data` is non-empty, builds **one** aggregated `NotificationContent` (title **Starred Project Update** or **Starred Project Updates**, body from `plan.name` with typographic quotes, then `has been updated.` or `+ N more have been updated.`); when `data` is empty, returns an empty list (no “no updates” notification); updates `last_acked_jwt_id` for pagination when content is returned.
|
||||
- Registered in `MainActivity.onCreate()` via `DailyNotificationPlugin.setNativeFetcher(new TimeSafariNativeFetcher(this))`.
|
||||
- **Sync starred plan IDs**
|
||||
- Shared helper `syncStarredPlansToNativePlugin(planIds)` in `src/services/notifications/syncStarredPlansToNativePlugin.ts` (exported from `src/services/notifications/index.ts`) calls `DailyNotification.updateStarredPlans` on native only; ignores `UNIMPLEMENTED`.
|
||||
- When user enables New Activity, `scheduleNewActivityDualNotification()` uses the helper with `settings.starredPlanHandleIds ?? []`.
|
||||
- When Account view loads and New Activity is on, `initializeState()` uses the helper with the same list.
|
||||
- When the user stars or unstars on a project (`ProjectViewView.toggleStar`), after a successful settings save, the helper runs if `notifyingNewActivityTime` is set so prefetch sees the current list without reopening Account.
|
||||
- **Dual schedule config and scheduling**
|
||||
- Added `src/services/notifications/dualScheduleConfig.ts`: `timeToCron()`, `timeToCronFiveMinutesBefore()`, `buildDualScheduleConfig({ notifyTime, title?, body? })` (contentFetch 5 min before, userNotification at chosen time).
|
||||
- When user enables New Activity and picks a time, app calls `DailyNotification.scheduleDualNotification({ config })` with this config.
|
||||
- When user disables New Activity, app calls `DailyNotification.cancelDualSchedule()`.
|
||||
- **UI for New Activity notification**
|
||||
- Unhid the “New Activity Notification” block in `AccountViewView.vue` (toggle + accessibility).
|
||||
- Enable flow: time dialog → save settings → on native, `scheduleNewActivityDualNotification(timeText)` (configure fetcher, updateStarredPlans, scheduleDualNotification).
|
||||
- Disable flow: on native, `cancelDualSchedule()` then save and clear settings.
|
||||
- Added `starredPlanHandleIds` to `AccountSettings` in `interfaces/accountView.ts`.
|
||||
- **Exports**
|
||||
- `src/services/notifications/index.ts` exports `configureNativeFetcherIfReady`, `syncStarredPlansToNativePlugin`, `buildDualScheduleConfig`, `timeToCron`, `timeToCronFiveMinutesBefore`, and `DualScheduleConfigInput`.
|
||||
|
||||
---
|
||||
|
||||
## Checklist of Remaining Tasks
|
||||
|
||||
### iOS
|
||||
|
||||
**Parity outline (API, starred plans, plugin vs app work):** See **`doc/new-activity-notifications-ios-android-parity.md`**.
|
||||
|
||||
- **Confirm iOS native fetcher / dual schedule**
|
||||
Plugin exposes `configureNativeFetcher` on iOS. Confirm whether the plugin expects an iOS-specific native fetcher registration (similar to Android’s `setNativeFetcher`) and, if so, register a TimeSafari fetcher implementation for iOS so API-driven notifications work on iPhone.
|
||||
- **Verify dual schedule on iOS**
|
||||
Test `scheduleDualNotification` and `cancelDualSchedule` on iOS; ensure content fetch and user notification fire at the expected times and that foreground/background behavior matches expectations.
|
||||
|
||||
### Testing and hardening
|
||||
|
||||
- **Test full flow on Android**
|
||||
Enable New Activity, set time, wait for prefetch and notification (or use a short rollover for testing). Confirm notification shows with API-derived or fallback content.
|
||||
- **Test full flow on iOS**
|
||||
Same as Android: enable, set time, verify prefetch and notification delivery and content.
|
||||
- **Test with no starred plans**
|
||||
Enable New Activity with empty `starredPlanHandleIds`; confirm no crash; the native fetcher returns no Endorser-derived items when there is nothing to query or no new rows (see `TimeSafariNativeFetcher`).
|
||||
- **Test JWT expiry**
|
||||
Ensure behavior when the token passed to `configureNativeFetcher` has expired (e.g. app in background for a long time); document or implement refresh (e.g. re-call `configureNativeFetcherIfReady` on foreground or when opening Account).
|
||||
|
||||
### Optional enhancements
|
||||
|
||||
- **Offers endpoints**
|
||||
Extend `TimeSafariNativeFetcher` (and any iOS fetcher) to call offers endpoints (e.g. `offers`, `offersToPlansOwnedByMe`) and merge with project-update content for richer notifications.
|
||||
- **Documentation**
|
||||
Add a short “New Activity notifications” section to BUILDING.md or a user-facing help page describing how the feature works and how to troubleshoot (e.g. no notification, wrong content, JWT/API errors).
|
||||
|
||||
---
|
||||
|
||||
## File Reference
|
||||
|
||||
|
||||
| Area | Files |
|
||||
| ---------------------- | ----------------------------------------------------------------------- |
|
||||
| Fetcher config | `src/services/notifications/nativeFetcherConfig.ts` |
|
||||
| Starred list → plugin | `src/services/notifications/syncStarredPlansToNativePlugin.ts` |
|
||||
| Dual schedule config | `src/services/notifications/dualScheduleConfig.ts` |
|
||||
| Notification exports | `src/services/notifications/index.ts` |
|
||||
| Startup | `src/main.capacitor.ts` |
|
||||
| Account UI and flow | `src/views/AccountViewView.vue` |
|
||||
| Project star / unstar | `src/views/ProjectViewView.vue` (`toggleStar`) |
|
||||
| Settings type | `src/interfaces/accountView.ts` |
|
||||
| Android native fetcher | `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` |
|
||||
| Android registration | `android/app/src/main/java/app/timesafari/MainActivity.java` |
|
||||
| iOS native fetcher | `ios/App/App/TimeSafariNativeFetcher.swift` |
|
||||
| iOS registration | `ios/App/App/AppDelegate.swift` (`DailyNotificationPlugin.registerNativeFetcher`) |
|
||||
| Plugin 3.x handoff | `doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md` |
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
# Notification Integration Changes - Implementation Outline
|
||||
|
||||
**Date**: 2026-01-23
|
||||
**Purpose**: Detailed outline of changes needed to integrate DailyNotificationPlugin with UI
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines all changes required to integrate the DailyNotificationPlugin with the existing notification UI, making it work seamlessly on both native (iOS/Android) and web platforms.
|
||||
|
||||
**Estimated Complexity**: Medium
|
||||
**Estimated Files Changed**: 3-4 files
|
||||
**Breaking Changes**: None (backward compatible)
|
||||
|
||||
---
|
||||
|
||||
## Change Summary
|
||||
|
||||
| File | Changes | Complexity | Risk |
|
||||
|------|---------|------------|------|
|
||||
| `PushNotificationPermission.vue` | Add platform detection, native flow | Medium | Low |
|
||||
| `AccountViewView.vue` | Platform detection in toggles, hide push server on native | Low | Low |
|
||||
| `WebPushNotificationService.ts` | Complete stub implementation (optional) | Medium | Low |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Changes
|
||||
|
||||
### 1. PushNotificationPermission.vue
|
||||
|
||||
**File**: `src/components/PushNotificationPermission.vue`
|
||||
**Current Lines**: ~656 lines
|
||||
**Estimated New Lines**: +50-80 lines
|
||||
**Complexity**: Medium
|
||||
|
||||
#### Changes Required
|
||||
|
||||
**A. Add Imports** (Top of script section)
|
||||
```typescript
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { NotificationService } from "@/services/notifications";
|
||||
```
|
||||
|
||||
**B. Add Platform Detection Property**
|
||||
```typescript
|
||||
// Add to class properties
|
||||
private get isNativePlatform(): boolean {
|
||||
return Capacitor.isNativePlatform();
|
||||
}
|
||||
```
|
||||
|
||||
**C. Modify `open()` Method** (Lines 170-258)
|
||||
- **Current**: Always initializes web push (VAPID key, service worker)
|
||||
- **Change**: Add platform check at start
|
||||
- If native: Skip VAPID/service worker, show UI immediately
|
||||
- If web: Keep existing logic
|
||||
|
||||
**D. Modify `turnOnNotifications()` Method** (Lines 393-499)
|
||||
- **Current**: Web push subscription flow
|
||||
- **Change**: Split into two paths:
|
||||
- **Native path**: Use `NotificationService.getInstance()` → `requestPermissions()` → `scheduleDailyNotification()`
|
||||
- **Web path**: Keep existing logic
|
||||
|
||||
**E. Add New Method: `turnOnNativeNotifications()`**
|
||||
- Request permissions via `NotificationService`
|
||||
- Convert time input (AM/PM) to 24-hour format (HH:mm)
|
||||
- Call `scheduleDailyNotification()` with proper options
|
||||
- Save to settings
|
||||
- Call callback with success/time/message
|
||||
|
||||
**F. Update `handleTurnOnNotifications()` Method** (Line 643)
|
||||
- Add platform check
|
||||
- Route to `turnOnNativeNotifications()` or `turnOnNotifications()` based on platform
|
||||
|
||||
**G. Update Computed Properties**
|
||||
- `isSystemReady`: For native, return `true` immediately (no VAPID needed)
|
||||
- `canShowNotificationForm`: For native, return `true` immediately
|
||||
|
||||
**H. Update Template** (Optional - for better UX)
|
||||
- Add platform-specific messaging if desired
|
||||
- Native: "Notifications will be scheduled on your device"
|
||||
- Web: Keep existing messaging
|
||||
|
||||
#### Code Structure Preview
|
||||
|
||||
```typescript
|
||||
async open(pushType: string, callback?: ...) {
|
||||
this.callback = callback || this.callback;
|
||||
this.isVisible = true;
|
||||
this.pushType = pushType;
|
||||
|
||||
// Platform detection
|
||||
if (this.isNativePlatform) {
|
||||
// Native: No VAPID/service worker needed
|
||||
this.serviceWorkerReady = true; // Fake it for UI
|
||||
this.vapidKey = "native"; // Placeholder
|
||||
return; // Skip web push initialization
|
||||
}
|
||||
|
||||
// Existing web push initialization...
|
||||
// (keep all existing code)
|
||||
}
|
||||
|
||||
async turnOnNotifications() {
|
||||
if (this.isNativePlatform) {
|
||||
return this.turnOnNativeNotifications();
|
||||
}
|
||||
// Existing web push logic...
|
||||
}
|
||||
|
||||
private async turnOnNativeNotifications(): Promise<void> {
|
||||
const service = NotificationService.getInstance();
|
||||
|
||||
// Request permissions
|
||||
const granted = await service.requestPermissions();
|
||||
if (!granted) {
|
||||
// Handle permission denial
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert time to 24-hour format
|
||||
const time24h = this.convertTo24HourFormat();
|
||||
|
||||
// Determine title and body based on pushType
|
||||
const title = this.pushType === this.DAILY_CHECK_TITLE
|
||||
? "Daily Check-In"
|
||||
: "Daily Reminder";
|
||||
const body = this.pushType === this.DIRECT_PUSH_TITLE
|
||||
? this.messageInput
|
||||
: "Time to check your TimeSafari activity";
|
||||
|
||||
// Schedule notification
|
||||
const success = await service.scheduleDailyNotification({
|
||||
time: time24h,
|
||||
title,
|
||||
body,
|
||||
priority: 'normal'
|
||||
});
|
||||
|
||||
if (success) {
|
||||
// Save to settings
|
||||
const timeText = this.notificationTimeText;
|
||||
await this.$saveSettings({
|
||||
[this.pushType === this.DAILY_CHECK_TITLE
|
||||
? 'notifyingNewActivityTime'
|
||||
: 'notifyingReminderTime']: timeText,
|
||||
...(this.pushType === this.DIRECT_PUSH_TITLE && {
|
||||
notifyingReminderMessage: this.messageInput
|
||||
})
|
||||
});
|
||||
|
||||
// Call callback
|
||||
this.callback(true, timeText, this.messageInput);
|
||||
}
|
||||
}
|
||||
|
||||
private convertTo24HourFormat(): string {
|
||||
const hour = parseInt(this.hourInput);
|
||||
const minute = parseInt(this.minuteInput);
|
||||
|
||||
let hour24 = hour;
|
||||
if (!this.hourAm && hour !== 12) {
|
||||
hour24 = hour + 12;
|
||||
} else if (this.hourAm && hour === 12) {
|
||||
hour24 = 0;
|
||||
}
|
||||
|
||||
return `${hour24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing Considerations
|
||||
- Test on iOS device
|
||||
- Test on Android device
|
||||
- Test on web (should still work as before)
|
||||
- Test permission denial flow
|
||||
- Test time conversion (AM/PM → 24-hour)
|
||||
|
||||
---
|
||||
|
||||
### 2. AccountViewView.vue
|
||||
|
||||
**File**: `src/views/AccountViewView.vue`
|
||||
**Current Lines**: 2124 lines
|
||||
**Estimated New Lines**: +20-30 lines
|
||||
**Complexity**: Low
|
||||
|
||||
#### Changes Required
|
||||
|
||||
**A. Add Import** (Top of script section, around line 739)
|
||||
```typescript
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
```
|
||||
|
||||
**B. Add Computed Property** (In class, around line 888)
|
||||
```typescript
|
||||
private get isNativePlatform(): boolean {
|
||||
return Capacitor.isNativePlatform();
|
||||
}
|
||||
```
|
||||
|
||||
**C. Modify Notification Toggle Methods** (Lines 1134-1202)
|
||||
|
||||
**`showNewActivityNotificationChoice()`** (Lines 1134-1158)
|
||||
- **Current**: Always uses `PushNotificationPermission` component
|
||||
- **Change**: Add platform check
|
||||
- If native: Use `NotificationService` directly (or still use component - it will handle platform)
|
||||
- If web: Keep existing logic
|
||||
- **Note**: Since we're updating `PushNotificationPermission` to handle both, this might not need changes, but we could add direct native path for cleaner code
|
||||
|
||||
**`showReminderNotificationChoice()`** (Lines 1171-1202)
|
||||
- Same as above
|
||||
|
||||
**D. Conditionally Hide Push Server Setting** (Lines 506-549)
|
||||
- Wrap the entire "Notification Push Server" section in `v-if="!isNativePlatform"`
|
||||
- This hides it on iOS/Android where it's not needed
|
||||
|
||||
**E. Update Status Display** (Optional)
|
||||
- When showing notification status, could add platform indicator
|
||||
- "Native notification scheduled" vs "Web push subscription active"
|
||||
|
||||
#### Code Structure Preview
|
||||
|
||||
```typescript
|
||||
// Add computed property
|
||||
private get isNativePlatform(): boolean {
|
||||
return Capacitor.isNativePlatform();
|
||||
}
|
||||
|
||||
// In template, wrap push server section:
|
||||
<section v-if="!isNativePlatform" id="sectionPushServer">
|
||||
<h2 class="text-slate-500 text-sm font-bold mb-2">
|
||||
Notification Push Server
|
||||
</h2>
|
||||
<!-- ... existing push server UI ... -->
|
||||
</section>
|
||||
|
||||
// Optional: Update notification choice methods
|
||||
async showNewActivityNotificationChoice(): Promise<void> {
|
||||
if (!this.notifyingNewActivity) {
|
||||
// Component now handles platform detection, so this can stay the same
|
||||
// OR we could add direct native path here for cleaner separation
|
||||
(this.$refs.pushNotificationPermission as PushNotificationPermission)
|
||||
.open(DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => {
|
||||
// ... existing callback ...
|
||||
});
|
||||
} else {
|
||||
// ... existing turn-off logic ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing Considerations
|
||||
- Verify push server section hidden on iOS
|
||||
- Verify push server section hidden on Android
|
||||
- Verify push server section visible on web
|
||||
- Test notification toggles work on all platforms
|
||||
|
||||
---
|
||||
|
||||
### 3. WebPushNotificationService.ts (Optional Enhancement)
|
||||
|
||||
**File**: `src/services/notifications/WebPushNotificationService.ts`
|
||||
**Current Lines**: 213 lines
|
||||
**Estimated New Lines**: +100-150 lines
|
||||
**Complexity**: Medium
|
||||
**Priority**: Low (can be done later)
|
||||
|
||||
#### Changes Required
|
||||
|
||||
**A. Complete `scheduleDailyNotification()` Implementation**
|
||||
- Extract logic from `PushNotificationPermission.vue`
|
||||
- Subscribe to push service
|
||||
- Send subscription to server
|
||||
- Return success status
|
||||
|
||||
**B. Complete `cancelDailyNotification()` Implementation**
|
||||
- Get current subscription
|
||||
- Unsubscribe from push service
|
||||
- Notify server to stop sending
|
||||
|
||||
**C. Complete `getStatus()` Implementation**
|
||||
- Check settings for `notifyingNewActivityTime` / `notifyingReminderTime`
|
||||
- Check service worker subscription status
|
||||
- Return combined status
|
||||
|
||||
**Note**: This is optional because `PushNotificationPermission.vue` already handles web push. Completing this would allow using `NotificationService` directly for web too, but it's not required for the integration to work.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: Core Integration (Required)
|
||||
1. ✅ Update `PushNotificationPermission.vue` with platform detection
|
||||
2. ✅ Update `AccountViewView.vue` to hide push server on native
|
||||
3. ✅ Test on native platforms
|
||||
|
||||
### Phase 2: Polish (Optional)
|
||||
4. Complete `WebPushNotificationService.ts` implementation
|
||||
5. Add platform-specific UI messaging
|
||||
6. Add status indicators
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Low Risk Changes
|
||||
- ✅ Adding platform detection (read-only check)
|
||||
- ✅ Conditionally hiding UI elements
|
||||
- ✅ Adding new code paths (not modifying existing)
|
||||
|
||||
### Medium Risk Changes
|
||||
- ⚠️ Modifying `turnOnNotifications()` flow (but we're adding, not replacing)
|
||||
- ⚠️ Time format conversion (need to test edge cases)
|
||||
|
||||
### Mitigation Strategies
|
||||
1. **Backward Compatibility**: All changes are additive - existing web push flow remains unchanged
|
||||
2. **Feature Flags**: Could add feature flag to enable/disable native notifications
|
||||
3. **Gradual Rollout**: Test on one platform first (e.g., Android), then iOS
|
||||
4. **Fallback**: If native service fails, could fall back to showing error message
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Functional Testing
|
||||
- [ ] Native iOS: Request permissions → Schedule notification → Verify scheduled
|
||||
- [ ] Native Android: Request permissions → Schedule notification → Verify scheduled
|
||||
- [ ] Web: Existing flow still works (no regression)
|
||||
- [ ] Permission denial: Shows appropriate error message
|
||||
- [ ] Time conversion: AM/PM correctly converts to 24-hour format
|
||||
- [ ] Both notification types: Daily Check and Direct Push work on native
|
||||
- [ ] Settings persistence: Times saved correctly to database
|
||||
|
||||
### UI Testing
|
||||
- [ ] Push server setting hidden on iOS
|
||||
- [ ] Push server setting hidden on Android
|
||||
- [ ] Push server setting visible on web
|
||||
- [ ] Notification toggles work on all platforms
|
||||
- [ ] Time picker UI works on native (same as web)
|
||||
|
||||
### Edge Cases
|
||||
- [ ] 12:00 AM conversion (should be 00:00)
|
||||
- [ ] 12:00 PM conversion (should be 12:00)
|
||||
- [ ] Invalid time input handling
|
||||
- [ ] App restart: Notifications still scheduled
|
||||
- [ ] Device reboot: Notifications still scheduled (Android)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Required
|
||||
- ✅ `@capacitor/core` - Already in project
|
||||
- ✅ `@timesafari/daily-notification-plugin` - Already installed
|
||||
- ✅ `NotificationService` - Already created
|
||||
|
||||
### No New Dependencies Needed
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Task | Time Estimate |
|
||||
|------|---------------|
|
||||
| Update PushNotificationPermission.vue | 2-3 hours |
|
||||
| Update AccountViewView.vue | 30 minutes - 1 hour |
|
||||
| Testing on iOS | 1-2 hours |
|
||||
| Testing on Android | 1-2 hours |
|
||||
| Bug fixes & polish | 1-2 hours |
|
||||
| **Total** | **5-10 hours** |
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise:
|
||||
|
||||
1. **Quick Rollback**: Revert changes to `PushNotificationPermission.vue` and `AccountViewView.vue`
|
||||
2. **Partial Rollback**: Keep platform detection but disable native path (feature flag)
|
||||
3. **No Data Migration Needed**: Settings structure unchanged
|
||||
|
||||
---
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
1. **Should we keep using `PushNotificationPermission` component for native, or create separate native flow?**
|
||||
- **Recommendation**: Keep using component (simpler, less code duplication)
|
||||
|
||||
2. **Should we show different UI messaging for native vs web?**
|
||||
- **Recommendation**: Optional enhancement, not required for MVP
|
||||
|
||||
3. **Should we complete `WebPushNotificationService` now or later?**
|
||||
- **Recommendation**: Later (not blocking, existing component works)
|
||||
|
||||
4. **How to handle notification cancellation on native?**
|
||||
- **Recommendation**: Use `NotificationService.cancelDailyNotification()` in existing turn-off logic
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Implementation
|
||||
|
||||
1. Update documentation with platform-specific instructions
|
||||
2. Add error handling for edge cases
|
||||
3. Consider adding notification status display in UI
|
||||
4. Test on real devices (critical for native notifications)
|
||||
5. Monitor for any platform-specific issues
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-23
|
||||
@@ -0,0 +1,250 @@
|
||||
# Lay of the Land: API-Driven Daily Message (New Activity) and Web-Push Confusion
|
||||
|
||||
**Purpose:** Shareable analysis of the New Activity (API-driven daily message) implementation and the root cause of “always fires / can’t be turned off.” For discussion with teammates.
|
||||
|
||||
**Related:** `doc/notification-from-api-call.md` (plan and progress), teammate note about web-push confusion and possibly removing that logic.
|
||||
|
||||
---
|
||||
|
||||
## 1. Two Separate Notification Features
|
||||
|
||||
There are **two** distinct native notification flows that both go through the same UI component:
|
||||
|
||||
| Feature | Plugin API | Purpose |
|
||||
|--------|------------|--------|
|
||||
| **Daily Reminder** | `scheduleDailyNotification` / `cancelDailyReminder` | Single daily alarm, static title/body (user’s message). |
|
||||
| **New Activity** (API-driven) | `scheduleDualNotification` / `cancelDualSchedule` | Prefetch from API 5 min before, then notify at chosen time with API or fallback content. |
|
||||
|
||||
- **Daily Reminder** is driven from AccountViewView’s “Daily Reminder” toggle; on native it uses `NotificationService.getInstance().scheduleDailyNotification()` / `cancelDailyNotification()` (backed by `NativeNotificationService` and a single `reminderId`: `"daily_timesafari_reminder"`).
|
||||
- **New Activity** is intended to be driven only by `scheduleNewActivityDualNotification()` / `cancelDualSchedule()` in AccountViewView (dual schedule only).
|
||||
|
||||
So: one feature = single schedule (reminder), the other = dual schedule (prefetch + notify). They are different plugin APIs and different lifecycle (enable/disable) handling.
|
||||
|
||||
---
|
||||
|
||||
## 2. Where the Bug Comes From: One Dialog, Two Behaviors
|
||||
|
||||
**New Activity** reuses the same dialog as Daily Reminder: **`PushNotificationPermission.vue`**.
|
||||
|
||||
- When the user turns **New Activity** on from AccountViewView:
|
||||
- AccountViewView opens this dialog with `DAILY_CHECK_TITLE` and a callback that, on success, calls `scheduleNewActivityDualNotification(timeText)` on native.
|
||||
- The dialog does **not** receive `skipSchedule: true` for this flow (only the “edit reminder” flow does).
|
||||
|
||||
So when the user clicks “Turn on Daily Reminder” in the dialog for **New Activity**:
|
||||
|
||||
1. **PushNotificationPermission** (native path) runs `turnOnNativeNotifications()` and always calls:
|
||||
- `service.scheduleDailyNotification({ time, title: "Daily Check-In", body: "Time to check your TimeSafari activity", ... })`
|
||||
- i.e. it schedules the **single** daily reminder (plugin’s `scheduleDailyNotification`), using the same `reminderId` as Daily Reminder (`"daily_timesafari_reminder"`).
|
||||
2. Then the callback runs and AccountViewView calls **`scheduleNewActivityDualNotification(timeText)`**, which calls the plugin’s **`scheduleDualNotification`**.
|
||||
|
||||
Result:
|
||||
|
||||
- **Two schedules** are created when enabling New Activity:
|
||||
- One **single** reminder (wrong for New Activity): static “Daily Check-In” message, same ID as Daily Reminder.
|
||||
- One **dual** schedule (correct): prefetch + notify with API/fallback content.
|
||||
- When the user turns **New Activity** off, AccountViewView only calls **`cancelDualSchedule()`**. It never calls `cancelDailyNotification()` (or equivalent) for the single reminder.
|
||||
- So the **single** reminder stays scheduled and keeps firing at the chosen time. That’s the notification that “always fires” and “can’t be turned off.”
|
||||
|
||||
So the “huge problem with confusion with the web-push” is really: **the same dialog and the same “Turn on” path are used for both Daily Reminder and New Activity, but the dialog always schedules the single daily reminder on native**, while New Activity is supposed to use only the dual schedule. That mixing is what makes the wrong schedule stick and not be cancellable from the New Activity toggle.
|
||||
|
||||
---
|
||||
|
||||
## 3. Key Files and Flows
|
||||
|
||||
- **`src/components/PushNotificationPermission.vue`**
|
||||
- Shared dialog for both “Daily Reminder” and “New Activity” (via `pushType` = `DIRECT_PUSH_TITLE` vs `DAILY_CHECK_TITLE`).
|
||||
- On native it always uses `NotificationService.getInstance().scheduleDailyNotification(...)` (single reminder) and does not branch on “New Activity” to skip scheduling or to call the dual API.
|
||||
- Saves `notifyingNewActivityTime` when `pushType === DAILY_CHECK_TITLE` (lines 834–836). So the dialog both schedules the wrong thing and persists settings for New Activity.
|
||||
|
||||
- **`src/views/AccountViewView.vue`**
|
||||
- **Daily Reminder:** toggle opens same dialog with `DIRECT_PUSH_TITLE`; on native, disable path calls `service.cancelDailyNotification()`.
|
||||
- **New Activity:** toggle opens same dialog with `DAILY_CHECK_TITLE`; on success callback calls `scheduleNewActivityDualNotification(timeText)`; on disable only calls `DailyNotification.cancelDualSchedule()`.
|
||||
- `initializeState()`: on native with `activeDid`, calls `configureNativeFetcherIfReady(activeDid)` and, if New Activity is on, `updateStarredPlans(...)`. It does **not** re-call `scheduleNewActivityDualNotification` on load (so no double dual-schedule from here).
|
||||
|
||||
- **`src/services/notifications/NativeNotificationService.ts`**
|
||||
- Single reminder only: `scheduleDailyNotification` → plugin `scheduleDailyNotification` with `id: this.reminderId` (`"daily_timesafari_reminder"`); `cancelDailyNotification` → `cancelDailyReminder({ reminderId })`. No dual API here.
|
||||
|
||||
- **`src/services/notifications/nativeFetcherConfig.ts`**
|
||||
- Only configures the plugin for API calls (JWT, apiBaseUrl, activeDid). No scheduling.
|
||||
|
||||
- **`src/services/notifications/dualScheduleConfig.ts`**
|
||||
- Builds config for `scheduleDualNotification` (contentFetch 5 min before, userNotification at notify time). Used only from AccountViewView’s `scheduleNewActivityDualNotification`.
|
||||
|
||||
- **`src/main.capacitor.ts`**
|
||||
- Imports the daily-notification plugin; after a 2s delay calls `configureNativeFetcherIfReady()`. No scheduling; only fetcher config.
|
||||
|
||||
So: the “always fires / can’t turn off” behavior is from the **single** reminder created in `PushNotificationPermission` for New Activity and never cancelled when New Activity is turned off. The “confusion with web-push” is the reuse of the same dialog and the same native “schedule single reminder” path for both features.
|
||||
|
||||
---
|
||||
|
||||
## 4. Plugin Usage Summary
|
||||
|
||||
- **Single daily reminder (Daily Reminder):**
|
||||
- Scheduled/cancelled via `NativeNotificationService.scheduleDailyNotification` / `cancelDailyNotification` → plugin `scheduleDailyNotification` / `cancelDailyReminder` with one `reminderId`.
|
||||
- **Dual schedule (New Activity):**
|
||||
- Scheduled/cancelled only in AccountViewView via `DailyNotification.scheduleDualNotification` / `cancelDualSchedule` (and `configureNativeFetcherIfReady` + `updateStarredPlans` as per doc).
|
||||
- **Fetcher config (New Activity):**
|
||||
- `configureNativeFetcherIfReady()` from main.capacitor and from AccountViewView `initializeState` / `scheduleNewActivityDualNotification`; no scheduling by itself.
|
||||
|
||||
---
|
||||
|
||||
## 5. Root Cause (Concise)
|
||||
|
||||
- **Single code path in PushNotificationPermission** for native: it always schedules the **single** daily reminder, regardless of `pushType` (Daily Reminder vs New Activity).
|
||||
- For **New Activity**, that creates an extra, wrong schedule (single reminder) in addition to the correct dual schedule.
|
||||
- **Disable path for New Activity** only calls `cancelDualSchedule()` and never cancels the single reminder, so that reminder keeps firing and appears as “always fires” and “can’t be turned off.”
|
||||
|
||||
---
|
||||
|
||||
## 6. Proper Fix: Options and Detail
|
||||
|
||||
A fix should ensure that (1) enabling New Activity creates only the dual schedule, and (2) disabling New Activity removes every schedule that was created for it. Below are concrete options and implementation notes.
|
||||
|
||||
### 6.1 Option A: Don’t schedule the single reminder when the dialog is for New Activity (recommended)
|
||||
|
||||
**Idea:** On native, when the dialog is opened for **New Activity** (`pushType === DAILY_CHECK_TITLE`), the dialog should **not** call `scheduleDailyNotification`. Only the callback in AccountViewView should run, and it already calls `scheduleNewActivityDualNotification(timeText)`, which uses the dual API only.
|
||||
|
||||
**Where:** `PushNotificationPermission.vue`, inside `turnOnNativeNotifications()`.
|
||||
|
||||
**Implementation sketch:**
|
||||
|
||||
- After requesting permissions and before calling `service.scheduleDailyNotification(...)`, branch on `pushType` and platform:
|
||||
- If native **and** `pushType === this.DAILY_CHECK_TITLE`: skip the `scheduleDailyNotification` call entirely. Still run the rest of the flow (e.g. build `timeText`, save settings if desired, call `callback(true, timeText, ...)`). AccountViewView’s callback will then call `scheduleNewActivityDualNotification(timeText)` and that is the only schedule created for New Activity.
|
||||
- Otherwise (web, or Daily Reminder on native): keep current behavior and call `scheduleDailyNotification` as today.
|
||||
|
||||
**Pros:** Single source of truth for “what is scheduled for New Activity” (dual only). No leftover single reminder to cancel later. Clear separation: dialog collects time + permission; AccountViewView owns native scheduling for New Activity.
|
||||
|
||||
**Cons:** Dialog’s native path now has two behaviors (schedule vs no schedule) depending on `pushType`; needs a quick comment so future changes don’t regress.
|
||||
|
||||
**Note:** The “edit reminder” flow already uses `skipSchedule: true` so the dialog doesn’t schedule; only the parent does. For New Activity enable, we’re doing the same idea: dialog doesn’t schedule on native, parent does.
|
||||
|
||||
### 6.2 Option B: When turning New Activity off, also cancel the single reminder
|
||||
|
||||
**Idea:** Assume the wrong single reminder might already exist (e.g. from before the fix, or from a different code path). When the user turns **New Activity** off, in addition to `cancelDualSchedule()`, call the service’s `cancelDailyNotification()` so the single reminder (same `reminderId` as Daily Reminder) is cancelled too.
|
||||
|
||||
**Where:** `AccountViewView.vue`, inside the disable branch of `showNewActivityNotificationChoice()` (where we currently only call `DailyNotification.cancelDualSchedule()`).
|
||||
|
||||
**Implementation sketch:**
|
||||
|
||||
- On native, when user confirms “turn off New Activity”:
|
||||
1. Call `DailyNotification.cancelDualSchedule()` (existing).
|
||||
2. Call `NotificationService.getInstance().cancelDailyNotification()` (new) so any single reminder that was mistakenly scheduled for this flow is removed.
|
||||
|
||||
**Pros:** Defensive: cleans up the bad schedule even if it was created in the past or by another path. Complements Option A (e.g. A prevents new wrong schedules; B cleans up existing ones).
|
||||
|
||||
**Cons:** That single `reminderId` is shared with **Daily Reminder**. If the user has **Daily Reminder** on and **New Activity** on, then turns only **New Activity** off, we must not cancel the reminder they still want for Daily Reminder. So either:
|
||||
- Only call `cancelDailyNotification()` when we’re sure the single reminder was created for New Activity (e.g. we don’t have a separate “New Activity reminder ID”), which is hard without more state, or
|
||||
- Don’t use Option B alone as the primary fix: use Option A so we never create the single reminder for New Activity, and only add B if we decide we need a one-time cleanup or a safety net (with care not to cancel Daily Reminder’s schedule).
|
||||
|
||||
**Recommendation:** Use Option A as the main fix. Add Option B only if the team agrees we need to cancel the single reminder on “New Activity off” and can do so without affecting Daily Reminder (e.g. by introducing a distinct reminder ID for a “New Activity legacy” reminder and only cancelling that, or by documenting that B is a one-time migration and not long-term behavior).
|
||||
|
||||
### 6.3 Optional cleanup: Separate reminder IDs or dialog responsibilities
|
||||
|
||||
- **Separate reminder IDs:** Today both Daily Reminder and the mistaken New Activity single reminder use `"daily_timesafari_reminder"`. If we ever want to support “both features on” and cancel only one, we’d need a second ID (e.g. one for Daily Reminder, one for New Activity). With Option A in place, New Activity no longer creates a single reminder, so we might not need a second ID unless we add a dedicated “New Activity fallback” single alarm later.
|
||||
- **Dialog responsibilities:** We could narrow the dialog’s role when used for New Activity on native to “collect time + request permission and report success,” and leave all scheduling to AccountViewView. That’s what Option A does without necessarily refactoring the rest of the dialog (e.g. web push, Daily Reminder) in the same change.
|
||||
- **Removing web-push logic for New Activity:** If the team decides to “totally remove” web-push logic that was added for New Activity, that would be a separate change (e.g. ensure New Activity on web either uses a different mechanism or is explicitly unsupported). The lay-of-the-land and this fix section focus on native; web can be scoped in a follow-up.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing New Activity on a Real Device (iOS or Android)
|
||||
|
||||
Use this section to verify the New Activity flow end-to-end on a physical device after implementing the fix (or to reproduce the current bug).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Build:** Native app built and installed (e.g. `npx cap sync` then build/run from Xcode or Android Studio), or a dev build on device.
|
||||
- **Identity:** User is signed in (active DID set) so `configureNativeFetcherIfReady` and the native fetcher can use a valid JWT.
|
||||
- **Endorser API URL:** New Activity prefetch uses **Account → API Server URL** (the Endorser base URL passed to `configureNativeFetcher`), not the Partner API URL. You can run these tests against **production, test, or local Endorser** (e.g. the test preset `https://test-api.endorser.ch`); use an identity, JWT, and starred plans that exist on **that** server. Changing only **Partner API** URL does not change where `plansLastUpdatedBetween` is called.
|
||||
- **Optional:** One or more starred plans so the API can return activity; with zero starred plans the notification should still show with a sensible fallback (e.g. “No updates in your starred projects”).
|
||||
|
||||
### Enable flow
|
||||
|
||||
1. Open **Account** (Profile).
|
||||
2. In the **Notifications** section, turn **New Activity Notification** on.
|
||||
3. In the dialog, choose a time. For quick testing, set the device clock or pick a time **2–5 minutes from now** (e.g. if it’s 14:00, choose 14:03).
|
||||
4. Tap **Turn on Daily Reminder** (or equivalent), grant notification permission when the OS prompts, and confirm the dialog closes and the toggle shows on with the chosen time.
|
||||
5. **Background the app** (home or switch to another app). The prefetch runs ~5 minutes before the chosen time; the user notification fires at the chosen time.
|
||||
|
||||
### What to verify (after fix)
|
||||
|
||||
- **One notification** at the chosen time, with content from the API or the fallback text (e.g. “Check your starred projects and offers for updates.”). You should **not** see a second, static “Daily Check-In” / “Time to check your TimeSafari activity” notification from the old single-reminder path.
|
||||
- **Before the fix:** You may see two notifications (one static from the mistaken single schedule, one from the dual schedule), and turning New Activity off will only stop the dual one; the static one will keep firing.
|
||||
|
||||
### Disable flow
|
||||
|
||||
1. On **Account**, turn **New Activity Notification** off and confirm in the “turn off” dialog.
|
||||
2. Wait until the next occurrence of the previously chosen time (or use the same “time a few minutes ahead” trick and wait). **No notification** should appear. If one still appears, the single reminder was not cancelled (current bug or Option B not applied correctly).
|
||||
|
||||
### Device-specific notes
|
||||
|
||||
- **Android:** This app has **exact alarm disabled** (no `SCHEDULE_EXACT_ALARM`). Notification permission must be granted; delivery may be inexact or batched by the system. If the app is killed by the OS, behavior may depend on plugin boot/recovery behavior.
|
||||
- **iOS:** Notification permission and background capabilities (e.g. background fetch) may affect prefetch. Test with app in background, not force-quit.
|
||||
- **Time zone:** The chosen time is in the device’s local time. Ensure the device date/time and time zone are correct when testing.
|
||||
|
||||
### Optional test cases
|
||||
|
||||
- **No starred plans:** Enable New Activity with no starred projects; confirm no crash and a sensible fallback message in the notification.
|
||||
- **JWT / API errors:** After leaving the app in background for a long time, the JWT may expire. Re-opening Account (or app) may re-run `configureNativeFetcherIfReady`; document or test whether a new notification still gets valid content or shows fallback.
|
||||
- **Daily Reminder and New Activity both on:** With the fix, turning off only New Activity should not affect the Daily Reminder notification (they use different plugin APIs; Option B must not cancel the single reminder if the user still has Daily Reminder on).
|
||||
|
||||
### Testing: starred project with new activity (Android native fetcher)
|
||||
|
||||
Use this to verify that when a **starred** plan has **new** activity reported by `plansLastUpdatedBetween`, the notification shows API-derived copy (not only the dual-schedule default from `dualScheduleConfig.ts`).
|
||||
|
||||
The steps and expected notification copy below are **Android-specific**: this repo registers `TimeSafariNativeFetcher` only on Android today. Do not assume the same strings or behavior on iOS until native fetcher parity exists; see **`doc/notification-from-api-call.md`** (iOS checklist and remaining tasks).
|
||||
|
||||
**How it works (short):** On Android, `TimeSafariNativeFetcher` POSTs to `/api/v2/report/plansLastUpdatedBetween` with `planIds` from the plugin (`updateStarredPlans`) and `afterId` from stored `last_acked_jwt_id` (or `"0"` initially). When the response `data` array is **non-empty**, the fetcher builds **one** `NotificationContent`: title **Starred Project Update** (one row) or **Starred Project Updates** (two or more rows); body uses each row’s `plan.name` when present (else **Unnamed Project**). For a single update: `[name] has been updated.` For multiple: typographic quotes around the first row’s name, then ` + N more have been updated.` (with `N` = number of additional rows). When `data` is **empty**, the fetcher returns **no** notification items (no “nothing to report” notification). (See `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`.)
|
||||
|
||||
**Procedure (repeatable on device)**
|
||||
|
||||
1. Sign in on the Endorser environment you mean to test (e.g. test API URL in Account—see **Prerequisites**, Endorser API URL) so `configureNativeFetcherIfReady` can set JWT and `activeDid`.
|
||||
2. Star at least one project you can change (e.g. your own test plan on staging).
|
||||
3. Turn **New Activity Notification** on and pick a time **2–5 minutes ahead** (same quick-test pattern as above).
|
||||
4. Open **Account** once (or finish the enable flow) so `updateStarredPlans({ planIds })` runs with current `starredPlanHandleIds`.
|
||||
5. **Background the app** (home out; do not force-quit). Prefetch runs on the cron **~5 minutes before** the chosen time; the user notification fires at the chosen time.
|
||||
6. **Produce new activity the API will return:** before that prefetch window (i.e. early enough that the scheduled content fetch still sees it), make a real change to the starred plan so `plansLastUpdatedBetween` returns **new** rows after the current `afterId` (e.g. an edit or other update your backend exposes through that report). If you change the plan **after** prefetch already ran with no new rows, you may not get an API-derived notification until the next prefetch cycle (typically the next day at the same T−5 schedule, unless you reschedule).
|
||||
|
||||
**What to verify**
|
||||
|
||||
- **One notification** at the chosen time (no extra static “Daily Check-In” after the fix—see “What to verify (after fix)” above).
|
||||
- **Success path (API returns updates):** Title/body match **Starred Project Update(s)** and the `[name] has been updated.` / `[first name] + N more have been updated.` patterns (names from `plan.name`), not the generic `buildDualScheduleConfig` defaults (**New Activity** / **Check your starred projects and offers for updates.**), which apply when the plugin falls back—e.g. fetch failure—not when the Android fetcher successfully returns Endorser-parsed content.
|
||||
- **Contrast (cursor caught up, no new rows):** After a successful fetch that returned data, `last_acked_jwt_id` advances. Without further plan changes, a later prefetch may return an empty `data` array; the fetcher then supplies **no** Endorser-derived notification (useful to compare against the “has activity” case; the plugin may still show dual-schedule fallback text depending on configuration).
|
||||
|
||||
**Repeatability:** Each successful fetch that returns data moves the `afterId` cursor forward. To see **Starred Project Update** copy again on subsequent tests, make **another** qualifying plan change (or accept heavier setup such as clearing app/plugin storage to reset cursor—usually unnecessary).
|
||||
|
||||
**Debugging:** On Android, filter **logcat** for `TimeSafariNativeFetcher` (e.g. HTTP 200, `Fetched N notification(s)`) to confirm prefetch ran and how many `NotificationContent` items were built.
|
||||
|
||||
**Note:** The in-app **New Activity** screen loads starred changes via the JS stack; the **push** path uses the native fetcher and plugin cache. Validate the notification using **background + prefetch timing**, not only by opening that screen.
|
||||
|
||||
---
|
||||
|
||||
## 8. Plugin Repo Alignment and Attention Items
|
||||
|
||||
Comparison with the **daily-notification-plugin** repo on gitea (`trent_larson/daily-notification-plugin`, `master` or the tag this app pins) to confirm our documentation and usage line up, and to flag anything that needs attention for the New Activity feature.
|
||||
|
||||
### 8.1 What lines up
|
||||
|
||||
- **API surface:** Plugin `definitions.ts` exposes `configureNativeFetcher({ apiBaseUrl, activeDid, jwtToken })`, `scheduleDualNotification(config)`, `cancelDualSchedule()`, `updateStarredPlans({ planIds })`, `scheduleDailyNotification(options)`, and `cancelDailyReminder(reminderId)`. Our app uses these as described in this doc; `buildDualScheduleConfig` produces a `DualScheduleConfiguration` that matches the plugin’s `ContentFetchConfig` / `UserNotificationConfig` / `relationship` shape (cron schedules, title/body, `callbacks: {}`, `fallbackBehavior: "show_default"`, etc.).
|
||||
- **Native fetcher:** Plugin is designed for a host-supplied JWT via `configureNativeFetcher` and a native fetcher implementation (e.g. Android `TimeSafariNativeFetcher`). Our `nativeFetcherConfig.ts` and Android `TimeSafariNativeFetcher.java` follow that model; prefetch runs in the plugin’s background workers and uses the configured credentials.
|
||||
- **Dual vs single:** The plugin clearly separates:
|
||||
- **Single daily path:** `scheduleDailyNotification(options)` (with `id` on Android) and `cancelDailyReminder(reminderId)` (iOS uses `reminder_<reminderId>` for the static-reminder path).
|
||||
- **Dual path:** `scheduleDualNotification(config)` and `cancelDualSchedule()`.
|
||||
So our analysis that “two schedules” are created when the dialog schedules the single reminder and AccountViewView schedules the dual is consistent with the plugin.
|
||||
- **Exact alarm:** The plugin’s Android implementation does **not** require exact alarm: it proceeds with scheduling using inexact/windowed alarms when exact is not granted. The plugin’s `INTEGRATION_GUIDE.md` still shows `SCHEDULE_EXACT_ALARM` in the manifest example; this app has chosen to disable exact alarm, and the plugin supports that. No doc change needed beyond what we already state in section 7.
|
||||
|
||||
### 8.2 Attention items
|
||||
|
||||
- **`cancelDailyReminder` signature:** In the plugin’s `definitions.ts`, `cancelDailyReminder(reminderId: string)`. The app calls it with an object: `cancelDailyReminder({ reminderId })`. On iOS the plugin uses `call.getString("reminderId")`, so the object form works. If the plugin’s TypeScript definition is ever used for strict typing, prefer updating the plugin to accept `{ reminderId: string }` or document that the bridge accepts an object with a `reminderId` key.
|
||||
- **Plugin INTEGRATION_GUIDE vs this app:** The guide describes generic polling, dual scheduling, and optional `SCHEDULE_EXACT_ALARM`. This app uses the dual-schedule + native-fetcher path only (no generic polling), and does not use exact alarm. When onboarding or debugging, treat the guide as the full plugin feature set; our flow is the “legacy dual scheduling” + native fetcher part plus `updateStarredPlans` and `configureNativeFetcher`.
|
||||
- **iOS `scheduleDailyNotification` and stable `id`:** On **Android**, the plugin uses `options.getString("id")` as the stable `scheduleId` for “one per day” semantics and cleanup. On **iOS**, the implementation in the repo was observed to build notification content with an internally generated id (e.g. `daily_<timestamp>`) and not obviously use the app-provided `id` from the call. If the app ever relies on a stable id on iOS for the single reminder (e.g. to cancel or replace only that reminder), it’s worth confirming in the plugin’s iOS code whether the call’s `id` is read and used; if not, consider requesting or contributing a change so iOS also uses the app-provided id for consistency with Android.
|
||||
- **Dual schedule and content fetch:** The plugin’s dual schedule runs the content-fetch job on its cron and then the user notification at the configured time; our config uses a 5-minute gap and `relationship.contentTimeout` / `fallbackBehavior: "show_default"`. The native fetcher is invoked by the plugin’s background layer when the content-fetch schedule fires; we don’t rely on JS `callbacks` in the config (we pass `callbacks: {}`). That matches the “native fetcher does the work” design.
|
||||
|
||||
### 8.3 iOS `UNIMPLEMENTED` on `scheduleDualNotification` (other methods work)
|
||||
|
||||
If iOS logs `scheduleNewActivityDualNotification failed: {"code":"UNIMPLEMENTED"}` while `configureNativeFetcher` succeeds, Capacitor is often rejecting the call in **JavaScript** because `scheduleDualNotification` is missing from `window.Capacitor.PluginHeaders` for `DailyNotification` (stale **Pods / Xcode binary** after upgrading the plugin). **Not** usually a missing Swift handler if `node_modules` already lists the method in `pluginMethods`.
|
||||
|
||||
**Recovery:** `npx cap sync ios`, `cd ios/App && pod install`, Xcode **Clean Build Folder**, rebuild. See **`doc/plugin-feedback-ios-scheduleDualNotification.md`** (troubleshooting section).
|
||||
|
||||
### 8.4 Summary
|
||||
|
||||
The plugin repo aligns with how we use it for New Activity (dual schedule + native fetcher, no generic polling, exact alarm optional). The main follow-ups are: (1) clarify or align `cancelDailyReminder` argument shape in the plugin if needed for typing/tooling, and (2) confirm on iOS whether `scheduleDailyNotification` uses the app-provided `id` for stable single-reminder semantics.
|
||||
@@ -0,0 +1,238 @@
|
||||
# Notification Permissions & Rollover Handling
|
||||
|
||||
**Date**: 2026-01-23
|
||||
**Purpose**: Answers to questions about permission requests and rollover handling
|
||||
|
||||
---
|
||||
|
||||
## Question 1: Where does the notification permission request happen?
|
||||
|
||||
### Permission Request Flow
|
||||
|
||||
The permission request flows through multiple layers:
|
||||
|
||||
```
|
||||
User clicks "Turn on Daily Message"
|
||||
↓
|
||||
PushNotificationPermission.vue
|
||||
↓ (line 715)
|
||||
service.requestPermissions()
|
||||
↓
|
||||
NotificationService.getInstance()
|
||||
↓ (platform detection)
|
||||
NativeNotificationService.requestPermissions()
|
||||
↓ (line 53)
|
||||
DailyNotification.requestPermissions()
|
||||
↓
|
||||
Plugin Native Code
|
||||
↓
|
||||
┌─────────────────────┬─────────────────────┐
|
||||
│ iOS Platform │ Android Platform │
|
||||
├─────────────────────┼─────────────────────┤
|
||||
│ UNUserNotification │ ActivityCompat │
|
||||
│ Center.current() │ .requestPermissions()│
|
||||
│ .requestAuthorization│ │
|
||||
│ (options: [.alert, │ (POST_NOTIFICATIONS) │
|
||||
│ .sound, .badge]) │ │
|
||||
└─────────────────────┴─────────────────────┘
|
||||
↓
|
||||
Native OS Permission Dialog
|
||||
↓
|
||||
User grants/denies
|
||||
↓
|
||||
Result returned to app
|
||||
```
|
||||
|
||||
### Code Locations
|
||||
|
||||
**1. UI Entry Point** (`src/components/PushNotificationPermission.vue`):
|
||||
```typescript
|
||||
// Line 715
|
||||
const granted = await service.requestPermissions();
|
||||
```
|
||||
|
||||
**2. Service Layer** (`src/services/notifications/NativeNotificationService.ts`):
|
||||
```typescript
|
||||
// Lines 49-68
|
||||
async requestPermissions(): Promise<boolean> {
|
||||
const result = await DailyNotification.requestPermissions();
|
||||
return result.allPermissionsGranted;
|
||||
}
|
||||
```
|
||||
|
||||
**3. Plugin Registration** (`src/plugins/DailyNotificationPlugin.ts`):
|
||||
```typescript
|
||||
// Line 30-36
|
||||
const DailyNotification = registerPlugin<DailyNotificationPluginType>(
|
||||
"DailyNotification"
|
||||
);
|
||||
```
|
||||
|
||||
**4. iOS Native Implementation** (`node_modules/@timesafari/daily-notification-plugin/ios/Plugin/DailyNotificationScheduler.swift`):
|
||||
```swift
|
||||
// Lines 113-115
|
||||
func requestPermissions() async -> Bool {
|
||||
let granted = try await notificationCenter.requestAuthorization(
|
||||
options: [.alert, .sound, .badge]
|
||||
)
|
||||
return granted
|
||||
}
|
||||
```
|
||||
|
||||
**5. Android Native Implementation** (`node_modules/@timesafari/daily-notification-plugin/android/src/main/java/com/timesafari/dailynotification/PermissionManager.java`):
|
||||
```java
|
||||
// Line 87
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
new String[]{Manifest.permission.POST_NOTIFICATIONS},
|
||||
REQUEST_CODE
|
||||
);
|
||||
```
|
||||
|
||||
### Platform-Specific Details
|
||||
|
||||
#### iOS
|
||||
- **API Used**: `UNUserNotificationCenter.requestAuthorization()`
|
||||
- **Options Requested**: `.alert`, `.sound`, `.badge`
|
||||
- **Dialog**: System-native iOS permission dialog
|
||||
- **Location**: First time user enables notifications
|
||||
- **Result**: Returns `true` if granted, `false` if denied
|
||||
|
||||
#### Android
|
||||
- **API Used**: `ActivityCompat.requestPermissions()`
|
||||
- **Permission**: `POST_NOTIFICATIONS` (Android 13+)
|
||||
- **Dialog**: System-native Android permission dialog
|
||||
- **Location**: First time user enables notifications
|
||||
- **Result**: Returns `true` if granted, `false` if denied
|
||||
- **Note**: Android 12 and below don't require runtime permission (declared in manifest)
|
||||
|
||||
### When Permission Request Happens
|
||||
|
||||
The permission request is triggered when:
|
||||
1. User opens the notification setup dialog (`PushNotificationPermission.vue`)
|
||||
2. User clicks "Turn on Daily Message" button
|
||||
3. App detects native platform (`isNativePlatform === true`)
|
||||
4. `turnOnNativeNotifications()` method is called
|
||||
5. `service.requestPermissions()` is called (line 715)
|
||||
|
||||
**Important**: The permission dialog only appears **once** per app installation. After that:
|
||||
- If granted: Future calls to `requestPermissions()` return `true` immediately
|
||||
- If denied: User must manually enable in system settings
|
||||
|
||||
---
|
||||
|
||||
## Question 2: Does the plugin handle rollovers automatically?
|
||||
|
||||
### ✅ Yes - Rollover Handling is Automatic
|
||||
|
||||
The plugin **automatically handles rollovers** in multiple scenarios:
|
||||
|
||||
### 1. Initial Scheduling (Time Has Passed Today)
|
||||
|
||||
**Location**: `ios/Plugin/DailyNotificationScheduler.swift` (lines 326-329)
|
||||
|
||||
```swift
|
||||
// If time has passed today, schedule for tomorrow
|
||||
if scheduledDate <= now {
|
||||
scheduledDate = calendar.date(byAdding: .day, value: 1, to: scheduledDate) ?? scheduledDate
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- If user schedules a notification for 9:00 AM but it's already 10:00 AM today
|
||||
- Plugin automatically schedules it for 9:00 AM **tomorrow**
|
||||
- No manual intervention needed
|
||||
|
||||
### 2. Daily Rollover (After Notification Fires)
|
||||
|
||||
**Location**: `ios/Plugin/DailyNotificationScheduler.swift` (lines 437-609)
|
||||
|
||||
The plugin has a `scheduleNextNotification()` function that:
|
||||
- Automatically schedules the next day's notification after current one fires
|
||||
- Handles 24-hour rollovers with DST (Daylight Saving Time) awareness
|
||||
- Prevents duplicate rollovers with state tracking
|
||||
|
||||
**Key Function**: `calculateNextScheduledTime()` (lines 397-435)
|
||||
```swift
|
||||
// Add 24 hours (handles DST transitions automatically)
|
||||
guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else {
|
||||
// Fallback to simple 24-hour addition
|
||||
return currentScheduledTime + (24 * 60 * 60 * 1000)
|
||||
}
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- ✅ DST-safe: Uses Calendar API to handle daylight saving transitions
|
||||
- ✅ Automatic: No manual scheduling needed
|
||||
- ✅ Persistent: Survives app restarts and device reboots
|
||||
- ✅ Duplicate prevention: Tracks rollover state to prevent duplicates
|
||||
|
||||
### 3. Rollover State Tracking
|
||||
|
||||
**Location**: `ios/Plugin/DailyNotificationStorage.swift` (lines 161-195)
|
||||
|
||||
The plugin tracks rollover state to prevent duplicate scheduling:
|
||||
|
||||
```swift
|
||||
// Check if rollover was processed recently (< 1 hour ago)
|
||||
if let lastTime = lastRolloverTime,
|
||||
(currentTime - lastTime) < (60 * 60 * 1000) {
|
||||
// Skip - already processed
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
**Purpose**: Prevents multiple rollover attempts if notification fires multiple times
|
||||
|
||||
### 4. Android Rollover Handling
|
||||
|
||||
Android implementation also handles rollovers:
|
||||
- Uses `AlarmManager` with `setRepeating()` or schedules next alarm after current fires
|
||||
- Handles timezone changes and DST transitions
|
||||
- Persists across device reboots via `BootReceiver`
|
||||
|
||||
### Rollover Scenarios Handled
|
||||
|
||||
| Scenario | Handled? | How |
|
||||
|----------|----------|-----|
|
||||
| Time passed today | ✅ Yes | Schedules for tomorrow automatically |
|
||||
| Daily rollover | ✅ Yes | Schedules next day after notification fires |
|
||||
| DST transitions | ✅ Yes | Uses Calendar API for DST-aware calculations |
|
||||
| Device reboot | ✅ Yes | BootReceiver restores schedules |
|
||||
| App restart | ✅ Yes | Schedules persist in database |
|
||||
| Duplicate prevention | ✅ Yes | State tracking prevents duplicate rollovers |
|
||||
|
||||
### Verification
|
||||
|
||||
You can verify rollover handling by:
|
||||
|
||||
1. **Check iOS logs** for rollover messages:
|
||||
```
|
||||
DNP-ROLLOVER: START id=... current_time=... scheduled_time=...
|
||||
DNP-ROLLOVER: CALC_NEXT current=... next=... diff_hours=24.00
|
||||
```
|
||||
|
||||
2. **Test scenario**: Schedule notification for a time that's already passed today
|
||||
- Expected: Notification scheduled for tomorrow at same time
|
||||
|
||||
3. **Test scenario**: Wait for notification to fire
|
||||
- Expected: Next day's notification automatically scheduled
|
||||
|
||||
### Summary
|
||||
|
||||
✅ **Permission Request**: Happens in native plugin code via platform-specific APIs:
|
||||
- iOS: `UNUserNotificationCenter.requestAuthorization()`
|
||||
- Android: `ActivityCompat.requestPermissions()`
|
||||
|
||||
✅ **Rollover Handling**: Fully automatic:
|
||||
- Initial scheduling: If time passed, schedules for tomorrow
|
||||
- Daily rollover: Automatically schedules next day after notification fires
|
||||
- DST handling: Calendar-aware calculations
|
||||
- Duplicate prevention: State tracking prevents issues
|
||||
- Persistence: Survives app restarts and device reboots
|
||||
|
||||
**No manual intervention needed** - the plugin handles all rollover scenarios automatically!
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-23
|
||||
@@ -0,0 +1,378 @@
|
||||
# Notification System Overview
|
||||
|
||||
**Date**: 2026-01-23
|
||||
**Purpose**: Understanding notification architecture and implementation guide for daily-notification-plugin
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Your app has **two separate notification systems** that coexist:
|
||||
|
||||
1. **Web Push Notifications** (Web/PWA platforms)
|
||||
- Uses service workers, VAPID keys, and a push server
|
||||
- Requires the "Notification Push Server" setting
|
||||
- Server-based delivery
|
||||
|
||||
2. **Native Notifications** (iOS/Android via DailyNotificationPlugin)
|
||||
- Uses native OS notification APIs
|
||||
- On-device scheduling (no server needed)
|
||||
- The "Notification Push Server" setting is **NOT used** for native
|
||||
|
||||
The system automatically selects the correct implementation based on platform using `Capacitor.isNativePlatform()`.
|
||||
|
||||
---
|
||||
|
||||
## Notification Push Server Setting
|
||||
|
||||
### Location
|
||||
- **File**: `src/views/AccountViewView.vue` (lines 506-549)
|
||||
- **UI Section**: Advanced Settings → "Notification Push Server"
|
||||
- **Database Field**: `settings.webPushServer`
|
||||
|
||||
### Purpose
|
||||
The "Notification Push Server" setting **ONLY applies to Web Push notifications** (web/PWA platforms). It configures:
|
||||
|
||||
1. **VAPID Key Retrieval**: The server URL used to fetch VAPID (Voluntary Application Server Identification) keys
|
||||
2. **Subscription Endpoint**: Where push subscriptions are sent
|
||||
3. **Push Message Delivery**: The server that sends push messages to browsers
|
||||
|
||||
### How It Works (Web Push Flow)
|
||||
|
||||
```
|
||||
User enables notification
|
||||
↓
|
||||
PushNotificationPermission.vue opens
|
||||
↓
|
||||
Fetches VAPID key from: {webPushServer}/web-push/vapid
|
||||
↓
|
||||
Subscribes to browser push service
|
||||
↓
|
||||
Sends subscription + time + message to: {webPushServer}/web-push/subscribe
|
||||
↓
|
||||
Server stores subscription and schedules push messages
|
||||
↓
|
||||
Server sends push messages at scheduled time via browser push service
|
||||
```
|
||||
|
||||
### Key Code Locations
|
||||
|
||||
**AccountViewView.vue** (lines 1473-1479):
|
||||
```typescript
|
||||
async onClickSavePushServer(): Promise<void> {
|
||||
await this.$saveSettings({
|
||||
webPushServer: this.webPushServerInput,
|
||||
});
|
||||
this.webPushServer = this.webPushServerInput;
|
||||
this.notify.warning(ACCOUNT_VIEW_CONSTANTS.INFO.RELOAD_VAPID);
|
||||
}
|
||||
```
|
||||
|
||||
**PushNotificationPermission.vue** (lines 177-221):
|
||||
- Retrieves `webPushServer` from settings
|
||||
- Fetches VAPID key from `{webPushServer}/web-push/vapid`
|
||||
- Uses VAPID key to subscribe to push notifications
|
||||
|
||||
**PushNotificationPermission.vue** (lines 556-575):
|
||||
- Sends subscription to `/web-push/subscribe` endpoint (relative URL, handled by service worker)
|
||||
|
||||
### Important Notes
|
||||
|
||||
- ⚠️ **This setting is NOT used for native iOS/Android notifications**
|
||||
- The setting defaults to `DEFAULT_PUSH_SERVER` if not configured
|
||||
- Changing the server requires reloading VAPID keys (hence the warning message)
|
||||
- Local development (`http://localhost`) skips VAPID key retrieval
|
||||
|
||||
---
|
||||
|
||||
## Daily Notification Plugin Integration
|
||||
|
||||
### Current Status
|
||||
|
||||
✅ **Infrastructure Complete**:
|
||||
- Plugin registered (`src/plugins/DailyNotificationPlugin.ts`)
|
||||
- Service abstraction layer created (`src/services/notifications/`)
|
||||
- Platform detection working
|
||||
- Native implementation ready (`NativeNotificationService.ts`)
|
||||
|
||||
🔄 **UI Integration Needed**:
|
||||
- `PushNotificationPermission.vue` still uses web push logic
|
||||
- AccountViewView notification toggles need platform detection
|
||||
- Settings storage needs to handle both systems
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
NotificationService.getInstance()
|
||||
↓
|
||||
Platform Detection (Capacitor.isNativePlatform())
|
||||
↓
|
||||
┌─────────────────────┬─────────────────────┐
|
||||
│ Native Platform │ Web Platform │
|
||||
│ (iOS/Android) │ (Web/PWA) │
|
||||
├─────────────────────┼─────────────────────┤
|
||||
│ NativeNotification │ WebPushNotification │
|
||||
│ Service │ Service │
|
||||
│ │ │
|
||||
│ Uses: │ Uses: │
|
||||
│ - DailyNotification │ - Service Workers │
|
||||
│ Plugin │ - VAPID Keys │
|
||||
│ - Native OS APIs │ - Push Server │
|
||||
│ - On-device alarms │ - Server scheduling │
|
||||
└─────────────────────┴─────────────────────┘
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Feature | Native (Plugin) | Web Push |
|
||||
|---------|----------------|----------|
|
||||
| **Server Required** | ❌ No | ✅ Yes (Notification Push Server) |
|
||||
| **Scheduling** | On-device | Server-side |
|
||||
| **Offline Delivery** | ✅ Yes | ❌ No (requires network) |
|
||||
| **Background Support** | ✅ Full | ⚠️ Limited (browser-dependent) |
|
||||
| **Permission Model** | OS-level | Browser-level |
|
||||
| **Settings Storage** | Local only | Local + server subscription |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Recommendations
|
||||
|
||||
### 1. Update PushNotificationPermission Component
|
||||
|
||||
**Current State**: Only handles web push
|
||||
|
||||
**Recommended Changes**:
|
||||
|
||||
```typescript
|
||||
// In PushNotificationPermission.vue
|
||||
import { NotificationService } from '@/services/notifications';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
async open(pushType: string, callback?: ...) {
|
||||
const isNative = Capacitor.isNativePlatform();
|
||||
|
||||
if (isNative) {
|
||||
// Use native notification service
|
||||
const service = NotificationService.getInstance();
|
||||
const granted = await service.requestPermissions();
|
||||
|
||||
if (granted) {
|
||||
// Show time picker UI
|
||||
// Then schedule via service.scheduleDailyNotification()
|
||||
}
|
||||
} else {
|
||||
// Existing web push logic
|
||||
// ... current implementation ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Update AccountViewView Notification Toggles
|
||||
|
||||
**Current State**: Always uses `PushNotificationPermission` component (web push)
|
||||
|
||||
**Recommended Changes**:
|
||||
|
||||
```typescript
|
||||
// In AccountViewView.vue
|
||||
import { NotificationService } from '@/services/notifications';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
async showNewActivityNotificationChoice(): Promise<void> {
|
||||
const isNative = Capacitor.isNativePlatform();
|
||||
|
||||
if (isNative) {
|
||||
// Use native service directly
|
||||
const service = NotificationService.getInstance();
|
||||
// Show time picker, then schedule
|
||||
} else {
|
||||
// Use existing PushNotificationPermission component
|
||||
(this.$refs.pushNotificationPermission as PushNotificationPermission)
|
||||
.open(DAILY_CHECK_TITLE, ...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Settings Storage Strategy
|
||||
|
||||
**Current Settings Fields** (from `src/db/tables/settings.ts`):
|
||||
- `notifyingNewActivityTime` - Time string for daily check
|
||||
- `notifyingReminderTime` - Time string for reminder
|
||||
- `notifyingReminderMessage` - Reminder message text
|
||||
- `webPushServer` - Push server URL (web only)
|
||||
|
||||
**Recommendation**: These settings work for both systems:
|
||||
- ✅ `notifyingNewActivityTime` - Works for both (native stores locally, web sends to server)
|
||||
- ✅ `notifyingReminderTime` - Works for both
|
||||
- ✅ `notifyingReminderMessage` - Works for both
|
||||
- ⚠️ `webPushServer` - Only used for web push (hide on native platforms)
|
||||
|
||||
### 4. Platform-Aware UI
|
||||
|
||||
**Recommendations**:
|
||||
|
||||
1. **Hide "Notification Push Server" setting on native platforms**:
|
||||
```vue
|
||||
<h2 v-if="!isNativePlatform" class="text-slate-500 text-sm font-bold mb-2">
|
||||
Notification Push Server
|
||||
</h2>
|
||||
```
|
||||
|
||||
2. **Update help text** to explain platform differences
|
||||
|
||||
3. **Show different messaging** based on platform:
|
||||
- Native: "Notifications are scheduled on your device"
|
||||
- Web: "Notifications are sent via push server"
|
||||
|
||||
---
|
||||
|
||||
## Notification Types
|
||||
|
||||
Your app supports two notification types:
|
||||
|
||||
### 1. Daily Check (`DAILY_CHECK_TITLE`)
|
||||
- **Purpose**: Notify user of new activity/updates
|
||||
- **Message**: Auto-generated by server (web) or app (native)
|
||||
- **Settings Field**: `notifyingNewActivityTime`
|
||||
|
||||
### 2. Direct Push (`DIRECT_PUSH_TITLE`)
|
||||
- **Purpose**: Daily reminder with custom message
|
||||
- **Message**: User-provided (max 100 characters)
|
||||
- **Settings Fields**: `notifyingReminderTime`, `notifyingReminderMessage`
|
||||
|
||||
Both types can be enabled simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Code Flow Examples
|
||||
|
||||
### Native Notification Flow (Recommended Implementation)
|
||||
|
||||
```typescript
|
||||
// 1. Get service instance
|
||||
const service = NotificationService.getInstance();
|
||||
|
||||
// 2. Request permissions
|
||||
const granted = await service.requestPermissions();
|
||||
if (!granted) {
|
||||
// Show error, guide to settings
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Schedule notification
|
||||
await service.scheduleDailyNotification({
|
||||
time: '09:00', // HH:mm format (24-hour)
|
||||
title: 'Daily Check-In',
|
||||
body: 'Time to check your TimeSafari activity',
|
||||
priority: 'normal'
|
||||
});
|
||||
|
||||
// 4. Save to settings
|
||||
await this.$saveSettings({
|
||||
notifyingNewActivityTime: '09:00'
|
||||
});
|
||||
|
||||
// 5. Check status
|
||||
const status = await service.getStatus();
|
||||
console.log('Enabled:', status.enabled);
|
||||
console.log('Time:', status.scheduledTime);
|
||||
```
|
||||
|
||||
### Web Push Flow (Current Implementation)
|
||||
|
||||
```typescript
|
||||
// 1. Open PushNotificationPermission component
|
||||
(this.$refs.pushNotificationPermission as PushNotificationPermission)
|
||||
.open(DAILY_CHECK_TITLE, async (success, timeText) => {
|
||||
if (success) {
|
||||
// Component handles:
|
||||
// - VAPID key retrieval from webPushServer
|
||||
// - Service worker subscription
|
||||
// - Sending subscription to server
|
||||
|
||||
// Just save the time
|
||||
await this.$saveSettings({
|
||||
notifyingNewActivityTime: timeText
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Native (iOS/Android)
|
||||
- [ ] Request permissions works
|
||||
- [ ] Notification appears at scheduled time
|
||||
- [ ] Notification survives app close
|
||||
- [ ] Notification survives device reboot
|
||||
- [ ] Both notification types can be enabled
|
||||
- [ ] Cancellation works correctly
|
||||
|
||||
### Web Push
|
||||
- [ ] VAPID key retrieval works
|
||||
- [ ] Service worker subscription works
|
||||
- [ ] Subscription sent to server
|
||||
- [ ] Push messages received at scheduled time
|
||||
- [ ] Works with different push server URLs
|
||||
|
||||
### Platform Detection
|
||||
- [ ] Correct service selected on iOS
|
||||
- [ ] Correct service selected on Android
|
||||
- [ ] Correct service selected on web
|
||||
- [ ] Settings UI shows/hides appropriately
|
||||
|
||||
---
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
### Core Notification Services
|
||||
- `src/services/notifications/NotificationService.ts` - Factory/selector
|
||||
- `src/services/notifications/NativeNotificationService.ts` - Native implementation
|
||||
- `src/services/notifications/WebPushNotificationService.ts` - Web implementation (stub)
|
||||
|
||||
### UI Components
|
||||
- `src/components/PushNotificationPermission.vue` - Web push UI (needs update)
|
||||
- `src/views/AccountViewView.vue` - Settings UI (lines 506-549 for push server)
|
||||
|
||||
### Settings & Constants
|
||||
- `src/db/tables/settings.ts` - Settings schema
|
||||
- `src/constants/app.ts` - `DEFAULT_PUSH_SERVER` constant
|
||||
- `src/libs/util.ts` - `DAILY_CHECK_TITLE`, `DIRECT_PUSH_TITLE`
|
||||
|
||||
### Plugin
|
||||
- `src/plugins/DailyNotificationPlugin.ts` - Plugin registration
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Update `PushNotificationPermission.vue`** to detect platform and use appropriate service
|
||||
2. **Update `AccountViewView.vue`** notification toggles to use platform detection
|
||||
3. **Hide "Notification Push Server" setting** on native platforms
|
||||
4. **Test on real devices** (iOS and Android)
|
||||
5. **Update documentation** with platform-specific instructions
|
||||
|
||||
---
|
||||
|
||||
## Questions & Answers
|
||||
|
||||
**Q: Do I need to configure the Notification Push Server for native apps?**
|
||||
A: No. The setting is only for web push. Native notifications are scheduled on-device.
|
||||
|
||||
**Q: Can both notification systems be active at the same time?**
|
||||
A: No, they're mutually exclusive per platform. The app automatically selects the correct one.
|
||||
|
||||
**Q: How do I test native notifications?**
|
||||
A: Use `NotificationService.getInstance()` and test on a real device (simulators have limitations).
|
||||
|
||||
**Q: What happens if I change the push server URL?**
|
||||
A: Only affects web push. Users need to re-subscribe to push notifications with the new server.
|
||||
|
||||
**Q: Can I use the same settings fields for both systems?**
|
||||
A: Yes! The time and message fields work for both. Only `webPushServer` is web-specific.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-23
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Plugin: Android — Alarm set after edit doesn’t fire (cancel-before-reschedule)
|
||||
|
||||
**Context:** Consuming app (TimeSafari) — user sets reminder at 6:57pm (fires), then edits to 7:00pm. Only one `scheduleDailyNotification` call is made (skipSchedule fix). Logs show "Scheduling OS alarm" and "Updated schedule in database" for 19:00, but the notification never fires at 7:00pm.
|
||||
|
||||
**Likely cause (plugin):** In `NotifyReceiver.kt`, before calling `setAlarmClock(pendingIntent)` the code:
|
||||
|
||||
1. Creates `pendingIntent` with `PendingIntent.getBroadcast(..., requestCode, intent, FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE)`.
|
||||
2. Gets `existingPendingIntent` with `PendingIntent.getBroadcast(..., requestCode, intent, FLAG_NO_CREATE | FLAG_IMMUTABLE)` (same `requestCode`, same `intent`).
|
||||
3. If not null: `alarmManager.cancel(existingPendingIntent)` and **`existingPendingIntent.cancel()`**.
|
||||
4. Then calls `alarmManager.setAlarmClock(alarmClockInfo, pendingIntent)`.
|
||||
|
||||
On Android, PendingIntent equality for caching is based on requestCode and Intent (action, component, etc.), not necessarily all extras. So `existingPendingIntent` is often the **same** (cached) PendingIntent as `pendingIntent`. Then we call **`existingPendingIntent.cancel()`**, which cancels that PendingIntent for future use. We then use the same (now cancelled) PendingIntent in **`setAlarmClock(..., pendingIntent)`**. On some devices/versions, setting an alarm with a cancelled PendingIntent can result in the alarm not firing.
|
||||
|
||||
**Suggested fix (plugin repo):**
|
||||
|
||||
- Remove the **`existingPendingIntent.cancel()`** call. Use only **`alarmManager.cancel(existingPendingIntent)`** to clear any existing alarm for this requestCode. That way the PendingIntent we pass to `setAlarmClock` is not cancelled; only the previous alarm is removed.
|
||||
- Optionally: only run the “cancel existing” block when we know there was a previous schedule (e.g. from DB) for this scheduleId that hasn’t fired yet, so we don’t cancel when the previous alarm already fired (e.g. user edited after first fire).
|
||||
|
||||
**Verification:**
|
||||
|
||||
- In the consuming app: set reminder 2–3 min from now, let it fire, then edit to 2–3 min from then and save. Capture logcat through the second scheduled time.
|
||||
- If the receiver never logs at the second time, the OS didn’t deliver the alarm; fixing the cancel-before-reschedule logic as above should be tried first in the plugin.
|
||||
|
||||
**References:**
|
||||
|
||||
- CONSUMING_APP_ANDROID_NOTES.md (double schedule, alarm scheduled but not firing).
|
||||
- NotifyReceiver.kt around “Cancelling existing alarm before rescheduling” and the following `setAlarmClock` use of `pendingIntent`.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Plugin fix: Android 6.0 (API 23) compatibility — replace java.time.ZoneId with TimeZone
|
||||
|
||||
**Date:** 2026-02-27
|
||||
**Target repo:** daily-notification-plugin
|
||||
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||
**Platform:** Android
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
On Android 6.0 (API 23), the plugin crashes at runtime when scheduling a daily notification because it uses `java.time.ZoneId`, which is only available from **API 26**. Replacing that with `java.util.TimeZone.getDefault().getID()` restores compatibility with API 23 and has **no functional impact** on API 26+ (same timezone ID string, same behavior).
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
- **File:** `android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt`
|
||||
- **Approximate location:** inside `scheduleExactNotification()`, when building `NotificationContentEntity` (around line 260).
|
||||
|
||||
The code uses:
|
||||
|
||||
```kotlin
|
||||
java.time.ZoneId.systemDefault().id
|
||||
```
|
||||
|
||||
On API 23 this causes a runtime failure (e.g. `NoClassDefFoundError`) when the scheduling path runs, because `java.time` was added to Android only in API 26 (Oreo).
|
||||
|
||||
---
|
||||
|
||||
## Required change
|
||||
|
||||
Replace the `java.time` call with the API-1–compatible equivalent.
|
||||
|
||||
**Before:**
|
||||
|
||||
```kotlin
|
||||
java.time.ZoneId.systemDefault().id
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```kotlin
|
||||
java.util.TimeZone.getDefault().id
|
||||
```
|
||||
|
||||
Use this in the same place where `NotificationContentEntity` is constructed (the parameter that stores the system timezone ID string). No other code changes are needed.
|
||||
|
||||
---
|
||||
|
||||
## Why this is safe on newer Android
|
||||
|
||||
- Both `ZoneId.systemDefault().id` and `TimeZone.getDefault().id` refer to the **same** system default timezone and return the **same** IANA timezone ID string (e.g. `"America/Los_Angeles"`, `"Europe/London"`).
|
||||
- Any downstream logic that reads this string (e.g. for display or next-run calculation) behaves identically on API 26+.
|
||||
- No change to data format or semantics; this is a backward-compatible drop-in replacement.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Build:** From a consuming app (e.g. crowd-funder-for-time-pwa) with `minSdkVersion = 23`, run a full Android build including the plugin. No compilation errors.
|
||||
2. **Runtime on API 23:** On an Android 6.0 device or emulator, enable daily notifications and schedule a time. The app should not crash; the notification should be scheduled and (after the delay) fire.
|
||||
3. **Runtime on API 26+:** Confirm scheduling and delivery still work as before on Android 8+.
|
||||
|
||||
---
|
||||
|
||||
## Context (consuming app)
|
||||
|
||||
- App and plugin both declare `minSdkVersion = 23` (Android 6.0). AlarmManager, permissions, and notification paths in the plugin are already API-23 safe; this `java.time` usage is the only blocker for running on Android 6.0 devices.
|
||||
|
||||
Use this document when applying the fix in the **daily-notification-plugin** repo (e.g. in Cursor). After changing the plugin, update the consuming app’s dependency (e.g. `npm update @timesafari/daily-notification-plugin` or point at the fixed commit), then `npx cap sync android` and rebuild.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Plugin feedback: Android dual schedule — native fetcher not used; fetch timing wrong
|
||||
|
||||
**Date:** 2026-03-24 21:56 PST
|
||||
**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin)
|
||||
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||
**Platform:** Android (Kotlin / Java)
|
||||
**Related:** New Activity notifications (`scheduleDualNotification` / `cancelDualSchedule`)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
On Android, the **dual (New Activity) schedule** path is **not** implementing the intended contract:
|
||||
|
||||
1. **Prefetch does not call `NativeNotificationContentFetcher`.**
|
||||
`ScheduleHelper.scheduleDualNotification` delegates fetch to `FetchWorker` (HTTP GET to optional `url`, or **mock JSON** when `url` is absent). The host app’s `TimeSafariNativeFetcher` is **never** invoked. Logcat shows `DNP-FETCH: Starting content fetch from: null, notificationTime=0` and **no** `TimeSafariNativeFetcher` `fetchContent` lines.
|
||||
|
||||
2. **Fetch is not scheduled at `contentFetch.schedule` (e.g. T−5 minutes).**
|
||||
`FetchWorker.enqueueFetch` enqueues **immediate** `OneTimeWorkRequest` work (no `setInitialDelay` aligned to the fetch cron). The **notify** alarm is scheduled correctly for `dual_notify_*`, but there is **no** corresponding alarm/work at the **fetch** cron time. A `dual_fetch_*` row may exist in the DB with `nextRunAt`, but the **actual** fetch runs at **enable/setup time**, not at T−5.
|
||||
|
||||
3. **Cache vs `DualScheduleHelper` / `contentTimeout`.**
|
||||
`DualScheduleHelper.resolveDualContentBlocking` only uses `contentCache` when the latest fetch is within `relationship.contentTimeout` (e.g. 5 minutes). If fetch runs **once at setup** and notify fires **~9+ minutes later**, cache is **stale** → `useCache=false` → default title/body from `userNotification`, even when mock payload was stored.
|
||||
|
||||
**Recommended direction (plugin):**
|
||||
|
||||
- For dual schedule when **no HTTP `url`** is configured (or when a flag indicates native mode), run **`NativeNotificationContentFetcher.fetchContent(FetchContext)`** (same path as `DailyNotificationFetchWorker` uses), persist results into the same `contentCache` / pipeline `DualScheduleHelper` expects.
|
||||
- **Schedule** that work (or an alarm that enqueues it) **at** `calculateNextRunTime(contentFetch.schedule)` — i.e. **before** the notify alarm, typically **5 minutes** earlier per app cron (see consuming app `timeToCronFiveMinutesBefore`).
|
||||
- Optionally align **one** scheduling mechanism: either exact alarm for fetch + notify, or WorkManager with **initial delay** to the next fetch instant (and reschedule after run).
|
||||
|
||||
---
|
||||
|
||||
## Symptoms (consuming app + logcat)
|
||||
|
||||
- Notification shows **default** copy from `userNotification` (`title` / `body` from `buildDualScheduleConfig`), not API-derived or native “No updates” copy.
|
||||
- Logcat: `DNP-DUAL: Resolved dual content: useCache=false` at notify time.
|
||||
- Logcat: `DNP-FETCH: Starting content fetch from: null, notificationTime=0` followed by `Content fetch completed successfully` **at schedule/setup time**, not at T−5.
|
||||
- **No** `TimeSafariNativeFetcher` `fetchContent START` / `POST …/plansLastUpdatedBetween` during prefetch window (host registers `NativeNotificationContentFetcher` and logs on configure + fetch).
|
||||
- **No** activity at the **prefetch cron** time (e.g. 19:05 for notify at 19:10); only **notify** fires at T.
|
||||
|
||||
---
|
||||
|
||||
## What the consuming app sends (contract)
|
||||
|
||||
**File:** `src/services/notifications/dualScheduleConfig.ts`
|
||||
|
||||
- `contentFetch.enabled: true`
|
||||
- `contentFetch.schedule`: cron **5 minutes before** `userNotification.schedule` (e.g. `"25 19 * * *"` for notify `"30 19 * * *"`).
|
||||
- **No** `contentFetch.url` — intended to use **native** Endorser API via `configureNativeFetcher` + `NativeNotificationContentFetcher`.
|
||||
- `relationship.autoLink: true`, `relationship.contentTimeout: 5 * 60 * 1000`, `fallbackBehavior: "show_default"`.
|
||||
|
||||
**Host app:** `android/.../TimeSafariNativeFetcher.java` implements `NativeNotificationContentFetcher` and calls `POST /api/v2/report/plansLastUpdatedBetween` with starred plan IDs from `updateStarredPlans`.
|
||||
|
||||
---
|
||||
|
||||
## Root cause (plugin code — paths to review)
|
||||
|
||||
These paths are from a local clone of **daily-notification-plugin**; line numbers may drift.
|
||||
|
||||
### 1. `FetchWorker` is URL/mock-only; does not call native fetcher
|
||||
|
||||
`android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt`
|
||||
|
||||
- `enqueueFetch` passes `config.url` into `InputData`; `doWork` logs `Starting content fetch from: $url`.
|
||||
- `fetchContent(url, …)` when `url` is null/blank returns **`generateMockContent()`** — never calls `DailyNotificationPlugin.getNativeFetcherStatic().fetchContent(...)`.
|
||||
|
||||
### 2. `scheduleDualNotification` runs fetch work immediately, not at fetch cron
|
||||
|
||||
`android/src/main/java/org/timesafari/dailynotification/DailyNotificationPlugin.kt` — `object ScheduleHelper`, `suspend fun scheduleDualNotification(...)`
|
||||
|
||||
- Calls `scheduleFetch(context, contentFetchConfig)` which resolves to `FetchWorker.scheduleFetchForDual` → `enqueueFetch` **without** delay tied to `contentFetchConfig.schedule`.
|
||||
- Schedules **notify** via `NotifyReceiver.scheduleExactNotification` for `dual_notify_*` at `calculateNextRunTime(userNotificationConfig.schedule)`.
|
||||
- Persists `dual_fetch_*` with `nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)` but **no** matching alarm/work is scheduled for that instant in the current flow (as observed).
|
||||
|
||||
### 3. Native fetcher exists elsewhere
|
||||
|
||||
`android/src/main/java/org/timesafari/dailynotification/DailyNotificationFetchWorker.java`
|
||||
|
||||
- Contains logic to call `NativeNotificationContentFetcher.fetchContent(FetchContext)` (with timeout). Dual schedule **does not** enqueue this worker for the TimeSafari `contentFetch` payload.
|
||||
|
||||
### 4. `DualScheduleHelper` behavior is consistent with “wrong fetch time”
|
||||
|
||||
`android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt`
|
||||
|
||||
- Uses latest `contentCache` only if `(now - fetchedAt) <= contentTimeoutMs`. If fetch ran at setup and notify is **later** than `contentTimeout`, **cache is ignored** → `useCache=false` in logs.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria (plugin)
|
||||
|
||||
After a fix, on a device with:
|
||||
|
||||
- `configureNativeFetcher` + `updateStarredPlans` called (host app),
|
||||
- `scheduleDualNotification` with `contentFetch.enabled: true`, no `url`, cron 5 min before notify,
|
||||
|
||||
then:
|
||||
|
||||
1. **At or before** the notify fire time, **within** `contentTimeout`, the cache used by `DualScheduleHelper` reflects **native** fetch results when the API returns data (or empty), not only mock JSON.
|
||||
2. Logcat **includes** host tag `TimeSafariNativeFetcher` with `fetchContent START` (or equivalent) **when** prefetch runs, **or** plugin logs an explicit `NativeNotificationContentFetcher` invocation.
|
||||
3. Prefetch **does not** run only at **INITIAL_SETUP**; it runs at the **next** occurrence of `contentFetch.schedule` (and reschedules for the following day after success, same as notify rollover).
|
||||
4. **Optional:** If `url` is set, preserve HTTP GET behavior; if `url` is absent and native fetcher is registered, use native path.
|
||||
|
||||
---
|
||||
|
||||
## References in consuming app
|
||||
|
||||
| Topic | Location |
|
||||
|--------|----------|
|
||||
| Dual config builder | `src/services/notifications/dualScheduleConfig.ts` |
|
||||
| `scheduleDualNotification` call | `src/views/AccountViewView.vue` (`scheduleNewActivityDualNotification`, `editNewActivityNotification`) |
|
||||
| Native fetcher | `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` |
|
||||
| Registration | `MainActivity` / plugin init (host registers `DailyNotificationPlugin.setNativeFetcher`) |
|
||||
|
||||
---
|
||||
|
||||
## Notes for Cursor / implementers
|
||||
|
||||
- **Do not** assume `contentFetch.url` is present; TimeSafari intentionally omits it for native API.
|
||||
- **Reuse** the same `FetchContext` / timeout semantics as `DailyNotificationFetchWorker` where possible to avoid two divergent native fetch implementations.
|
||||
- After changing timing, **verify** `WorkManager` unique work name `fetch_dual` / `cancelDualSchedule` still cancel only dual fetch and do not break daily reminder.
|
||||
|
||||
---
|
||||
|
||||
## Related docs in this repo
|
||||
|
||||
- `doc/notification-from-api-call.md` — integration plan for API-driven New Activity.
|
||||
- `doc/plugin-feedback-android-scheduleDualNotification-contentFetch-json.md` — optional `timeout` / `retry*` JSON parsing (already addressed on the plugin side).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user