Compare commits
414 Commits
registrati
...
daily-noti
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
|
|
5256681089 | ||
|
|
225b34d480 | ||
| d9f9460be7 | |||
|
|
b1026a9854 | ||
|
|
cba33c6ad9 | ||
|
|
756688bf75 | ||
| 7599b37c01 | |||
| a4024537c2 | |||
| 6fe4f21ea8 | |||
| 97b382451a | |||
|
|
be8230d046 | ||
| 284fee9ded | |||
|
|
7fd2c4e0c7 | ||
|
|
20322789a2 | ||
|
|
666bed0efd | ||
|
|
7432525f4c | ||
|
|
88778a167c | ||
|
|
f4144c7469 | ||
|
|
eca6dfe9d7 | ||
| 530cddfab0 | |||
|
|
a6d282e59b | ||
|
|
088b9eff7f | ||
| 5340c00ae2 | |||
| ee587ac3fc | |||
| b3112a4086 | |||
| db4496c57b | |||
| a51fd90659 | |||
| 0c627f4822 | |||
| c7276f0b4d | |||
| d6524cbd43 | |||
| f5bea24921 | |||
| 46d7fee95e | |||
| c0f407eb72 | |||
| e8e0f315f8 | |||
| 1ea4608f0d | |||
| 2dc9b509ce | |||
| f4569d8b98 | |||
| 7575895f75 | |||
| 67a9ecf6c6 | |||
| 823fa51275 | |||
|
|
e2c2d54c20 | ||
|
|
6fd53b020e | ||
|
|
a3d6b458b1 | ||
|
|
b1fcb49e7c | ||
|
|
299762789b | ||
|
|
7a961af750 | ||
|
|
1790a6c5d6 | ||
|
|
1cbed4d1c2 | ||
|
|
2f495f6767 | ||
|
|
0fae8bbda6 | ||
| 297fe3cec6 | |||
| 2a932af806 | |||
| 28cea8f55b | |||
|
|
f31a76b816 | ||
|
|
5d9f455fc8 | ||
|
|
afe0f5e019 | ||
|
|
e0e8af3fff | ||
| c3ff471ea1 | |||
| 0072db1595 | |||
|
|
24ec81b0ba | ||
|
|
2c439ef439 | ||
|
|
0ca70b0f4e | ||
|
|
d01c6c2e9b | ||
|
|
2b3c83c21c | ||
|
|
8b8566c578 | ||
| a1e2d635f7 | |||
| f371ce88a0 | |||
|
|
69e29ecf85 | ||
|
|
23b97d483d | ||
|
|
4c218c4786 | ||
|
|
31f66909fa | ||
|
|
7917e707e9 | ||
|
|
a9fe862dda | ||
|
|
79b2f9a273 | ||
|
|
cf854d5054 | ||
|
|
8eb4ad5c74 | ||
|
|
eb77547ba1 | ||
|
|
616bef655a | ||
|
|
6da9e14b8a | ||
|
|
e856ace61f | ||
| 855448d07a | |||
|
|
5da1591ad8 | ||
|
|
b06e2b46f6 | ||
| 626071281f | |||
|
|
5fc5b958af | ||
| 69c922284e | |||
|
|
ac603f66e2 | ||
|
|
9bdd66b9c9 | ||
|
|
6fb4ceab81 | ||
|
|
7b40012df4 | ||
|
|
79cb52419e | ||
|
|
d6b5e13499 | ||
|
|
61117a0f03 | ||
|
|
e1cf27be05 | ||
|
|
ccb1f29df4 | ||
|
|
f55ef85981 | ||
|
|
d9569922eb | ||
| 8815f36596 | |||
|
|
72872935ae | ||
|
|
a20c321a16 | ||
| c9cfeafd50 | |||
| 52b1e8ffa3 | |||
|
|
448d8a68d2 | ||
|
|
578dbe6177 | ||
|
|
704e495f5d | ||
|
|
04178bf9f8 | ||
|
|
b57be7670c | ||
|
|
10a1f435ed | ||
|
|
720be1aa4d | ||
|
|
4c761d8fd5 | ||
|
|
4cb1d8848f | ||
|
|
3e03aaf1e8 | ||
|
|
9ae9bed8a9 | ||
|
|
b2536adc4e | ||
|
|
22d6b08623 | ||
|
|
61703930f3 | ||
|
|
4c96a234e3 | ||
|
|
1a5aa7a5ef | ||
|
|
aa49a5d8a4 | ||
|
|
2db4f8f894 | ||
|
|
552de23ef2 | ||
|
|
2b423b8d7b | ||
|
|
8024688561 | ||
|
|
b374f2e5a1 | ||
| 9f1495e185 | |||
| f61cb6eea7 | |||
|
|
a522a10fb7 | ||
|
|
b4e1313b22 | ||
| d3f54d6bff | |||
| 2bb733a9ea | |||
|
|
f63f4856bf | ||
|
|
eb4ddaba50 | ||
|
|
971bc68a74 | ||
|
|
d2e04fe2a0 | ||
| 7da6f722f5 | |||
|
|
18ca6baded | ||
| 475f4d5ce5 | |||
|
|
ae4e9b3420 | ||
|
|
0bda040f15 | ||
|
|
a2e6ae5c28 | ||
| 24a7cf5eb6 | |||
| da0621c09a | |||
|
|
4a22a35b3e | ||
|
|
95b0cbca78 | ||
|
|
1227cdee76 | ||
|
|
fad7093fbd | ||
|
|
fddb2ac959 | ||
|
|
40babae05d | ||
|
|
acbc276ef6 | ||
|
|
649786ae01 | ||
|
|
4aea8d9ed3 | ||
|
|
0079ca252d |
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ language: Match repository languages and conventions
|
||||
|
||||
## Rules
|
||||
|
||||
0. **Principle:** just the facts m'am.
|
||||
1. **Default to the least complex solution.** Fix the problem directly
|
||||
where it occurs; avoid new layers, indirection, or patterns unless
|
||||
strictly necessary.
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
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
|
||||
✅ python script files must always have a blank line at their end
|
||||
✅ remove whitespace at the end of lines
|
||||
✅ use npm run lint-fix to check for warnings
|
||||
✅ do not use npm run dev let me handle running and supplying feedback
|
||||
@@ -22,12 +21,10 @@ alwaysApply: false
|
||||
|
||||
- [ ] **Timestamp Usage**: Include accurate timestamps in all interactions
|
||||
- [ ] **Code Quality**: Use npm run lint-fix to check for warnings
|
||||
- [ ] **File Standards**: Ensure Python files have blank line at end
|
||||
- [ ] **Whitespace**: Remove trailing whitespace from all lines
|
||||
|
||||
### After Development
|
||||
|
||||
- [ ] **Linting Check**: Run npm run lint-fix to verify code quality
|
||||
- [ ] **File Validation**: Confirm Python files end with blank line
|
||||
- [ ] **Whitespace Review**: Verify no trailing whitespace remains
|
||||
- [ ] **Documentation**: Update relevant documentation with changes
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
inherits: base_context.mdc
|
||||
alwaysApply: false
|
||||
---
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Meta-Rule: Core Always-On Rules
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
@@ -294,9 +293,6 @@ or context. They form the foundation for all AI assistant behavior.
|
||||
**See also**:
|
||||
|
||||
- `.cursor/rules/meta_feature_planning.mdc` for workflow-specific rules
|
||||
- `.cursor/rules/meta_bug_diagnosis.mdc` for investigation workflows
|
||||
- `.cursor/rules/meta_bug_fixing.mdc` for fix implementation
|
||||
- `.cursor/rules/meta_feature_implementation.mdc` for feature development
|
||||
|
||||
**Status**: Active core always-on meta-rule
|
||||
**Priority**: Critical (applies to every prompt)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**Status**: 🎯 **ACTIVE** - Version control guidelines
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 0) let the developer control git
|
||||
### 1) Version-Control Ownership
|
||||
|
||||
- **MUST NOT** run `git add`, `git commit`, or any write action.
|
||||
|
||||
@@ -6,10 +6,13 @@ 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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,6 +16,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*
|
||||
|
||||
45
.husky/_/husky.sh
Executable file → Normal file
45
.husky/_/husky.sh
Executable file → Normal file
@@ -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
|
||||
"
|
||||
@@ -9,6 +9,10 @@ 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,16 +22,47 @@ 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..."
|
||||
bash ./scripts/build-arch-guard.sh --staged || {
|
||||
echo
|
||||
echo "❌ Build Architecture Guard failed. Please fix the issues and try again."
|
||||
echo "💡 To bypass this check for emergency commits, use:"
|
||||
echo " git commit --no-verify"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
#echo "🏗️ Running Build Architecture Guard..."
|
||||
#bash ./scripts/build-arch-guard.sh --staged || {
|
||||
# echo
|
||||
# echo "❌ Build Architecture Guard failed. Please fix the issues and try again."
|
||||
# echo "💡 To bypass this check for emergency commits, use:"
|
||||
# echo " git commit --no-verify"
|
||||
# echo
|
||||
# exit 1
|
||||
#}
|
||||
|
||||
echo "✅ All pre-commit checks passed!"
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ else
|
||||
RANGE="HEAD~1..HEAD"
|
||||
fi
|
||||
|
||||
bash ./scripts/build-arch-guard.sh --range "$RANGE" || {
|
||||
echo
|
||||
echo "💡 To bypass this check for emergency pushes, use:"
|
||||
echo " git push --no-verify"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
#bash ./scripts/build-arch-guard.sh --range "$RANGE" || {
|
||||
# echo
|
||||
# echo "💡 To bypass this check for emergency pushes, use:"
|
||||
# echo " git push --no-verify"
|
||||
# echo
|
||||
# exit 1
|
||||
#}
|
||||
|
||||
243
BUILDING.md
243
BUILDING.md
@@ -175,27 +175,6 @@ cp .env.example .env.development
|
||||
|
||||
### Troubleshooting Quick Fixes
|
||||
|
||||
#### Common Issues
|
||||
|
||||
```bash
|
||||
# Clean and rebuild
|
||||
npm run clean:all
|
||||
npm install
|
||||
npm run build:web:dev
|
||||
|
||||
# Reset mobile projects
|
||||
npm run clean:ios
|
||||
npm run clean:android
|
||||
npm run build:ios # Regenerates iOS project
|
||||
npm run build:android # Regenerates Android project
|
||||
|
||||
# Fix Android asset issues
|
||||
npm run assets:validate:android # Validates and regenerates missing Android assets
|
||||
|
||||
# Check environment
|
||||
npm run test:web # Verifies web setup
|
||||
```
|
||||
|
||||
#### Platform-Specific Issues
|
||||
|
||||
- **iOS**: Ensure Xcode and Command Line Tools are installed
|
||||
@@ -217,7 +196,7 @@ npm run test:web # Verifies web setup
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- Git
|
||||
- For mobile builds: Xcode (macOS) or Android Studio
|
||||
- For mobile builds: Xcode (macOS) or Android Studio (or Android SDK Command Line Tools for Android emulator only; see [Android Emulator Without Android Studio](#android-emulator-without-android-studio-command-line-only))
|
||||
- For desktop builds: Additional build tools based on your OS
|
||||
|
||||
## Forks
|
||||
@@ -385,20 +364,19 @@ rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safa
|
||||
|
||||
(Note: The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.)
|
||||
|
||||
- For prod, get on the server and run the correct build:
|
||||
- For prod, you can do the same with `build:web:prod` instead.
|
||||
|
||||
... and log onto the server:
|
||||
Here are instructions directly on the server, but the build step can stay on "rendering chunks" for a long time and it basically hangs any other access to the server. In fact, last time it was killed: "Failed after 482 seconds (exit code: 137)" Maybe use `nice`?
|
||||
|
||||
- `pkgx +npm sh`
|
||||
|
||||
- `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout
|
||||
1.0.2 && npm install && npm run build:web:prod && cd -`
|
||||
- `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 1.0.2 && npm install && npm run build:web:prod && cd -`
|
||||
|
||||
(The plain `npm run build:web:prod` uses the .env.production file.)
|
||||
|
||||
- Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev-2 && mv crowd-funder-for-time-pwa/dist time-safari/`
|
||||
|
||||
- Record the new hash in the changelog. Edit package.json to increment version &
|
||||
Be sure to record the new hash in the changelog. Edit package.json to increment version &
|
||||
add "-beta", `npm install`, commit, and push. Also record what version is on production.
|
||||
|
||||
## Docker Deployment
|
||||
@@ -1069,7 +1047,7 @@ npx cap sync electron
|
||||
- Package integrity verification
|
||||
- Rollback capabilities
|
||||
|
||||
For detailed documentation, see [docs/electron-build-patterns.md](docs/electron-build-patterns.md).
|
||||
For detailed documentation, see [doc/electron-build-patterns.md](doc/electron-build-patterns.md).
|
||||
|
||||
## Mobile Builds (Capacitor)
|
||||
|
||||
@@ -1142,37 +1120,38 @@ If you need to build manually or want to understand the individual steps:
|
||||
|
||||
- Generate certificates inside XCode.
|
||||
- Right-click on App and under Signing & Capabilities set the Team.
|
||||
- In the App Developer setup (eg. https://developer.apple.com/account), under Identifiers and/or "Certificates, Identifiers & Profiles"
|
||||
|
||||
#### Each Release
|
||||
|
||||
##### 0. First time (or if dependencies change)
|
||||
|
||||
- `pkgx +rubygems.org sh`
|
||||
- `pkgx +rubygems.org zsh`
|
||||
|
||||
- ... and you may have to fix these, especially with pkgx:
|
||||
|
||||
```bash
|
||||
gem_path=$(which gem)
|
||||
shortened_path="${gem_path:h:h}"
|
||||
export GEM_HOME=$shortened_path
|
||||
export GEM_PATH=$shortened_path
|
||||
```
|
||||
```bash
|
||||
gem_path=$(which gem)
|
||||
shortened_path="${gem_path:h:h}"
|
||||
export GEM_HOME=$shortened_path
|
||||
export GEM_PATH=$shortened_path
|
||||
```
|
||||
|
||||
##### 1. Bump the version in package.json, then here
|
||||
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
||||
|
||||
```bash
|
||||
cd ios/App && xcrun agvtool new-version 40 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.0.7;/g" App.xcodeproj/project.pbxproj && cd -
|
||||
# Unfortunately this edits Info.plist directly.
|
||||
#xcrun agvtool new-marketing-version 0.4.5
|
||||
```
|
||||
```bash
|
||||
cd ios/App && xcrun agvtool new-version 65 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.3.8;/g" App.xcodeproj/project.pbxproj && cd -
|
||||
# Unfortunately this edits Info.plist directly.
|
||||
#xcrun agvtool new-marketing-version 0.4.5
|
||||
```
|
||||
|
||||
##### 2. Build
|
||||
|
||||
Here's prod. Also available: test, dev
|
||||
Here's prod. Also available: test, dev
|
||||
|
||||
```bash
|
||||
npm run build:ios:prod
|
||||
```
|
||||
```bash
|
||||
npm run build:ios:prod
|
||||
```
|
||||
|
||||
3.1. Use Xcode to build and run on simulator or device.
|
||||
|
||||
@@ -1197,11 +1176,133 @@ If you need to build manually or want to understand the individual steps:
|
||||
- It can take 15 minutes for the build to show up in the list of builds.
|
||||
- You'll probably have to "Manage" something about encryption, disallowed in France.
|
||||
- Then "Save" and "Add to Review" and "Resubmit to App Review".
|
||||
- Eventually it'll be "Ready for Distribution" which means
|
||||
- Eventually it'll be "Ready for Distribution" which means it's live
|
||||
- When finished, bump package.json version
|
||||
|
||||
### Android Build
|
||||
|
||||
Prerequisites: Android Studio with Java SDK installed
|
||||
Prerequisites: Android Studio with Java SDK installed (or **Android SDK Command Line Tools** only — see [Android Emulator Without Android Studio](#android-emulator-without-android-studio-command-line-only) below).
|
||||
|
||||
#### Android Emulator Without Android Studio (Command-Line Only)
|
||||
|
||||
You can build and run the app on an Android emulator using only the **Android SDK Command Line Tools** (no Android Studio). The project uses **API 36** (see `android/variables.gradle`: `compileSdkVersion` / `targetSdkVersion`).
|
||||
|
||||
##### 1. Environment
|
||||
|
||||
Set your SDK location and PATH (e.g. in `~/.zshrc` or `~/.bashrc`):
|
||||
|
||||
```bash
|
||||
# macOS default SDK location
|
||||
export ANDROID_HOME=$HOME/Library/Android/sdk
|
||||
# or: export ANDROID_HOME=$HOME/Android/Sdk
|
||||
|
||||
export PATH=$PATH:$ANDROID_HOME/emulator
|
||||
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 (e.g. `source ~/.zshrc`), then verify:
|
||||
|
||||
```bash
|
||||
adb version
|
||||
emulator -version
|
||||
avdmanager list
|
||||
```
|
||||
|
||||
##### 2. Install SDK components
|
||||
|
||||
Install platform tools, build tools, platform, and emulator:
|
||||
|
||||
```bash
|
||||
sdkmanager "platform-tools"
|
||||
sdkmanager "build-tools;34.0.0"
|
||||
sdkmanager "platforms;android-36"
|
||||
sdkmanager "emulator"
|
||||
```
|
||||
|
||||
##### 3. Install system image and create AVD
|
||||
|
||||
**Mac Silicon (Apple M1/M2/M3)** — use **ARM64** for native performance:
|
||||
|
||||
```bash
|
||||
# System image (API 36 matches the project)
|
||||
sdkmanager "system-images;android-36;google_apis;arm64-v8a"
|
||||
|
||||
# Create AVD
|
||||
avdmanager create avd \
|
||||
--name "TimeSafari_Emulator" \
|
||||
--package "system-images;android-36;google_apis;arm64-v8a" \
|
||||
--device "pixel_7"
|
||||
```
|
||||
|
||||
**Intel Mac (x86_64):**
|
||||
|
||||
```bash
|
||||
sdkmanager "system-images;android-36;google_apis;x86_64"
|
||||
|
||||
avdmanager create avd \
|
||||
--name "TimeSafari_Emulator" \
|
||||
--package "system-images;android-36;google_apis;x86_64" \
|
||||
--device "pixel_7"
|
||||
```
|
||||
|
||||
List AVDs: `avdmanager list avd`
|
||||
|
||||
##### 4. Start the emulator
|
||||
|
||||
```bash
|
||||
# Start in background (Mac Silicon or Intel)
|
||||
emulator -avd TimeSafari_Emulator -gpu host -no-audio &
|
||||
|
||||
# Optional: wait until booted
|
||||
adb wait-for-device
|
||||
while [ "$(adb shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do sleep 2; done
|
||||
```
|
||||
|
||||
If you have limited RAM, use reduced resources:
|
||||
|
||||
```bash
|
||||
emulator -avd TimeSafari_Emulator -no-audio -memory 2048 -cores 2 -gpu swiftshader_indirect &
|
||||
```
|
||||
|
||||
Check device: `adb devices`
|
||||
|
||||
##### 5. Build the app
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
npm run build:android
|
||||
# or: npm run build:android:debug
|
||||
```
|
||||
|
||||
The debug APK is produced at:
|
||||
`android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
##### 6. Install and launch on the emulator
|
||||
|
||||
With the emulator running:
|
||||
|
||||
```bash
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
adb shell am start -n app.timesafari.app/app.timesafari.MainActivity
|
||||
```
|
||||
|
||||
##### One-shot build and run
|
||||
|
||||
To build and run in one go (emulator or device must already be running):
|
||||
|
||||
```bash
|
||||
npm run build:android:debug:run # debug build, install, launch
|
||||
# or
|
||||
npm run build:android:test:run # test env build, install, launch
|
||||
```
|
||||
|
||||
##### Reference
|
||||
|
||||
- Emulator troubleshooting and options: [doc/android-emulator-deployment-guide.md](doc/android-emulator-deployment-guide.md)
|
||||
- **Physical device testing**: [doc/android-physical-device-guide.md](doc/android-physical-device-guide.md)
|
||||
|
||||
#### Android Build Commands
|
||||
|
||||
@@ -1303,8 +1404,8 @@ The recommended way to build for Android is using the automated build script:
|
||||
# Standard build and open Android Studio
|
||||
./scripts/build-android.sh
|
||||
|
||||
# Build with specific version numbers
|
||||
./scripts/build-android.sh --version 1.0.3 --build-number 35
|
||||
# Build with specific version numbers -- doesn't change source files
|
||||
#./scripts/build-android.sh --version 1.1.3 --build-number 48
|
||||
|
||||
# Build without opening Android Studio (for CI/CD)
|
||||
./scripts/build-android.sh --no-studio
|
||||
@@ -1315,26 +1416,26 @@ The recommended way to build for Android is using the automated build script:
|
||||
|
||||
#### Android Manual Build Process
|
||||
|
||||
##### 1. Bump the version in package.json, then here: android/app/build.gradle
|
||||
##### 1. Bump the version in package.json, then update these versions & run:
|
||||
|
||||
```bash
|
||||
perl -p -i -e 's/versionCode .*/versionCode 40/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionName .*/versionName "1.0.7"/g' android/app/build.gradle
|
||||
```
|
||||
```bash
|
||||
perl -p -i -e 's/versionCode .*/versionCode 65/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionName .*/versionName "1.3.8"/g' android/app/build.gradle
|
||||
```
|
||||
|
||||
##### 2. Build
|
||||
|
||||
Here's prod. Also available: test, dev
|
||||
|
||||
```bash
|
||||
npm run build:android:prod
|
||||
```
|
||||
```bash
|
||||
npm run build:android:prod
|
||||
```
|
||||
|
||||
##### 3. Open the project in Android Studio
|
||||
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
|
||||
##### 4. Use Android Studio to build and run on emulator or device
|
||||
|
||||
@@ -1379,6 +1480,8 @@ At play.google.com/console:
|
||||
- Note that if you add testers, you have to go to "Publishing Overview" and send
|
||||
those changes or your (closed) testers won't see it.
|
||||
|
||||
- When finished, bump package.json version
|
||||
|
||||
### Capacitor Operations
|
||||
|
||||
```bash
|
||||
@@ -1706,11 +1809,13 @@ npm run build:android:assets
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Electron Build Patterns](docs/electron-build-patterns.md)
|
||||
- [iOS Build Scripts](docs/ios-build-scripts.md)
|
||||
- [Android Build Scripts](docs/android-build-scripts.md)
|
||||
- [Web Build Scripts](docs/web-build-scripts.md)
|
||||
- [Build Troubleshooting](docs/build-troubleshooting.md)
|
||||
- [Electron Build Patterns](doc/electron-build-patterns.md)
|
||||
- [iOS Build Scripts](doc/ios-build-scripts.md)
|
||||
- [Android Build Scripts](doc/android-build-scripts.md)
|
||||
- [Android Physical Device Guide](doc/android-physical-device-guide.md)
|
||||
- [Android Emulator Deployment Guide](doc/android-emulator-deployment-guide.md)
|
||||
- [Web Build Scripts](doc/web-build-scripts.md)
|
||||
- [Build Troubleshooting](doc/build-troubleshooting.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -2333,7 +2438,7 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@nostr/tools': path.resolve(__dirname, 'node_modules/@nostr/tools'),
|
||||
'nostr-tools': path.resolve(__dirname, 'node_modules/nostr-tools'),
|
||||
'path': path.resolve(__dirname, './src/utils/node-modules/path.js'),
|
||||
'fs': path.resolve(__dirname, './src/utils/node-modules/fs.js'),
|
||||
'crypto': path.resolve(__dirname, './src/utils/node-modules/crypto.js'),
|
||||
@@ -2342,7 +2447,7 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'@nostr/tools',
|
||||
'nostr-tools',
|
||||
'@jlongster/sql.js',
|
||||
'absurd-sql',
|
||||
// ... additional dependencies
|
||||
@@ -2367,7 +2472,7 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
|
||||
**Path Aliases**:
|
||||
|
||||
- `@`: Points to `src/` directory
|
||||
- `@nostr/tools`: Nostr tools library
|
||||
- `nostr-tools`: Nostr tools library
|
||||
- `path`, `fs`, `crypto`: Node.js polyfills for browser
|
||||
|
||||
### B.2 vite.config.web.mts
|
||||
@@ -2507,7 +2612,7 @@ export default defineConfig(async () => {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ["vue", "vue-router", "@vueuse/core"],
|
||||
crypto: ["@nostr/tools", "crypto-js"],
|
||||
crypto: ["nostr-tools", "crypto-js"],
|
||||
ui: ["@fortawesome/vue-fontawesome"]
|
||||
}
|
||||
}
|
||||
|
||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -5,6 +5,84 @@ 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.3.8] - 2026
|
||||
### Added
|
||||
- Device wake-up for notifications
|
||||
|
||||
|
||||
## [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
|
||||
|
||||
852
CODE_QUALITY_DEEP_ANALYSIS.md
Normal file
852
CODE_QUALITY_DEEP_ANALYSIS.md
Normal file
@@ -0,0 +1,852 @@
|
||||
# TimeSafari Code Quality: Comprehensive Deep Analysis
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Date**: Tue Sep 16 05:22:10 AM UTC 2025
|
||||
**Status**: 🎯 **COMPREHENSIVE ANALYSIS** - Complete code quality assessment with actionable recommendations
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The TimeSafari codebase demonstrates **exceptional code quality** with mature patterns, minimal technical debt, and excellent separation of concerns. This comprehensive analysis covers **291 source files** totaling **104,527 lines** of code, including detailed examination of **94 Vue components and views**.
|
||||
|
||||
**Key Quality Metrics:**
|
||||
- **Technical Debt**: Extremely low (6 TODO/FIXME comments across entire codebase)
|
||||
- **Database Migration**: 99.5% complete (1 remaining legacy import)
|
||||
- **File Complexity**: High variance (largest file: 2,215 lines)
|
||||
- **Type Safety**: Mixed patterns (41 "as any" assertions in Vue files, 62 total)
|
||||
- **Error Handling**: Comprehensive (367 catch blocks with good coverage)
|
||||
- **Architecture**: Consistent Vue 3 Composition API with TypeScript
|
||||
|
||||
## Vue Components & Views Analysis (94 Files)
|
||||
|
||||
### Component Analysis (40 Components)
|
||||
|
||||
#### Component Size Distribution
|
||||
```
|
||||
Large Components (>500 lines): 5 components (12.5%)
|
||||
├── ImageMethodDialog.vue (947 lines) 🔴 CRITICAL
|
||||
├── GiftedDialog.vue (670 lines) ⚠️ HIGH PRIORITY
|
||||
├── PhotoDialog.vue (669 lines) ⚠️ HIGH PRIORITY
|
||||
├── PushNotificationPermission.vue (660 lines) ⚠️ HIGH PRIORITY
|
||||
└── MeetingMembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
|
||||
|
||||
Medium Components (200-500 lines): 12 components (30%)
|
||||
├── GiftDetailsStep.vue (450 lines)
|
||||
├── EntityGrid.vue (348 lines)
|
||||
├── ActivityListItem.vue (334 lines)
|
||||
├── OfferDialog.vue (327 lines)
|
||||
├── OnboardingDialog.vue (314 lines)
|
||||
├── EntitySelectionStep.vue (313 lines)
|
||||
├── GiftedPrompts.vue (293 lines)
|
||||
├── ChoiceButtonDialog.vue (250 lines)
|
||||
├── DataExportSection.vue (251 lines)
|
||||
├── AmountInput.vue (224 lines)
|
||||
├── HiddenDidDialog.vue (220 lines)
|
||||
└── FeedFilters.vue (218 lines)
|
||||
|
||||
Small Components (<200 lines): 23 components (57.5%)
|
||||
├── ContactListItem.vue (217 lines)
|
||||
├── EntitySummaryButton.vue (202 lines)
|
||||
├── IdentitySection.vue (186 lines)
|
||||
├── ContactInputForm.vue (173 lines)
|
||||
├── SpecialEntityCard.vue (156 lines)
|
||||
├── RegistrationNotice.vue (154 lines)
|
||||
├── ContactNameDialog.vue (154 lines)
|
||||
├── PersonCard.vue (153 lines)
|
||||
├── UserNameDialog.vue (147 lines)
|
||||
├── InfiniteScroll.vue (132 lines)
|
||||
├── LocationSearchSection.vue (124 lines)
|
||||
├── UsageLimitsSection.vue (123 lines)
|
||||
├── QuickNav.vue (118 lines)
|
||||
├── ProjectCard.vue (104 lines)
|
||||
├── ContactListHeader.vue (101 lines)
|
||||
├── TopMessage.vue (98 lines)
|
||||
├── InviteDialog.vue (95 lines)
|
||||
├── ImageViewer.vue (94 lines)
|
||||
├── EntityIcon.vue (86 lines)
|
||||
├── ShowAllCard.vue (66 lines)
|
||||
├── ContactBulkActions.vue (53 lines)
|
||||
├── ProjectIcon.vue (47 lines)
|
||||
└── LargeIdenticonModal.vue (44 lines)
|
||||
```
|
||||
|
||||
#### Critical Component Analysis
|
||||
|
||||
**1. `ImageMethodDialog.vue` (947 lines) 🔴 CRITICAL REFACTORING NEEDED**
|
||||
|
||||
**Issues Identified:**
|
||||
- **Excessive Single Responsibility**: Handles camera preview, file upload, URL input, cropping, diagnostics, and error handling
|
||||
- **Complex State Management**: 20+ reactive properties with interdependencies
|
||||
- **Mixed Concerns**: Camera API, file handling, UI state, and business logic intertwined
|
||||
- **Template Complexity**: ~300 lines of template with deeply nested conditions
|
||||
|
||||
**Refactoring Strategy:**
|
||||
```typescript
|
||||
// Current monolithic structure
|
||||
ImageMethodDialog.vue (947 lines) {
|
||||
CameraPreview: ~200 lines
|
||||
FileUpload: ~150 lines
|
||||
URLInput: ~100 lines
|
||||
CroppingInterface: ~200 lines
|
||||
DiagnosticsPanel: ~150 lines
|
||||
ErrorHandling: ~100 lines
|
||||
StateManagement: ~47 lines
|
||||
}
|
||||
|
||||
// Proposed component decomposition
|
||||
ImageMethodDialog.vue (coordinator, ~200 lines)
|
||||
├── CameraPreviewComponent.vue (~250 lines)
|
||||
├── FileUploadComponent.vue (~150 lines)
|
||||
├── URLInputComponent.vue (~100 lines)
|
||||
├── ImageCropperComponent.vue (~200 lines)
|
||||
├── DiagnosticsPanelComponent.vue (~150 lines)
|
||||
└── ImageUploadErrorHandler.vue (~100 lines)
|
||||
```
|
||||
|
||||
**2. `GiftedDialog.vue` (670 lines) ⚠️ HIGH PRIORITY**
|
||||
|
||||
**Assessment**: **GOOD** - Already partially refactored with step components extracted.
|
||||
|
||||
**3. `PhotoDialog.vue` (669 lines) ⚠️ HIGH PRIORITY**
|
||||
|
||||
**Issues**: Similar to ImageMethodDialog with significant code duplication.
|
||||
|
||||
**4. `PushNotificationPermission.vue` (660 lines) ⚠️ HIGH PRIORITY**
|
||||
|
||||
**Issues**: Complex permission logic with platform-specific code mixed together.
|
||||
|
||||
### View Analysis (54 Views)
|
||||
|
||||
#### View Size Distribution
|
||||
```
|
||||
Large Views (>1000 lines): 9 views (16.7%)
|
||||
├── AccountViewView.vue (2,215 lines) 🔴 CRITICAL
|
||||
├── HomeView.vue (1,852 lines) ⚠️ HIGH PRIORITY
|
||||
├── ProjectViewView.vue (1,479 lines) ⚠️ HIGH PRIORITY
|
||||
├── DatabaseMigration.vue (1,438 lines) ⚠️ HIGH PRIORITY
|
||||
├── ContactsView.vue (1,382 lines) ⚠️ HIGH PRIORITY
|
||||
├── TestView.vue (1,259 lines) ⚠️ MODERATE PRIORITY
|
||||
├── ClaimView.vue (1,225 lines) ⚠️ MODERATE PRIORITY
|
||||
├── NewEditProjectView.vue (957 lines) ⚠️ MODERATE PRIORITY
|
||||
└── ContactQRScanShowView.vue (929 lines) ⚠️ MODERATE PRIORITY
|
||||
|
||||
Medium Views (500-1000 lines): 8 views (14.8%)
|
||||
├── ConfirmGiftView.vue (898 lines)
|
||||
├── DiscoverView.vue (888 lines)
|
||||
├── DIDView.vue (848 lines)
|
||||
├── GiftedDetailsView.vue (840 lines)
|
||||
├── OfferDetailsView.vue (781 lines)
|
||||
├── HelpView.vue (780 lines)
|
||||
├── ProjectsView.vue (742 lines)
|
||||
└── ContactQRScanFullView.vue (701 lines)
|
||||
|
||||
Small Views (<500 lines): 37 views (68.5%)
|
||||
├── OnboardMeetingSetupView.vue (687 lines)
|
||||
├── ContactImportView.vue (568 lines)
|
||||
├── HelpNotificationsView.vue (566 lines)
|
||||
├── OnboardMeetingListView.vue (507 lines)
|
||||
├── InviteOneView.vue (475 lines)
|
||||
├── QuickActionBvcEndView.vue (442 lines)
|
||||
├── ContactAmountsView.vue (416 lines)
|
||||
├── SearchAreaView.vue (384 lines)
|
||||
├── SharedPhotoView.vue (379 lines)
|
||||
├── ContactGiftingView.vue (373 lines)
|
||||
├── ContactEditView.vue (345 lines)
|
||||
├── IdentitySwitcherView.vue (324 lines)
|
||||
├── UserProfileView.vue (323 lines)
|
||||
├── NewActivityView.vue (323 lines)
|
||||
├── QuickActionBvcBeginView.vue (303 lines)
|
||||
├── SeedBackupView.vue (292 lines)
|
||||
├── InviteOneAcceptView.vue (292 lines)
|
||||
├── ClaimCertificateView.vue (279 lines)
|
||||
├── StartView.vue (271 lines)
|
||||
├── ImportAccountView.vue (265 lines)
|
||||
├── ClaimAddRawView.vue (249 lines)
|
||||
├── OnboardMeetingMembersView.vue (247 lines)
|
||||
├── DeepLinkErrorView.vue (239 lines)
|
||||
├── ClaimReportCertificateView.vue (236 lines)
|
||||
├── DeepLinkRedirectView.vue (219 lines)
|
||||
├── ImportDerivedAccountView.vue (207 lines)
|
||||
├── ShareMyContactInfoView.vue (196 lines)
|
||||
├── RecentOffersToUserProjectsView.vue (176 lines)
|
||||
├── RecentOffersToUserView.vue (166 lines)
|
||||
├── NewEditAccountView.vue (142 lines)
|
||||
├── StatisticsView.vue (133 lines)
|
||||
├── HelpOnboardingView.vue (118 lines)
|
||||
├── LogView.vue (104 lines)
|
||||
├── NewIdentifierView.vue (97 lines)
|
||||
├── HelpNotificationTypesView.vue (73 lines)
|
||||
├── ConfirmContactView.vue (57 lines)
|
||||
└── QuickActionBvcView.vue (54 lines)
|
||||
```
|
||||
|
||||
#### Critical View Analysis
|
||||
|
||||
**1. `AccountViewView.vue` (2,215 lines) 🔴 CRITICAL REFACTORING NEEDED**
|
||||
|
||||
**Issues Identified:**
|
||||
- **Monolithic Architecture**: Handles 7 distinct concerns in single file
|
||||
- **Template Complexity**: ~750 lines of template with deeply nested conditions
|
||||
- **Method Proliferation**: 50+ methods handling disparate concerns
|
||||
- **State Management**: 25+ reactive properties without clear organization
|
||||
|
||||
**Refactoring Strategy:**
|
||||
```typescript
|
||||
// Current monolithic structure
|
||||
AccountViewView.vue (2,215 lines) {
|
||||
ProfileSection: ~400 lines
|
||||
SettingsSection: ~300 lines
|
||||
NotificationSection: ~200 lines
|
||||
ServerConfigSection: ~250 lines
|
||||
ExportImportSection: ~300 lines
|
||||
LimitsSection: ~150 lines
|
||||
MapSection: ~200 lines
|
||||
StateManagement: ~415 lines
|
||||
}
|
||||
|
||||
// Proposed component extraction
|
||||
AccountViewView.vue (coordinator, ~400 lines)
|
||||
├── ProfileManagementSection.vue (~300 lines)
|
||||
├── ServerConfigurationSection.vue (~250 lines)
|
||||
├── NotificationSettingsSection.vue (~200 lines)
|
||||
├── DataExportImportSection.vue (~300 lines)
|
||||
├── UsageLimitsDisplay.vue (~150 lines)
|
||||
├── LocationProfileSection.vue (~200 lines)
|
||||
└── AccountViewStateManager.ts (~200 lines)
|
||||
```
|
||||
|
||||
**2. `HomeView.vue` (1,852 lines) ⚠️ HIGH PRIORITY**
|
||||
|
||||
**Issues Identified:**
|
||||
- **Multiple Concerns**: Activity feed, projects, contacts, notifications in one file
|
||||
- **Complex State Management**: 20+ reactive properties with interdependencies
|
||||
- **Mixed Lifecycle Logic**: Mount, update, and destroy logic intertwined
|
||||
|
||||
**3. `ProjectViewView.vue` (1,479 lines) ⚠️ HIGH PRIORITY**
|
||||
|
||||
**Issues Identified:**
|
||||
- **Project Management Complexity**: Handles project details, members, offers, and activities
|
||||
- **Mixed Concerns**: Project data, member management, and activity feed in single view
|
||||
|
||||
### Vue Component Quality Patterns
|
||||
|
||||
#### Excellent Patterns Found:
|
||||
|
||||
**1. EntityIcon.vue (86 lines) ✅ EXCELLENT**
|
||||
```typescript
|
||||
// Clean, focused responsibility
|
||||
@Component({ name: "EntityIcon" })
|
||||
export default class EntityIcon extends Vue {
|
||||
@Prop() contact?: Contact;
|
||||
@Prop({ default: "" }) entityId!: string;
|
||||
@Prop({ default: 0 }) iconSize!: number;
|
||||
|
||||
generateIcon(): string {
|
||||
// Clear priority order: profile image → avatar → fallback
|
||||
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
|
||||
if (imageUrl) return `<img src="${imageUrl}" ... />`;
|
||||
|
||||
const identifier = this.contact?.did || this.entityId;
|
||||
if (!identifier) return `<img src="${blankSquareSvg}" ... />`;
|
||||
|
||||
return createAvatar(avataaars, { seed: identifier, size: this.iconSize }).toString();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. QuickNav.vue (118 lines) ✅ EXCELLENT**
|
||||
```typescript
|
||||
// Simple, focused navigation component
|
||||
@Component({ name: "QuickNav" })
|
||||
export default class QuickNav extends Vue {
|
||||
@Prop selected = "";
|
||||
|
||||
// Clean template with consistent patterns
|
||||
// Proper accessibility attributes
|
||||
// Responsive design with safe area handling
|
||||
}
|
||||
```
|
||||
|
||||
**3. Small Focused Views ✅ EXCELLENT**
|
||||
```typescript
|
||||
// QuickActionBvcView.vue (54 lines) - Perfect size
|
||||
// ConfirmContactView.vue (57 lines) - Focused responsibility
|
||||
// HelpNotificationTypesView.vue (73 lines) - Clear purpose
|
||||
// LogView.vue (104 lines) - Simple utility view
|
||||
```
|
||||
|
||||
#### Problematic Patterns Found:
|
||||
|
||||
**1. Excessive Props in Dialog Components**
|
||||
```typescript
|
||||
// GiftedDialog.vue - Too many props
|
||||
@Prop() fromProjectId = "";
|
||||
@Prop() toProjectId = "";
|
||||
@Prop() isFromProjectView = false;
|
||||
@Prop() hideShowAll = false;
|
||||
@Prop({ default: "person" }) giverEntityType = "person";
|
||||
@Prop({ default: "person" }) recipientEntityType = "person";
|
||||
// ... 10+ more props
|
||||
```
|
||||
|
||||
**2. Complex State Machines**
|
||||
```typescript
|
||||
// ImageMethodDialog.vue - Complex state management
|
||||
cameraState: "off" | "initializing" | "active" | "error" | "retrying" | "stopped" = "off";
|
||||
showCameraPreview = false;
|
||||
isRetrying = false;
|
||||
showDiagnostics = false;
|
||||
// ... 15+ more state properties
|
||||
```
|
||||
|
||||
**3. Excessive Reactive Properties**
|
||||
```typescript
|
||||
// AccountViewView.vue - Too many reactive properties
|
||||
downloadUrl: string = "";
|
||||
loadingLimits: boolean = false;
|
||||
loadingProfile: boolean = true;
|
||||
showAdvanced: boolean = false;
|
||||
showB64Copy: boolean = false;
|
||||
showContactGives: boolean = false;
|
||||
showDidCopy: boolean = false;
|
||||
showDerCopy: boolean = false;
|
||||
showGeneralAdvanced: boolean = false;
|
||||
showLargeIdenticonId?: string;
|
||||
showLargeIdenticonUrl?: string;
|
||||
showPubCopy: boolean = false;
|
||||
showShortcutBvc: boolean = false;
|
||||
warnIfProdServer: boolean = false;
|
||||
warnIfTestServer: boolean = false;
|
||||
zoom: number = 2;
|
||||
isMapReady: boolean = false;
|
||||
// ... 10+ more properties
|
||||
```
|
||||
|
||||
## File Size and Complexity Analysis (All Files)
|
||||
|
||||
### Problematic Large Files
|
||||
|
||||
#### 1. `AccountViewView.vue` (2,215 lines) 🔴 **CRITICAL**
|
||||
**Issues Identified:**
|
||||
- **Excessive Single File Responsibility**: Handles profile, settings, notifications, server configuration, export/import, limits checking
|
||||
- **Template Complexity**: ~750 lines of template with deeply nested conditions
|
||||
- **Method Proliferation**: 50+ methods handling disparate concerns
|
||||
- **State Management**: 25+ reactive properties without clear organization
|
||||
|
||||
#### 2. `PlatformServiceMixin.ts` (2,091 lines) ⚠️ **HIGH PRIORITY**
|
||||
**Issues Identified:**
|
||||
- **God Object Pattern**: Single file handling 80+ methods across multiple concerns
|
||||
- **Mixed Abstraction Levels**: Low-level SQL utilities mixed with high-level business logic
|
||||
- **Method Length Variance**: Some methods 100+ lines, others single-line wrappers
|
||||
|
||||
**Refactoring Strategy:**
|
||||
```typescript
|
||||
// Current monolithic mixin
|
||||
PlatformServiceMixin.ts (2,091 lines)
|
||||
|
||||
// Proposed separation of concerns
|
||||
├── CoreDatabaseMixin.ts // $db, $exec, $query, $first (200 lines)
|
||||
├── SettingsManagementMixin.ts // $settings, $saveSettings (400 lines)
|
||||
├── ContactManagementMixin.ts // $contacts, $insertContact (300 lines)
|
||||
├── EntityOperationsMixin.ts // $insertEntity, $updateEntity (400 lines)
|
||||
├── CachingMixin.ts // Cache management (150 lines)
|
||||
├── ActiveIdentityMixin.ts // Active DID management (200 lines)
|
||||
├── UtilityMixin.ts // Mapping, JSON parsing (200 lines)
|
||||
└── LoggingMixin.ts // $log, $logError (100 lines)
|
||||
```
|
||||
|
||||
#### 3. `HomeView.vue` (1,852 lines) ⚠️ **MODERATE PRIORITY**
|
||||
**Issues Identified:**
|
||||
- **Multiple Concerns**: Activity feed, projects, contacts, notifications in one file
|
||||
- **Complex State Management**: 20+ reactive properties with interdependencies
|
||||
- **Mixed Lifecycle Logic**: Mount, update, and destroy logic intertwined
|
||||
|
||||
### File Size Distribution Analysis
|
||||
```
|
||||
Files > 1000 lines: 9 files (4.6% of codebase)
|
||||
Files 500-1000 lines: 23 files (11.7% of codebase)
|
||||
Files 200-500 lines: 45 files (22.8% of codebase)
|
||||
Files < 200 lines: 120 files (60.9% of codebase)
|
||||
```
|
||||
|
||||
**Assessment**: Good distribution with most files reasonably sized, but critical outliers need attention.
|
||||
|
||||
## Type Safety Analysis
|
||||
|
||||
### Type Assertion Patterns
|
||||
|
||||
#### "as any" Usage (62 total instances) ⚠️
|
||||
|
||||
**Vue Components & Views (41 instances):**
|
||||
```typescript
|
||||
// ImageMethodDialog.vue:504
|
||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||
|
||||
// GiftedDialog.vue:228
|
||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||
|
||||
// AccountViewView.vue: Multiple instances for:
|
||||
// - PlatformServiceMixin method access
|
||||
// - Vue refs with complex typing
|
||||
// - External library integration (Leaflet)
|
||||
```
|
||||
|
||||
**Other Files (21 instances):**
|
||||
- **Vue Component References** (23 instances): `(this.$refs.dialog as any)`
|
||||
- **Platform Detection** (12 instances): `(navigator as any).standalone`
|
||||
- **External Library Integration** (15 instances): Leaflet, Axios extensions
|
||||
- **Legacy Code Compatibility** (8 instances): Temporary migration code
|
||||
- **Event Handler Workarounds** (4 instances): Vue event typing issues
|
||||
|
||||
**Example Problematic Pattern:**
|
||||
```typescript
|
||||
// src/views/AccountViewView.vue:934
|
||||
const iconDefault = L.Icon.Default.prototype as unknown as Record<string, unknown>;
|
||||
|
||||
// Better approach:
|
||||
interface LeafletIconPrototype {
|
||||
_getIconUrl?: unknown;
|
||||
}
|
||||
const iconDefault = L.Icon.Default.prototype as LeafletIconPrototype;
|
||||
```
|
||||
|
||||
#### "unknown" Type Usage (755 instances)
|
||||
**Analysis**: Generally good practice showing defensive programming, but some areas could benefit from more specific typing.
|
||||
|
||||
### Recommended Type Safety Improvements
|
||||
|
||||
1. **Create Interface Extensions**:
|
||||
```typescript
|
||||
// src/types/platform-service-mixin.ts
|
||||
interface VueWithPlatformServiceMixin extends Vue {
|
||||
$getActiveIdentity(): Promise<{ activeDid: string }>;
|
||||
$saveSettings(changes: Partial<Settings>): Promise<boolean>;
|
||||
// ... other methods
|
||||
}
|
||||
|
||||
// src/types/external.ts
|
||||
declare global {
|
||||
interface Navigator {
|
||||
standalone?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
interface VueRefWithOpen {
|
||||
open: (callback: (result?: unknown) => void) => void;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Component Ref Typing**:
|
||||
```typescript
|
||||
// Instead of: (this.$refs.dialog as any).open()
|
||||
// Use: (this.$refs.dialog as VueRefWithOpen).open()
|
||||
```
|
||||
|
||||
## Error Handling Consistency Analysis
|
||||
|
||||
### Error Handling Patterns (367 catch blocks)
|
||||
|
||||
#### Pattern Distribution:
|
||||
1. **Structured Logging** (85%): Uses logger.error with context
|
||||
2. **User Notification** (78%): Shows user-friendly error messages
|
||||
3. **Graceful Degradation** (92%): Provides fallback behavior
|
||||
4. **Error Propagation** (45%): Re-throws when appropriate
|
||||
|
||||
#### Excellent Pattern Example:
|
||||
```typescript
|
||||
// src/views/AccountViewView.vue:1617
|
||||
try {
|
||||
const response = await this.axios.delete(url, { headers });
|
||||
if (response.status === 204) {
|
||||
this.profileImageUrl = "";
|
||||
this.notify.success("Image deleted successfully.");
|
||||
}
|
||||
} catch (error) {
|
||||
if (isApiError(error) && error.response?.status === 404) {
|
||||
// Graceful handling - image already gone
|
||||
this.profileImageUrl = "";
|
||||
} else {
|
||||
this.notify.error("Failed to delete image", TIMEOUTS.STANDARD);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Areas for Improvement:
|
||||
1. **Inconsistent Error Typing**: Some catch(error: any), others catch(error: unknown)
|
||||
2. **Missing Error Boundaries**: No Vue error boundary components
|
||||
3. **Silent Failures**: 15% of catch blocks don't notify users
|
||||
|
||||
## Code Duplication Analysis
|
||||
|
||||
### Significant Duplication Patterns
|
||||
|
||||
#### 1. **Toggle Component Pattern** (12 occurrences)
|
||||
```html
|
||||
<!-- Repeated across multiple files -->
|
||||
<div class="relative ml-2 cursor-pointer" @click="toggleMethod()">
|
||||
<input v-model="property" type="checkbox" class="sr-only" />
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Solution**: Create `ToggleSwitch.vue` component with props for value, label, and change handler.
|
||||
|
||||
#### 2. **API Error Handling Pattern** (25 occurrences)
|
||||
```typescript
|
||||
try {
|
||||
const response = await this.axios.post(url, data, { headers });
|
||||
if (response.status === 200) {
|
||||
this.notify.success("Operation successful");
|
||||
}
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
this.notify.error(`Failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Create `ApiRequestMixin.ts` with standardized request/response handling.
|
||||
|
||||
#### 3. **Settings Update Pattern** (40+ occurrences)
|
||||
```typescript
|
||||
async methodName() {
|
||||
await this.$saveSettings({ property: this.newValue });
|
||||
this.property = this.newValue;
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Enhanced PlatformServiceMixin already provides `$saveSettings()` - migrate remaining manual patterns.
|
||||
|
||||
## Dependency and Coupling Analysis
|
||||
|
||||
### Import Dependency Patterns
|
||||
|
||||
#### Legacy Database Coupling (EXCELLENT)
|
||||
- **Status**: 99.5% resolved (1 remaining databaseUtil import)
|
||||
- **Remaining**: `src/views/DeepLinkErrorView.vue:import { logConsoleAndDb }`
|
||||
- **Resolution**: Replace with PlatformServiceMixin `$logAndConsole()`
|
||||
|
||||
#### Circular Dependency Status (EXCELLENT)
|
||||
- **Status**: 100% resolved, no active circular dependencies
|
||||
- **Previous Issues**: All resolved through PlatformServiceMixin architecture
|
||||
|
||||
#### Component Coupling Analysis
|
||||
```typescript
|
||||
// High coupling components (>10 imports)
|
||||
AccountViewView.vue: 15 imports (understandable given scope)
|
||||
HomeView.vue: 12 imports
|
||||
ProjectViewView.vue: 11 imports
|
||||
|
||||
// Well-isolated components (<5 imports)
|
||||
QuickActionViews: 3-4 imports each
|
||||
Component utilities: 2-3 imports each
|
||||
```
|
||||
|
||||
**Assessment**: Reasonable coupling levels with clear architectural boundaries.
|
||||
|
||||
## Console Logging Analysis (129 instances)
|
||||
|
||||
### Logging Pattern Distribution:
|
||||
1. **console.log**: 89 instances (69%)
|
||||
2. **console.warn**: 24 instances (19%)
|
||||
3. **console.error**: 16 instances (12%)
|
||||
|
||||
### Vue Components & Views Logging (3 instances):
|
||||
- **Components**: 1 console.* call
|
||||
- **Views**: 2 console.* calls
|
||||
|
||||
### Inconsistent Logging Approach:
|
||||
```typescript
|
||||
// Mixed patterns found:
|
||||
console.log("Direct console logging"); // 89 instances
|
||||
logger.debug("Structured logging"); // Preferred pattern
|
||||
this.$logAndConsole("Mixin logging"); // PlatformServiceMixin
|
||||
```
|
||||
|
||||
### Recommended Standardization:
|
||||
1. **Migration Strategy**: Replace all console.* with logger.* calls
|
||||
2. **Structured Context**: Add consistent metadata to log entries
|
||||
3. **Log Levels**: Standardize debug/info/warn/error usage
|
||||
|
||||
## Technical Debt Analysis (6 total)
|
||||
|
||||
### Components (1 TODO):
|
||||
```typescript
|
||||
// PushNotificationPermission.vue
|
||||
// TODO: secretDB functionality needs to be migrated to PlatformServiceMixin
|
||||
```
|
||||
|
||||
### Views (2 TODOs):
|
||||
```typescript
|
||||
// AccountViewView.vue
|
||||
// TODO: Implement this for SQLite
|
||||
// TODO: implement this for SQLite
|
||||
```
|
||||
|
||||
### Other Files (3 TODOs):
|
||||
```typescript
|
||||
// src/db/tables/accounts.ts
|
||||
// TODO: When finished with migration, move these fields to Account and move identity and mnemonic here.
|
||||
|
||||
// src/util.d.ts
|
||||
// TODO: , inspect: inspect
|
||||
|
||||
// src/libs/crypto/vc/passkeyHelpers.ts
|
||||
// TODO: If it's after February 2025 when you read this then consider whether it still makes sense
|
||||
```
|
||||
|
||||
**Assessment**: **EXCELLENT** - Only 6 TODO comments across 291 files.
|
||||
|
||||
## Performance Anti-Patterns
|
||||
|
||||
### Identified Issues:
|
||||
|
||||
#### 1. **Excessive Reactive Properties**
|
||||
```typescript
|
||||
// AccountViewView.vue has 25+ reactive properties
|
||||
// Many could be computed or moved to component state
|
||||
```
|
||||
|
||||
#### 2. **Inline Method Calls in Templates**
|
||||
```html
|
||||
<!-- Anti-pattern: -->
|
||||
<span>{{ readableDate(timeStr) }}</span>
|
||||
|
||||
<!-- Better: -->
|
||||
<span>{{ readableTime }}</span>
|
||||
<!-- With computed property -->
|
||||
```
|
||||
|
||||
#### 3. **Missing Key Attributes in Lists**
|
||||
```html
|
||||
<!-- Several v-for loops missing :key attributes -->
|
||||
<li v-for="item in items">
|
||||
```
|
||||
|
||||
#### 4. **Complex Template Logic**
|
||||
```html
|
||||
<!-- AccountViewView.vue - Complex nested conditions -->
|
||||
<div v-if="!activeDid" id="noticeBeforeShare" class="bg-amber-200...">
|
||||
<p class="mb-4">
|
||||
<b>Note:</b> Before you can share with others or take any action, you need an identifier.
|
||||
</p>
|
||||
<router-link :to="{ name: 'new-identifier' }" class="inline-block...">
|
||||
Create An Identifier
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Identity Details -->
|
||||
<IdentitySection
|
||||
:given-name="givenName"
|
||||
:profile-image-url="profileImageUrl"
|
||||
:active-did="activeDid"
|
||||
:is-registered="isRegistered"
|
||||
:show-large-identicon-id="showLargeIdenticonId"
|
||||
:show-large-identicon-url="showLargeIdenticonUrl"
|
||||
:show-did-copy="showDidCopy"
|
||||
@edit-name="onEditName"
|
||||
@show-qr-code="onShowQrCode"
|
||||
@add-image="onAddImage"
|
||||
@delete-image="onDeleteImage"
|
||||
@show-large-identicon-id="onShowLargeIdenticonId"
|
||||
@show-large-identicon-url="onShowLargeIdenticonUrl"
|
||||
/>
|
||||
```
|
||||
|
||||
## Specific Actionable Recommendations
|
||||
|
||||
### Priority 1: Critical File Refactoring
|
||||
|
||||
1. **Split AccountViewView.vue**:
|
||||
- **Timeline**: 2-3 sprints
|
||||
- **Strategy**: Extract 6 major sections into focused components
|
||||
- **Risk**: Medium (requires careful state management coordination)
|
||||
- **Benefit**: Massive maintainability improvement, easier testing
|
||||
|
||||
2. **Decompose ImageMethodDialog.vue**:
|
||||
- **Timeline**: 2-3 sprints
|
||||
- **Strategy**: Extract 6 focused components (camera, file upload, cropping, etc.)
|
||||
- **Risk**: Medium (complex camera state management)
|
||||
- **Benefit**: Massive maintainability improvement
|
||||
|
||||
3. **Decompose PlatformServiceMixin.ts**:
|
||||
- **Timeline**: 1-2 sprints
|
||||
- **Strategy**: Create focused mixins by concern area
|
||||
- **Risk**: Low (well-defined interfaces already exist)
|
||||
- **Benefit**: Better code organization, reduced cognitive load
|
||||
|
||||
### Priority 2: Component Extraction
|
||||
|
||||
1. **HomeView.vue** → 4 focused sections
|
||||
- **Timeline**: 1-2 sprints
|
||||
- **Risk**: Low (clear separation of concerns)
|
||||
- **Benefit**: Better code organization
|
||||
|
||||
2. **ProjectViewView.vue** → 4 focused sections
|
||||
- **Timeline**: 1-2 sprints
|
||||
- **Risk**: Low (well-defined boundaries)
|
||||
- **Benefit**: Improved maintainability
|
||||
|
||||
### Priority 3: Shared Component Creation
|
||||
|
||||
1. **CameraPreviewComponent.vue**
|
||||
- Extract from ImageMethodDialog.vue and PhotoDialog.vue
|
||||
- **Benefit**: Eliminate code duplication
|
||||
|
||||
2. **FileUploadComponent.vue**
|
||||
- Extract from ImageMethodDialog.vue and PhotoDialog.vue
|
||||
- **Benefit**: Consistent file handling
|
||||
|
||||
3. **ToggleSwitch.vue**
|
||||
- Replace 12 duplicate toggle patterns
|
||||
- **Benefit**: Consistent UI components
|
||||
|
||||
4. **DiagnosticsPanelComponent.vue**
|
||||
- Extract from ImageMethodDialog.vue
|
||||
- **Benefit**: Reusable debugging component
|
||||
|
||||
### Priority 4: Type Safety Enhancement
|
||||
|
||||
1. **Eliminate "as any" Assertions**:
|
||||
- **Timeline**: 1 sprint
|
||||
- **Strategy**: Create proper interface extensions
|
||||
- **Risk**: Low
|
||||
- **Benefit**: Better compile-time error detection
|
||||
|
||||
2. **Standardize Error Typing**:
|
||||
- **Timeline**: 0.5 sprint
|
||||
- **Strategy**: Use consistent `catch (error: unknown)` pattern
|
||||
- **Risk**: None
|
||||
- **Benefit**: Better error handling consistency
|
||||
|
||||
### Priority 5: State Management Optimization
|
||||
|
||||
1. **Create Composables for Complex State**:
|
||||
```typescript
|
||||
// src/composables/useCameraState.ts
|
||||
export function useCameraState() {
|
||||
const cameraState = ref<CameraState>("off");
|
||||
const showPreview = ref(false);
|
||||
const isRetrying = ref(false);
|
||||
|
||||
const startCamera = async () => { /* ... */ };
|
||||
const stopCamera = () => { /* ... */ };
|
||||
|
||||
return { cameraState, showPreview, isRetrying, startCamera, stopCamera };
|
||||
}
|
||||
```
|
||||
|
||||
2. **Group Related Reactive Properties**:
|
||||
```typescript
|
||||
// Instead of:
|
||||
showB64Copy: boolean = false;
|
||||
showDidCopy: boolean = false;
|
||||
showDerCopy: boolean = false;
|
||||
showPubCopy: boolean = false;
|
||||
|
||||
// Use:
|
||||
copyStates = {
|
||||
b64: false,
|
||||
did: false,
|
||||
der: false,
|
||||
pub: false
|
||||
};
|
||||
```
|
||||
|
||||
### Priority 6: Code Standardization
|
||||
|
||||
1. **Logging Standardization**:
|
||||
- **Timeline**: 1 sprint
|
||||
- **Strategy**: Replace all console.* with logger.*
|
||||
- **Risk**: None
|
||||
- **Benefit**: Consistent logging, better debugging
|
||||
|
||||
2. **Template Optimization**:
|
||||
- Add missing `:key` attributes
|
||||
- Convert inline method calls to computed properties
|
||||
- Implement virtual scrolling for large lists
|
||||
|
||||
## Quality Metrics Summary
|
||||
|
||||
### Vue Component Quality Distribution:
|
||||
| Size Category | Count | Percentage | Quality Assessment |
|
||||
|---------------|-------|------------|-------------------|
|
||||
| Large (>500 lines) | 5 | 12.5% | 🔴 Needs Refactoring |
|
||||
| Medium (200-500 lines) | 12 | 30% | 🟡 Good with Minor Issues |
|
||||
| Small (<200 lines) | 23 | 57.5% | 🟢 Excellent |
|
||||
|
||||
### Vue View Quality Distribution:
|
||||
| Size Category | Count | Percentage | Quality Assessment |
|
||||
|---------------|-------|------------|-------------------|
|
||||
| Large (>1000 lines) | 9 | 16.7% | 🔴 Needs Refactoring |
|
||||
| Medium (500-1000 lines) | 8 | 14.8% | 🟡 Good with Minor Issues |
|
||||
| Small (<500 lines) | 37 | 68.5% | 🟢 Excellent |
|
||||
|
||||
### Overall Quality Metrics:
|
||||
| Metric | Components | Views | Overall Assessment |
|
||||
|--------|------------|-------|-------------------|
|
||||
| Technical Debt | 1 TODO | 2 TODOs | 🟢 Excellent |
|
||||
| Type Safety | 6 "as any" | 35 "as any" | 🟡 Good |
|
||||
| Console Logging | 1 instance | 2 instances | 🟢 Excellent |
|
||||
| Architecture Consistency | 100% | 100% | 🟢 Excellent |
|
||||
| Component Reuse | High | High | 🟢 Excellent |
|
||||
|
||||
### Before vs. Target State:
|
||||
| Metric | Current | Target | Status |
|
||||
|--------|---------|---------|---------|
|
||||
| Files >1000 lines | 9 files | 3 files | 🟡 Needs Work |
|
||||
| "as any" assertions | 62 | 15 | 🟡 Moderate |
|
||||
| Console.* calls | 129 | 0 | 🔴 Needs Work |
|
||||
| Component reuse | 40% | 75% | 🟡 Moderate |
|
||||
| Error consistency | 85% | 95% | 🟢 Good |
|
||||
| Type coverage | 88% | 95% | 🟢 Good |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Low Risk Improvements (High Impact):
|
||||
- Logging standardization
|
||||
- Type assertion cleanup
|
||||
- Missing key attributes
|
||||
- Component extraction from AccountViewView.vue
|
||||
- Shared component creation (ToggleSwitch, CameraPreview)
|
||||
|
||||
### Medium Risk Improvements:
|
||||
- PlatformServiceMixin decomposition
|
||||
- State management optimization
|
||||
- ImageMethodDialog decomposition
|
||||
|
||||
### High Risk Items:
|
||||
- None identified - project demonstrates excellent architectural discipline
|
||||
|
||||
## Conclusion
|
||||
|
||||
The TimeSafari codebase demonstrates **exceptional code quality** with:
|
||||
|
||||
**Key Strengths:**
|
||||
- **Consistent Architecture**: 100% Vue 3 Composition API with TypeScript
|
||||
- **Minimal Technical Debt**: Only 6 TODO comments across 291 files
|
||||
- **Excellent Small Components**: 68.5% of views and 57.5% of components are well-sized
|
||||
- **Strong Type Safety**: Minimal "as any" usage, mostly justified
|
||||
- **Clean Logging**: Minimal console.* usage, structured logging preferred
|
||||
- **Excellent Database Migration**: 99.5% complete
|
||||
- **Comprehensive Error Handling**: 367 catch blocks with good coverage
|
||||
- **No Circular Dependencies**: 100% resolved
|
||||
|
||||
**Primary Focus Areas:**
|
||||
1. **Decompose Large Files**: 5 components and 9 views need refactoring
|
||||
2. **Extract Shared Components**: Camera, file upload, and diagnostics components
|
||||
3. **Optimize State Management**: Group related properties and create composables
|
||||
4. **Improve Type Safety**: Create proper interface extensions for mixin methods
|
||||
5. **Logging Standardization**: Replace 129 console.* calls with structured logger.*
|
||||
|
||||
**The component architecture is production-ready** with these improvements representing **strategic optimization** rather than critical fixes. The codebase demonstrates **mature Vue.js development practices** with excellent separation of concerns and consistent patterns.
|
||||
|
||||
---
|
||||
|
||||
**Investigation Methodology:**
|
||||
- Static analysis of 291 source files (197 general + 94 Vue components/views)
|
||||
- Pattern recognition across 104,527 lines of code
|
||||
- Manual review of large files and complexity patterns
|
||||
- Dependency analysis and coupling assessment
|
||||
- Performance anti-pattern identification
|
||||
- Architecture consistency evaluation
|
||||
13
README.md
13
README.md
@@ -15,7 +15,7 @@ Quick start:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build:web:serve -- --test
|
||||
npm run build:web:dev
|
||||
```
|
||||
|
||||
To be able to take action on the platform: go to [the test page](http://localhost:8080/test) and click "Become User 0".
|
||||
@@ -68,16 +68,16 @@ TimeSafari supports configurable logging levels via the `VITE_LOG_LEVEL` environ
|
||||
|
||||
```bash
|
||||
# Show only errors
|
||||
VITE_LOG_LEVEL=error npm run dev
|
||||
VITE_LOG_LEVEL=error npm run build:web:dev
|
||||
|
||||
# Show warnings and errors
|
||||
VITE_LOG_LEVEL=warn npm run dev
|
||||
VITE_LOG_LEVEL=warn npm run build:web:dev
|
||||
|
||||
# Show info, warnings, and errors (default)
|
||||
VITE_LOG_LEVEL=info npm run dev
|
||||
VITE_LOG_LEVEL=info npm run build:web:dev
|
||||
|
||||
# Show all log levels including debug
|
||||
VITE_LOG_LEVEL=debug npm run dev
|
||||
VITE_LOG_LEVEL=debug npm run build:web:dev
|
||||
```
|
||||
|
||||
### Available Levels
|
||||
@@ -279,13 +279,11 @@ 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.
|
||||
@@ -308,7 +306,6 @@ timesafari/
|
||||
## 🤝 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
|
||||
|
||||
|
||||
@@ -27,12 +27,18 @@ if (!project.ext.MY_KEYSTORE_FILE) {
|
||||
android {
|
||||
namespace 'app.timesafari'
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "app.timesafari.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 40
|
||||
versionName "1.0.7"
|
||||
versionCode 65
|
||||
versionName "1.3.8"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -101,6 +107,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"
|
||||
|
||||
@@ -18,6 +18,7 @@ dependencies {
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capawesome-capacitor-file-picker')
|
||||
implementation project(':timesafari-daily-notification-plugin')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?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: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 +29,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 +98,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>
|
||||
|
||||
@@ -42,6 +42,31 @@
|
||||
"biometricTitle": "Biometric login for TimeSafari"
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -34,5 +34,17 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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,21 @@ import android.webkit.WebSettings;
|
||||
import android.webkit.WebViewClient;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import app.timesafari.safearea.SafeAreaPlugin;
|
||||
import app.timesafari.sharedimage.SharedImagePlugin;
|
||||
//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";
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -48,9 +64,160 @@ public class MainActivity extends BridgeActivity {
|
||||
// Register SafeArea plugin
|
||||
registerPlugin(SafeAreaPlugin.class);
|
||||
|
||||
// Register SharedImage plugin
|
||||
registerPlugin(SharedImagePlugin.class);
|
||||
|
||||
// Register DailyNotification plugin
|
||||
// Plugin is written in Kotlin but compiles to Java-compatible bytecode
|
||||
registerPlugin(org.timesafari.dailynotification.DailyNotificationPlugin.class);
|
||||
|
||||
// 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);
|
||||
processSharedImage(imageUri, fileName);
|
||||
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()) {
|
||||
processSharedImage(imageUris.get(0), null);
|
||||
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 to temp file
|
||||
* Uses try-with-resources to ensure proper stream cleanup and prevent network issues
|
||||
*/
|
||||
private void 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;
|
||||
}
|
||||
|
||||
// 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
|
||||
storeSharedImageInPreferences(base64String, actualFileName);
|
||||
|
||||
Log.d(TAG, "Successfully processed shared image: " + actualFileName);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Error processing shared image", e);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Unexpected error processing shared image", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store shared image data in SharedPreferences for plugin to read
|
||||
* Plugin will read and clear the data when called
|
||||
*/
|
||||
private void storeSharedImageInPreferences(String base64, String fileName) {
|
||||
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");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error storing shared image in SharedPreferences", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,73 @@
|
||||
package app.timesafari;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.timesafari.dailynotification.FetchContext;
|
||||
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||
import org.timesafari.dailynotification.NotificationContent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
|
||||
|
||||
private static final String TAG = "TimeSafariNativeFetcher";
|
||||
private final Context context;
|
||||
|
||||
// Configuration from TypeScript (set via configure())
|
||||
private volatile String apiBaseUrl;
|
||||
private volatile String activeDid;
|
||||
private volatile String jwtToken;
|
||||
|
||||
public TimeSafariNativeFetcher(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(String apiBaseUrl, String activeDid, String jwtToken) {
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
this.activeDid = activeDid;
|
||||
this.jwtToken = jwtToken;
|
||||
Log.i(TAG, "Fetcher configured with API: " + apiBaseUrl + ", DID: " + activeDid);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public CompletableFuture<List<NotificationContent>> fetchContent(@NonNull FetchContext fetchContext) {
|
||||
Log.d(TAG, "Fetching notification content, trigger: " + fetchContext.trigger);
|
||||
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
// TODO: Implement actual content fetching for TimeSafari
|
||||
// This should query the TimeSafari API for notification content
|
||||
// using the configured apiBaseUrl, activeDid, and jwtToken
|
||||
|
||||
// For now, return a placeholder notification
|
||||
long scheduledTime = fetchContext.scheduledTime != null
|
||||
? fetchContext.scheduledTime
|
||||
: System.currentTimeMillis() + 60000; // 1 minute from now
|
||||
|
||||
NotificationContent content = new NotificationContent(
|
||||
"TimeSafari Update",
|
||||
"Check your starred projects for updates!",
|
||||
scheduledTime
|
||||
);
|
||||
|
||||
List<NotificationContent> results = new ArrayList<>();
|
||||
results.add(content);
|
||||
|
||||
Log.d(TAG, "Returning " + results.size() + " notification(s)");
|
||||
return results;
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Fetch failed", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
12
android/app/src/main/res/xml/network_security_config.xml
Normal file
12
android/app/src/main/res/xml/network_security_config.xml
Normal file
@@ -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) {
|
||||
|
||||
@@ -28,3 +28,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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ext {
|
||||
minSdkVersion = 22
|
||||
minSdkVersion = 23
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.8.0'
|
||||
|
||||
@@ -44,6 +44,31 @@ const config: CapacitorConfig = {
|
||||
biometricTitle: 'Biometric login for TimeSafari'
|
||||
},
|
||||
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: {
|
||||
|
||||
122
doc/DAILY_NOTIFICATION_BUG_DIAGNOSIS.md
Normal file
122
doc/DAILY_NOTIFICATION_BUG_DIAGNOSIS.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# 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 **different** clone (e.g. `daily-notification-plugin_test`) and never pushed to that gitea `master`, then:
|
||||
|
||||
- `npm install` / `npm update` in the app would not pull the fixes.
|
||||
- The app’s `node_modules` would only have the fixes if they were copied/linked from the fixed repo.
|
||||
|
||||
**Do this:**
|
||||
|
||||
- If the fixes live in another clone: either **push** the fixed plugin to gitea `master` and run `npm update @timesafari/daily-notification-plugin` (then `npx cap sync android`, then clean build), **or** point the app at the fixed plugin locally, e.g. in **app** `package.json`:
|
||||
- `"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin"`
|
||||
(adjust path to your fixed plugin repo), then `npm install`, `npx cap sync android`, clean build and reinstall.
|
||||
|
||||
### 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.
|
||||
169
doc/DAILY_NOTIFICATION_DUPLICATE_FALLBACK_ANALYSIS.md
Normal file
169
doc/DAILY_NOTIFICATION_DUPLICATE_FALLBACK_ANALYSIS.md
Normal file
@@ -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`).
|
||||
82
doc/NOTIFICATION_TROUBLESHOOTING.md
Normal file
82
doc/NOTIFICATION_TROUBLESHOOTING.md
Normal file
@@ -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**.
|
||||
|
||||
259
doc/android-api-23-upgrade-impact-analysis.md
Normal file
259
doc/android-api-23-upgrade-impact-analysis.md
Normal file
@@ -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).
|
||||
|
||||
85
doc/android-daily-notification-second-schedule-issue.md
Normal file
85
doc/android-daily-notification-second-schedule-issue.md
Normal file
@@ -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.
|
||||
655
doc/android-emulator-deployment-guide.md
Normal file
655
doc/android-emulator-deployment-guide.md
Normal file
@@ -0,0 +1,655 @@
|
||||
# Android Emulator Deployment Guide (No Android Studio)
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Date**: 2025-01-27
|
||||
**Status**: 🎯 **ACTIVE** - Complete guide for deploying TimeSafari to Android emulator using command-line tools
|
||||
|
||||
## Overview
|
||||
|
||||
This guide provides comprehensive instructions for building and deploying TimeSafari to Android emulators using only command-line tools, without requiring Android Studio. It leverages the existing build system and adds emulator-specific deployment workflows.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required Tools
|
||||
|
||||
1. **Android SDK Command Line Tools**
|
||||
```bash
|
||||
# Install via package manager (Arch Linux)
|
||||
sudo pacman -S android-sdk-cmdline-tools-latest
|
||||
|
||||
# Or download from Google
|
||||
# https://developer.android.com/studio/command-line
|
||||
```
|
||||
|
||||
2. **Android SDK Platform Tools**
|
||||
```bash
|
||||
# Install via package manager
|
||||
sudo pacman -S android-sdk-platform-tools
|
||||
|
||||
# Or via Android SDK Manager
|
||||
sdkmanager "platform-tools"
|
||||
```
|
||||
|
||||
3. **Android SDK Build Tools**
|
||||
```bash
|
||||
sdkmanager "build-tools;34.0.0"
|
||||
```
|
||||
|
||||
4. **Android Platform**
|
||||
```bash
|
||||
sdkmanager "platforms;android-34"
|
||||
```
|
||||
|
||||
5. **Android Emulator**
|
||||
```bash
|
||||
sdkmanager "emulator"
|
||||
```
|
||||
|
||||
6. **System Images**
|
||||
```bash
|
||||
# For API 34 (Android 14)
|
||||
sdkmanager "system-images;android-34;google_apis;x86_64"
|
||||
|
||||
# For API 33 (Android 13) - alternative
|
||||
sdkmanager "system-images;android-33;google_apis;x86_64"
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
```bash
|
||||
# Add to ~/.bashrc or ~/.zshrc
|
||||
export ANDROID_HOME=$HOME/Android/Sdk
|
||||
export ANDROID_AVD_HOME=$HOME/.android/avd # Important for AVD location
|
||||
export PATH=$PATH:$ANDROID_HOME/emulator
|
||||
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 shell
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
### Verify Installation
|
||||
|
||||
```bash
|
||||
# Check all tools are available
|
||||
adb version
|
||||
emulator -version
|
||||
avdmanager list
|
||||
```
|
||||
|
||||
## Resource-Aware Emulator Setup
|
||||
|
||||
### ⚡ **Quick Start Recommendation**
|
||||
|
||||
**For best results, always start with resource analysis:**
|
||||
|
||||
```bash
|
||||
# 1. Check your system capabilities
|
||||
./scripts/avd-resource-checker.sh
|
||||
|
||||
# 2. Use the generated optimal startup script
|
||||
/tmp/start-avd-TimeSafari_Emulator.sh
|
||||
|
||||
# 3. Deploy your app
|
||||
npm run build:android:dev
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
This prevents system lockups and ensures optimal performance.
|
||||
|
||||
### AVD Resource Checker Script
|
||||
|
||||
**New Feature**: TimeSafari includes an intelligent resource checker that automatically detects your system capabilities and recommends optimal AVD configurations.
|
||||
|
||||
```bash
|
||||
# Check system resources and get recommendations
|
||||
./scripts/avd-resource-checker.sh
|
||||
|
||||
# Check resources for specific AVD
|
||||
./scripts/avd-resource-checker.sh TimeSafari_Emulator
|
||||
|
||||
# Test AVD startup performance
|
||||
./scripts/avd-resource-checker.sh TimeSafari_Emulator --test
|
||||
|
||||
# Create optimized AVD with recommended settings
|
||||
./scripts/avd-resource-checker.sh TimeSafari_Emulator --create
|
||||
```
|
||||
|
||||
**What the script analyzes:**
|
||||
- **System Memory**: Total and available RAM
|
||||
- **CPU Cores**: Available processing power
|
||||
- **GPU Capabilities**: NVIDIA, AMD, Intel, or software rendering
|
||||
- **Hardware Acceleration**: Optimal graphics settings
|
||||
|
||||
**What it generates:**
|
||||
- **Optimal configuration**: Memory, cores, and GPU settings
|
||||
- **Startup command**: Ready-to-use emulator command
|
||||
- **Startup script**: Saved to `/tmp/start-avd-{name}.sh` for reuse
|
||||
|
||||
## Emulator Management
|
||||
|
||||
### Create Android Virtual Device (AVD)
|
||||
|
||||
```bash
|
||||
# List available system images
|
||||
avdmanager list target
|
||||
|
||||
# Create AVD for API 34
|
||||
avdmanager create avd \
|
||||
--name "TimeSafari_Emulator" \
|
||||
--package "system-images;android-34;google_apis;x86_64" \
|
||||
--device "pixel_7"
|
||||
|
||||
# List created AVDs
|
||||
avdmanager list avd
|
||||
```
|
||||
|
||||
### Start Emulator
|
||||
|
||||
```bash
|
||||
# Start emulator with hardware acceleration (recommended)
|
||||
emulator -avd TimeSafari_Emulator -gpu host -no-audio &
|
||||
|
||||
# Start with reduced resources (if system has limited RAM)
|
||||
emulator -avd TimeSafari_Emulator \
|
||||
-no-audio \
|
||||
-memory 2048 \
|
||||
-cores 2 \
|
||||
-gpu swiftshader_indirect &
|
||||
|
||||
# Start with minimal resources (safest for low-end systems)
|
||||
emulator -avd TimeSafari_Emulator \
|
||||
-no-audio \
|
||||
-memory 1536 \
|
||||
-cores 1 \
|
||||
-gpu swiftshader_indirect &
|
||||
|
||||
# Check if emulator is running
|
||||
adb devices
|
||||
```
|
||||
|
||||
### Resource Management
|
||||
|
||||
**Important**: Android emulators can consume significant system resources. Choose the appropriate configuration based on your system:
|
||||
|
||||
- **High-end systems** (16GB+ RAM, dedicated GPU): Use `-gpu host`
|
||||
- **Mid-range systems** (8-16GB RAM): Use `-memory 2048 -cores 2`
|
||||
- **Low-end systems** (4-8GB RAM): Use `-memory 1536 -cores 1 -gpu swiftshader_indirect`
|
||||
|
||||
### Emulator Control
|
||||
|
||||
```bash
|
||||
# Stop emulator
|
||||
adb emu kill
|
||||
|
||||
# Restart emulator
|
||||
adb reboot
|
||||
|
||||
# Check emulator status
|
||||
adb get-state
|
||||
```
|
||||
|
||||
## Build and Deploy Workflow
|
||||
|
||||
### Method 1: Using Existing Build Scripts
|
||||
|
||||
The TimeSafari project already has comprehensive Android build scripts that can be adapted for emulator deployment:
|
||||
|
||||
```bash
|
||||
# Development build with auto-run
|
||||
npm run build:android:dev:run
|
||||
|
||||
# Test build with auto-run
|
||||
npm run build:android:test:run
|
||||
|
||||
# Production build with auto-run
|
||||
npm run build:android:prod:run
|
||||
```
|
||||
|
||||
### Method 2: Custom Emulator Deployment Script
|
||||
|
||||
Create a new script specifically for emulator deployment:
|
||||
|
||||
```bash
|
||||
# Create emulator deployment script
|
||||
cat > scripts/deploy-android-emulator.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# deploy-android-emulator.sh
|
||||
# Author: Matthew Raymer
|
||||
# Date: 2025-01-27
|
||||
# Description: Deploy TimeSafari to Android emulator without Android Studio
|
||||
|
||||
set -e
|
||||
|
||||
# Source common utilities
|
||||
source "$(dirname "$0")/common.sh"
|
||||
|
||||
# Default values
|
||||
BUILD_MODE="development"
|
||||
AVD_NAME="TimeSafari_Emulator"
|
||||
START_EMULATOR=true
|
||||
CLEAN_BUILD=true
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dev|--development)
|
||||
BUILD_MODE="development"
|
||||
shift
|
||||
;;
|
||||
--test)
|
||||
BUILD_MODE="test"
|
||||
shift
|
||||
;;
|
||||
--prod|--production)
|
||||
BUILD_MODE="production"
|
||||
shift
|
||||
;;
|
||||
--avd)
|
||||
AVD_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-start-emulator)
|
||||
START_EMULATOR=false
|
||||
shift
|
||||
;;
|
||||
--no-clean)
|
||||
CLEAN_BUILD=false
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [options]"
|
||||
echo "Options:"
|
||||
echo " --dev, --development Build for development"
|
||||
echo " --test Build for testing"
|
||||
echo " --prod, --production Build for production"
|
||||
echo " --avd NAME Use specific AVD name"
|
||||
echo " --no-start-emulator Don't start emulator"
|
||||
echo " --no-clean Skip clean build"
|
||||
echo " -h, --help Show this help"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Function to check if emulator is running
|
||||
check_emulator_running() {
|
||||
if adb devices | grep -q "emulator.*device"; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to start emulator
|
||||
start_emulator() {
|
||||
log_info "Starting Android emulator: $AVD_NAME"
|
||||
|
||||
# Check if AVD exists
|
||||
if ! avdmanager list avd | grep -q "$AVD_NAME"; then
|
||||
log_error "AVD '$AVD_NAME' not found. Please create it first."
|
||||
log_info "Create AVD with: avdmanager create avd --name $AVD_NAME --package system-images;android-34;google_apis;x86_64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start emulator in background
|
||||
emulator -avd "$AVD_NAME" -no-audio -no-snapshot &
|
||||
EMULATOR_PID=$!
|
||||
|
||||
# Wait for emulator to boot
|
||||
log_info "Waiting for emulator to boot..."
|
||||
adb wait-for-device
|
||||
|
||||
# Wait for boot to complete
|
||||
log_info "Waiting for boot to complete..."
|
||||
while [ "$(adb shell getprop sys.boot_completed)" != "1" ]; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log_success "Emulator is ready!"
|
||||
}
|
||||
|
||||
# Function to build and deploy
|
||||
build_and_deploy() {
|
||||
log_info "Building TimeSafari for $BUILD_MODE mode..."
|
||||
|
||||
# Clean build if requested
|
||||
if [ "$CLEAN_BUILD" = true ]; then
|
||||
log_info "Cleaning previous build..."
|
||||
npm run clean:android
|
||||
fi
|
||||
|
||||
# Build based on mode
|
||||
case $BUILD_MODE in
|
||||
"development")
|
||||
npm run build:android:dev
|
||||
;;
|
||||
"test")
|
||||
npm run build:android:test
|
||||
;;
|
||||
"production")
|
||||
npm run build:android:prod
|
||||
;;
|
||||
esac
|
||||
|
||||
# Deploy to emulator
|
||||
log_info "Deploying to emulator..."
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
# Launch app
|
||||
log_info "Launching TimeSafari..."
|
||||
adb shell am start -n app.timesafari/.MainActivity
|
||||
|
||||
log_success "TimeSafari deployed and launched successfully!"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
log_info "TimeSafari Android Emulator Deployment"
|
||||
log_info "Build Mode: $BUILD_MODE"
|
||||
log_info "AVD Name: $AVD_NAME"
|
||||
|
||||
# Start emulator if requested and not running
|
||||
if [ "$START_EMULATOR" = true ]; then
|
||||
if ! check_emulator_running; then
|
||||
start_emulator
|
||||
else
|
||||
log_info "Emulator already running"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build and deploy
|
||||
build_and_deploy
|
||||
|
||||
log_success "Deployment completed successfully!"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
EOF
|
||||
|
||||
# Make script executable
|
||||
chmod +x scripts/deploy-android-emulator.sh
|
||||
```
|
||||
|
||||
### Method 3: Direct Command Line Deployment
|
||||
|
||||
For quick deployments without scripts:
|
||||
|
||||
```bash
|
||||
# 1. Ensure emulator is running
|
||||
adb devices
|
||||
|
||||
# 2. Build the app
|
||||
npm run build:android:dev
|
||||
|
||||
# 3. Install APK
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
# 4. Launch app
|
||||
adb shell am start -n app.timesafari/.MainActivity
|
||||
|
||||
# 5. View logs
|
||||
adb logcat | grep -E "(TimeSafari|Capacitor|MainActivity)"
|
||||
```
|
||||
|
||||
## Advanced Deployment Options
|
||||
|
||||
### Custom API Server Configuration
|
||||
|
||||
For development with custom API endpoints:
|
||||
|
||||
```bash
|
||||
# Build with custom API IP
|
||||
npm run build:android:dev:custom
|
||||
|
||||
# Or modify capacitor.config.ts for specific IP
|
||||
# Then build normally
|
||||
npm run build:android:dev
|
||||
```
|
||||
|
||||
### Debug vs Release Builds
|
||||
|
||||
```bash
|
||||
# Debug build (default)
|
||||
npm run build:android:debug
|
||||
|
||||
# Release build
|
||||
npm run build:android:release
|
||||
|
||||
# Install specific build
|
||||
adb install -r android/app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
### Asset Management
|
||||
|
||||
```bash
|
||||
# Validate Android assets
|
||||
npm run assets:validate:android
|
||||
|
||||
# Generate assets only
|
||||
npm run build:android:assets
|
||||
|
||||
# Clean assets
|
||||
npm run assets:clean
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Emulator Not Starting / AVD Not Found**
|
||||
```bash
|
||||
# Check available AVDs
|
||||
avdmanager list avd
|
||||
|
||||
# If AVD exists but emulator can't find it, check AVD location
|
||||
echo $ANDROID_AVD_HOME
|
||||
ls -la ~/.android/avd/
|
||||
|
||||
# Fix AVD path issue (common on Arch Linux)
|
||||
export ANDROID_AVD_HOME=/home/$USER/.config/.android/avd
|
||||
|
||||
# Or create symlinks if AVDs are in different location
|
||||
mkdir -p ~/.android/avd
|
||||
ln -s /home/$USER/.config/.android/avd/* ~/.android/avd/
|
||||
|
||||
# Create new AVD if needed
|
||||
avdmanager create avd --name "TimeSafari_Emulator" --package "system-images;android-34;google_apis;x86_64"
|
||||
|
||||
# Check emulator logs
|
||||
emulator -avd TimeSafari_Emulator -verbose
|
||||
```
|
||||
|
||||
2. **System Lockup / High Resource Usage**
|
||||
```bash
|
||||
# Kill any stuck emulator processes
|
||||
pkill -f emulator
|
||||
|
||||
# Check system resources
|
||||
free -h
|
||||
nvidia-smi # if using NVIDIA GPU
|
||||
|
||||
# Start with minimal resources
|
||||
emulator -avd TimeSafari_Emulator \
|
||||
-no-audio \
|
||||
-memory 1536 \
|
||||
-cores 1 \
|
||||
-gpu swiftshader_indirect &
|
||||
|
||||
# Monitor resource usage
|
||||
htop
|
||||
|
||||
# If still having issues, try software rendering only
|
||||
emulator -avd TimeSafari_Emulator \
|
||||
-no-audio \
|
||||
-no-snapshot \
|
||||
-memory 1024 \
|
||||
-cores 1 \
|
||||
-gpu off &
|
||||
```
|
||||
|
||||
3. **ADB Device Not Found**
|
||||
```bash
|
||||
# Restart ADB server
|
||||
adb kill-server
|
||||
adb start-server
|
||||
|
||||
# Check devices
|
||||
adb devices
|
||||
|
||||
# Check emulator status
|
||||
adb get-state
|
||||
```
|
||||
|
||||
3. **Build Failures**
|
||||
```bash
|
||||
# Clean everything
|
||||
npm run clean:android
|
||||
|
||||
# Rebuild
|
||||
npm run build:android:dev
|
||||
|
||||
# Check Gradle logs
|
||||
cd android && ./gradlew clean --stacktrace
|
||||
```
|
||||
|
||||
4. **Installation Failures**
|
||||
```bash
|
||||
# Uninstall existing app
|
||||
adb uninstall app.timesafari
|
||||
|
||||
# Reinstall
|
||||
adb install android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
# Check package info
|
||||
adb shell pm list packages | grep timesafari
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
1. **Emulator Performance**
|
||||
```bash
|
||||
# Start with hardware acceleration
|
||||
emulator -avd TimeSafari_Emulator -gpu host
|
||||
|
||||
# Use snapshot for faster startup
|
||||
emulator -avd TimeSafari_Emulator -snapshot default
|
||||
|
||||
# Allocate more RAM
|
||||
emulator -avd TimeSafari_Emulator -memory 4096
|
||||
```
|
||||
|
||||
2. **Build Performance**
|
||||
```bash
|
||||
# Use Gradle daemon
|
||||
echo "org.gradle.daemon=true" >> android/gradle.properties
|
||||
|
||||
# Increase heap size
|
||||
echo "org.gradle.jvmargs=-Xmx4g" >> android/gradle.properties
|
||||
|
||||
# Enable parallel builds
|
||||
echo "org.gradle.parallel=true" >> android/gradle.properties
|
||||
```
|
||||
|
||||
## Integration with Existing Build System
|
||||
|
||||
### NPM Scripts Integration
|
||||
|
||||
Add emulator-specific scripts to `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"emulator:check": "./scripts/avd-resource-checker.sh",
|
||||
"emulator:check:test": "./scripts/avd-resource-checker.sh TimeSafari_Emulator --test",
|
||||
"emulator:check:create": "./scripts/avd-resource-checker.sh TimeSafari_Emulator --create",
|
||||
"emulator:start": "emulator -avd TimeSafari_Emulator -no-audio &",
|
||||
"emulator:start:optimized": "/tmp/start-avd-TimeSafari_Emulator.sh",
|
||||
"emulator:stop": "adb emu kill",
|
||||
"emulator:deploy": "./scripts/deploy-android-emulator.sh",
|
||||
"emulator:deploy:dev": "./scripts/deploy-android-emulator.sh --dev",
|
||||
"emulator:deploy:test": "./scripts/deploy-android-emulator.sh --test",
|
||||
"emulator:deploy:prod": "./scripts/deploy-android-emulator.sh --prod",
|
||||
"emulator:logs": "adb logcat | grep -E '(TimeSafari|Capacitor|MainActivity)'",
|
||||
"emulator:shell": "adb shell"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
For automated testing and deployment:
|
||||
|
||||
```bash
|
||||
# GitHub Actions example
|
||||
- name: Start Android Emulator
|
||||
run: |
|
||||
emulator -avd TimeSafari_Emulator -no-audio -no-snapshot &
|
||||
adb wait-for-device
|
||||
adb shell getprop sys.boot_completed
|
||||
|
||||
- name: Build and Deploy
|
||||
run: |
|
||||
npm run build:android:test
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
adb shell am start -n app.timesafari/.MainActivity
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
npm run test:android
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Start emulator once per session**
|
||||
```bash
|
||||
emulator -avd TimeSafari_Emulator -no-audio &
|
||||
```
|
||||
|
||||
2. **Use incremental builds**
|
||||
```bash
|
||||
# For rapid iteration
|
||||
npm run build:android:sync
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
3. **Monitor logs continuously**
|
||||
```bash
|
||||
adb logcat | grep -E "(TimeSafari|Capacitor|MainActivity)" --color=always
|
||||
```
|
||||
|
||||
### Performance Tips
|
||||
|
||||
1. **Use snapshots for faster startup**
|
||||
2. **Enable hardware acceleration**
|
||||
3. **Allocate sufficient RAM (4GB+)**
|
||||
4. **Use SSD storage for AVDs**
|
||||
5. **Close unnecessary applications**
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Use debug builds for development only**
|
||||
2. **Never commit debug keystores**
|
||||
3. **Use release builds for testing**
|
||||
4. **Validate API endpoints in production builds**
|
||||
|
||||
## Conclusion
|
||||
|
||||
This guide provides a complete solution for deploying TimeSafari to Android emulators without Android Studio. The approach leverages the existing build system while adding emulator-specific deployment capabilities.
|
||||
|
||||
The key benefits:
|
||||
- ✅ **No Android Studio required**
|
||||
- ✅ **Command-line only workflow**
|
||||
- ✅ **Integration with existing build scripts**
|
||||
- ✅ **Automated deployment options**
|
||||
- ✅ **Comprehensive troubleshooting guide**
|
||||
|
||||
For questions or issues, refer to the troubleshooting section or check the existing build documentation in `BUILDING.md`.
|
||||
515
doc/android-physical-device-guide.md
Normal file
515
doc/android-physical-device-guide.md
Normal file
@@ -0,0 +1,515 @@
|
||||
# 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
|
||||
|
||||
Create or edit `.env.development` with your computer's IP:
|
||||
|
||||
```bash
|
||||
# .env.development - for physical device testing
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=http://192.168.1.100:3000
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=http://192.168.1.100:3000
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_APP_SERVER=http://192.168.1.100:8080
|
||||
```
|
||||
|
||||
**Important**: Replace `192.168.1.100` with your actual IP address.
|
||||
|
||||
### Step 4: Start Your Local Server
|
||||
|
||||
If testing against local API servers, ensure they're accessible from the network:
|
||||
|
||||
```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.
|
||||
106
doc/daily-notification-alignment-outline.md
Normal file
106
doc/daily-notification-alignment-outline.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 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:** Test app at
|
||||
`/Users/aardimus/Sites/trentlarson/daily-notification-plugin_test/daily-notification-plugin/test-apps/daily-notification-test`
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
251
doc/daily-notification-plugin-android-receiver-issue.md
Normal file
251
doc/daily-notification-plugin-android-receiver-issue.md
Normal file
@@ -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.
|
||||
312
doc/daily-notification-plugin-architecture.md
Normal file
312
doc/daily-notification-plugin-architecture.md
Normal file
@@ -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.
|
||||
348
doc/daily-notification-plugin-checklist.md
Normal file
348
doc/daily-notification-plugin-checklist.md
Normal file
@@ -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) 🚀
|
||||
193
doc/daily-notification-plugin-integration-summary.md
Normal file
193
doc/daily-notification-plugin-integration-summary.md
Normal file
@@ -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) 🚀
|
||||
237
doc/daily-notification-plugin-integration.md
Normal file
237
doc/daily-notification-plugin-integration.md
Normal file
@@ -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
|
||||
|
||||
|
||||
139
doc/ios-share-extension-git-commit-guide.md
Normal file
139
doc/ios-share-extension-git-commit-guide.md
Normal file
@@ -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.
|
||||
|
||||
283
doc/ios-share-extension-improvements.md
Normal file
283
doc/ios-share-extension-improvements.md
Normal file
@@ -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
|
||||
|
||||
140
doc/ios-share-extension-setup.md
Normal file
140
doc/ios-share-extension-setup.md
Normal file
@@ -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)
|
||||
|
||||
93
doc/ios-share-implementation-status.md
Normal file
93
doc/ios-share-implementation-status.md
Normal file
@@ -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
|
||||
|
||||
507
doc/native-share-target-implementation.md
Normal file
507
doc/native-share-target-implementation.md
Normal file
@@ -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)
|
||||
|
||||
412
doc/notification-integration-changes-outline.md
Normal file
412
doc/notification-integration-changes-outline.md
Normal file
@@ -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
|
||||
238
doc/notification-permissions-and-rollovers.md
Normal file
238
doc/notification-permissions-and-rollovers.md
Normal file
@@ -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
|
||||
378
doc/notification-system-overview.md
Normal file
378
doc/notification-system-overview.md
Normal file
@@ -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
|
||||
27
doc/plugin-android-edit-reschedule-alarm-not-firing.md
Normal file
27
doc/plugin-android-edit-reschedule-alarm-not-firing.md
Normal file
@@ -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`.
|
||||
71
doc/plugin-feedback-android-6-api23-zoneid-fix.md
Normal file
71
doc/plugin-feedback-android-6-api23-zoneid-fix.md
Normal file
@@ -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.
|
||||
114
doc/plugin-feedback-android-duplicate-reminder-notification.md
Normal file
114
doc/plugin-feedback-android-duplicate-reminder-notification.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Plugin feedback: Android duplicate reminder notification on first-time setup
|
||||
|
||||
**Date:** 2026-02-18
|
||||
**Generated:** 2026-02-18 17:47:06 PST
|
||||
**Target repo:** daily-notification-plugin (local copy at `daily-notification-plugin_test`)
|
||||
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||
**Platform:** Android
|
||||
|
||||
## Summary
|
||||
|
||||
When the user sets a **Reminder Notification for the first time** (toggle on → set message and time in `PushNotificationPermission`), **two notifications** fire at the scheduled time:
|
||||
|
||||
1. **Correct one:** User’s chosen title/message, from the static reminder alarm (`scheduleId` = `daily_timesafari_reminder`).
|
||||
2. **Extra one:** Fallback message (“Daily Update” / “🌅 Good morning! Ready to make today amazing?”), from a second alarm that uses a **UUID** as `notification_id`.
|
||||
|
||||
When the user **edits** an existing reminder (Edit Notification Details), only one notification fires. The duplicate only happens on **initial** setup.
|
||||
|
||||
The app calls `scheduleDailyNotification` **once** per user action in both flows (first-time and edit). The duplicate is caused inside the plugin by the **prefetch worker** scheduling a second alarm via the legacy `DailyNotificationScheduler`.
|
||||
|
||||
---
|
||||
|
||||
## Evidence from Logcat
|
||||
|
||||
Filter: `DNP-SCHEDULE`, `DailyNotificationWorker`, `DailyNotificationReceiver`.
|
||||
|
||||
- **17:42:34** – Single call from app: plugin schedules the static reminder alarm (`scheduleId=daily_timesafari_reminder`, source=INITIAL_SETUP). One OS alarm is scheduled.
|
||||
- **17:45:00** – **Two** `RECEIVE_START` events:
|
||||
- First: `display=5e373fd1-0f08-4e8f-b166-cfd46d694d82` (UUID).
|
||||
- Second: `static_reminder id=daily_timesafari_reminder`.
|
||||
- Both run in parallel: Worker for UUID shows `DN|JIT_FRESH skip=true` and displays; Worker for `daily_timesafari_reminder` shows `DN|DISPLAY_STATIC_REMINDER` and displays. So two notifications are shown.
|
||||
|
||||
Conclusion: two different PendingIntents fire at the same time: one with `notification_id` = UUID, one with `notification_id` = `daily_timesafari_reminder`.
|
||||
|
||||
---
|
||||
|
||||
## Root cause (plugin side)
|
||||
|
||||
1. **ScheduleHelper.scheduleDailyNotification** (e.g. in `DailyNotificationPlugin.kt`):
|
||||
- Cancels existing alarm for `scheduleId`.
|
||||
- Schedules **one** alarm via **NotifyReceiver.scheduleExactNotification** with `reminderId = scheduleId`, `scheduleId = scheduleId`, `isStaticReminder = true` (INITIAL_SETUP). That alarm carries title/body in the intent and is the “correct” notification.
|
||||
- Enqueues **DailyNotificationFetchWorker** (prefetch) to run 2 minutes before the same time.
|
||||
|
||||
2. **DailyNotificationFetchWorker** runs ~2 minutes before the display time:
|
||||
- Tries to fetch content (e.g. native fetcher). For a static-reminder-only app (no URL, no fetcher returning content), the fetch returns empty/null.
|
||||
- Goes to **handleFailedFetch** → **useFallbackContent** → **getFallbackContent** → **createEmergencyFallbackContent(scheduledTime)**.
|
||||
- **createEmergencyFallbackContent** builds a `NotificationContent()` (default constructor), which assigns a **random UUID** as `id`, and sets title “Daily Update” and body “🌅 Good morning! Ready to make today amazing?”.
|
||||
- **useFallbackContent** then calls **scheduleNotificationIfNeeded(fallbackContent)**.
|
||||
|
||||
3. **scheduleNotificationIfNeeded** uses the **legacy DailyNotificationScheduler** (AlarmManager) to schedule **another** alarm at the **same** `scheduledTime`, with `notification_id` = that UUID.
|
||||
|
||||
So at fire time there are two alarms:
|
||||
|
||||
- NotifyReceiver’s alarm: `notification_id` = `daily_timesafari_reminder`, `is_static_reminder` = true → correct user message.
|
||||
- DailyNotificationScheduler’s alarm: `notification_id` = UUID → fallback message.
|
||||
|
||||
The prefetch path is intended for “fetch content then display” flows. For **static reminder** schedules, the display is already fully handled by the single NotifyReceiver alarm; the prefetch worker should not schedule a second alarm.
|
||||
|
||||
---
|
||||
|
||||
## Why edit doesn’t show the duplicate (in observed behavior)
|
||||
|
||||
On edit, the app still calls the plugin once and the plugin again enqueues the prefetch worker. Possible reasons the duplicate is less obvious on edit:
|
||||
|
||||
- Different timing (e.g. user sets a time further out, or doesn’t wait for the second notification).
|
||||
- Or the first-time run leaves the prefetch/legacy path in a state where the duplicate only appears on first setup.
|
||||
|
||||
Regardless, the **correct fix** is to ensure that for static-reminder schedules the prefetch worker never schedules a second alarm.
|
||||
|
||||
---
|
||||
|
||||
## Recommended fix (in the plugin)
|
||||
|
||||
**Option A (recommended): Do not enqueue prefetch for static reminder schedules**
|
||||
|
||||
In **ScheduleHelper.scheduleDailyNotification** (or equivalent), when scheduling a **static reminder** (title/body from app, no URL, display already in the intent), **do not** enqueue `DailyNotificationFetchWorker` for that run. The prefetch is for “fetch content then show”; for static reminders there is nothing to fetch and the only alarm should be the one from NotifyReceiver.
|
||||
|
||||
- No new inputData flags needed.
|
||||
- No change to DailyNotificationFetchWorker semantics for other flows.
|
||||
|
||||
**Option B: Prefetch worker skips scheduling when display is already scheduled**
|
||||
|
||||
- When enqueueing the prefetch work for a static-reminder schedule, pass an input flag (e.g. `display_already_scheduled` or `is_static_reminder_schedule` = true).
|
||||
- In **DailyNotificationFetchWorker**, in **useFallbackContent** (and anywhere else that calls **scheduleNotificationIfNeeded** for this work item), if that flag is set, **do not** call **scheduleNotificationIfNeeded**.
|
||||
- Ensures only the NotifyReceiver alarm fires for that time.
|
||||
|
||||
Option A is simpler and matches the semantics: static reminder = one alarm, no prefetch.
|
||||
|
||||
---
|
||||
|
||||
## App-side behavior (no change required)
|
||||
|
||||
- **First-time reminder:** Account view opens `PushNotificationPermission` without `skipSchedule`. User sets time/message and confirms. Dialog’s `turnOnNativeNotifications` calls `NotificationService.scheduleDailyNotification(...)` **once** and then the callback saves settings. No second schedule from the app.
|
||||
- **Edit reminder:** Account view opens the dialog with `skipSchedule: true`. Only the parent’s callback runs; it calls `cancelDailyNotification()` (on iOS) then `scheduleDailyNotification(...)` **once**. No double schedule from the app.
|
||||
|
||||
So the duplicate is entirely due to the plugin’s prefetch worker scheduling an extra alarm via the legacy scheduler; fixing it in the plugin as above will resolve the issue.
|
||||
|
||||
---
|
||||
|
||||
## Files to consider in the plugin
|
||||
|
||||
- **ScheduleHelper.scheduleDailyNotification** (e.g. in `DailyNotificationPlugin.kt`): where the single NotifyReceiver alarm and the prefetch work are enqueued. Either skip enqueueing prefetch for static reminder (Option A), or add inputData for “display already scheduled” (Option B).
|
||||
- **DailyNotificationFetchWorker**: `useFallbackContent` → `scheduleNotificationIfNeeded`; if using Option B, skip `scheduleNotificationIfNeeded` when the new flag is set.
|
||||
- **DailyNotificationScheduler** (legacy): used by `scheduleNotificationIfNeeded` to add the second (UUID) alarm; no change required if the worker simply stops calling it for static-reminder schedules.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After the fix:
|
||||
|
||||
1. **First-time:** Turn on Reminder Notification, set message and time (e.g. 2–3 minutes ahead). Wait until the scheduled time. **Only one** notification should appear, with the user’s message.
|
||||
2. Logcat should show a single `RECEIVE_START` at that time (e.g. `static_reminder id=daily_timesafari_reminder`), and no second `display=<uuid>` for the same time.
|
||||
|
||||
You can reuse the same Logcat filter as above to confirm a single receiver run per scheduled time.
|
||||
74
doc/plugin-feedback-android-exact-alarm-settings.md
Normal file
74
doc/plugin-feedback-android-exact-alarm-settings.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Plugin feedback: Android exact alarm — stop opening Settings automatically
|
||||
|
||||
**Date:** 2026-03-09
|
||||
**Target repo:** daily-notification-plugin
|
||||
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||
**Platform:** Android
|
||||
|
||||
## Summary
|
||||
|
||||
When the consuming app calls `scheduleDailyNotification()` after the user has granted `POST_NOTIFICATIONS`, the plugin checks whether **exact alarms** can be scheduled (Android 12+). If not, it **opens the system Settings** (exact-alarm or app-details screen) and **rejects** the call. This is intrusive: the app prefers to schedule without forcing the user into Settings, and to inform the user about exact alarms in its own UI (e.g. a note in the success message).
|
||||
|
||||
**Requested change:** Remove the automatic opening of Settings for exact alarm permission from `scheduleDailyNotification()`. Either:
|
||||
|
||||
- **Option A (preferred):** Do not open Settings and do not reject when exact alarm is not granted. Proceed with scheduling (using inexact alarms if necessary when exact is unavailable), and let the consuming app handle any UX (e.g. optional hint to enable exact alarms).
|
||||
- **Option B:** Do not open Settings, but still reject with a clear error code/message when exact alarm is required and not granted, so the app can show its own message or deep-link to Settings if desired.
|
||||
|
||||
---
|
||||
|
||||
## Where this lives in the plugin
|
||||
|
||||
**File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt`
|
||||
**Method:** `scheduleDailyNotification(call: PluginCall)`
|
||||
**Lines:** ~1057–1109 (exact line numbers may shift with edits)
|
||||
|
||||
Current behavior:
|
||||
|
||||
1. At the start of `scheduleDailyNotification()`, the plugin calls `canScheduleExactAlarms(context)`.
|
||||
2. If `false`:
|
||||
- If Android S+ and `canRequestExactAlarmPermission(context)` is true: it builds `Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM`, calls `context.startActivity(intent)`, logs **"Exact alarm permission required. Opened Settings for user to grant permission."** (tag `DNP-PLUGIN`), and rejects with `EXACT_ALARM_PERMISSION_REQUIRED`.
|
||||
- Else: it opens app details (`Settings.ACTION_APPLICATION_DETAILS_SETTINGS`), logs **"Exact alarm permission denied. Directing user to app settings."**, and rejects with `PERMISSION_DENIED`.
|
||||
3. Only if exact alarms are allowed does the plugin continue to schedule.
|
||||
|
||||
So the **exact alarms** feature here is: **gate scheduling on exact alarm permission and, when not granted, open Settings and reject.**
|
||||
|
||||
---
|
||||
|
||||
## Evidence from consumer app (logcat)
|
||||
|
||||
Filter: `DNP-PLUGIN`, `DNP-SCHEDULE`, `DailyNotificationWorker`, `DailyNotificationReceiver`.
|
||||
|
||||
Typical sequence when user enables daily notification:
|
||||
|
||||
1. `DNP-PLUGIN: Created pending permission request: ... type=POST_NOTIFICATIONS`
|
||||
2. User grants notification permission.
|
||||
3. `DNP-PLUGIN: Resolving pending POST_NOTIFICATIONS request on resume: granted=true`
|
||||
4. App calls `scheduleDailyNotification(...)`.
|
||||
5. `DNP-PLUGIN: Exact alarm permission required. Opened Settings for user to grant permission.`
|
||||
|
||||
The consumer app does **not** call any plugin API to “request exact alarm” or “open exact alarm settings”; it only calls `requestPermissions()` (POST_NOTIFICATIONS) and then `scheduleDailyNotification()`. The plugin’s own guard in `scheduleDailyNotification()` is what opens Settings.
|
||||
|
||||
---
|
||||
|
||||
## Consumer app context
|
||||
|
||||
- **Permission flow:** The app requests `POST_NOTIFICATIONS` via the plugin’s `requestPermissions()`, then calls `scheduleDailyNotification()`. It does not request exact alarm permission itself.
|
||||
- **UX:** The app already shows an optional note when exact alarm is not granted (e.g. “If notifications don’t appear, enable ‘Exact alarms’ in Android Settings → Apps → TimeSafari → App settings”). It does not want the plugin to open Settings automatically.
|
||||
- **Manifest:** The app declares `SCHEDULE_EXACT_ALARM` in its AndroidManifest; the issue is only the **automatic redirect to Settings** and the **reject** when exact alarm is not yet granted.
|
||||
|
||||
---
|
||||
|
||||
## Suggested plugin changes
|
||||
|
||||
1. **In `scheduleDailyNotification()`:** Remove the block that opens Settings and rejects when `!canScheduleExactAlarms(context)` (the block ~1057–1109). Do **not** call `startActivity` for `ACTION_REQUEST_SCHEDULE_EXACT_ALARM` or `ACTION_APPLICATION_DETAILS_SETTINGS` from this method.
|
||||
2. **Scheduling when exact alarm is not granted:** Prefer Option A: continue and schedule even when exact alarms are not allowed (e.g. use inexact/alarm manager APIs that don’t require exact alarm, or document that timing may be approximate). If the plugin must reject when exact is required, use Option B: reject with a specific error code/message and no `startActivity`.
|
||||
3. **Leave other APIs unchanged:** Methods such as `openExactAlarmSettings()` or `requestExactAlarmPermission()` can remain for apps that explicitly want to send the user to Settings; the change is only to stop doing it automatically inside `scheduleDailyNotification()`.
|
||||
|
||||
---
|
||||
|
||||
## Relation to existing docs
|
||||
|
||||
- **Plugin:** `doc/daily-notification-plugin-android-receiver-issue.md` and `doc/daily-notification-plugin-checklist.md` describe use of `SCHEDULE_EXACT_ALARM` (not `USE_EXACT_ALARM`). This feedback does not change that; it only asks to stop auto-opening Settings in `scheduleDailyNotification()`.
|
||||
- **Consumer app:** `doc/notification-permissions-and-rollovers.md` describes the permission flow; `doc/NOTIFICATION_TROUBLESHOOTING.md` mentions exact alarms for user guidance.
|
||||
|
||||
Use this document when implementing the change in the **daily-notification-plugin** repo (e.g. with Cursor). After changing the plugin, update the consuming app’s dependency (e.g. `npm update @timesafari/daily-notification-plugin`), then `npx cap sync android` and rebuild.
|
||||
151
doc/plugin-feedback-android-post-reboot-fallback-text.md
Normal file
151
doc/plugin-feedback-android-post-reboot-fallback-text.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Plugin feedback: Android daily notification shows fallback text after device restart
|
||||
|
||||
**Date:** 2026-02-23
|
||||
**Target repo:** daily-notification-plugin
|
||||
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||
**Platform:** Android
|
||||
|
||||
## Summary
|
||||
|
||||
When the user sets a daily reminder (custom title/message) and then **restarts the device**, the notification still fires at the scheduled time but displays **fallback text** instead of the user’s message. If the device is **not** restarted, the same flow (app active, background, or closed) shows the correct user-set text.
|
||||
|
||||
So the regression is specific to **post-reboot**: the alarm survives reboot (good), but the **content** used for display is wrong (fallback).
|
||||
|
||||
---
|
||||
|
||||
## Evidence from Logcat
|
||||
|
||||
Filter: `DNP-SCHEDULE`, `DailyNotificationWorker`, `DailyNotificationReceiver`.
|
||||
|
||||
**After reboot (boot recovery):**
|
||||
|
||||
```
|
||||
02-23 16:28:44.489 W/DNP-SCHEDULE: Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=2026-02-24 16:28:44, source=BOOT_RECOVERY
|
||||
02-23 16:28:44.489 W/DNP-SCHEDULE: Existing PendingIntent found for requestCode=53438 - alarm already scheduled
|
||||
```
|
||||
|
||||
So boot recovery does **not** replace the alarm; the existing PendingIntent is kept.
|
||||
|
||||
**When the notification fires (after reboot):**
|
||||
|
||||
```
|
||||
02-23 16:32:00.601 D/DailyNotificationReceiver: DN|RECEIVE_START action=org.timesafari.daily.NOTIFICATION
|
||||
02-23 16:32:00.650 D/DailyNotificationReceiver: DN|WORK_ENQUEUE display=notify_1771835520000 work_name=display_notify_1771835520000
|
||||
02-23 16:32:00.847 D/DailyNotificationWorker: DN|WORK_START id=notify_1771835520000 action=display ...
|
||||
02-23 16:32:00.912 D/DailyNotificationWorker: DN|DISPLAY_START id=notify_1771835520000
|
||||
02-23 16:32:00.982 D/DailyNotificationWorker: DN|JIT_FRESH skip=true ageMin=0 id=notify_1771835520000
|
||||
02-23 16:32:00.982 D/DailyNotificationWorker: DN|DISPLAY_NOTIF_START id=notify_1771835520000
|
||||
02-23 16:32:01.018 I/DailyNotificationWorker: DN|DISPLAY_NOTIF_OK id=notify_1771835520000
|
||||
```
|
||||
|
||||
Important detail: the worker logs **`DN|JIT_FRESH skip=true`**, and there is **no** `DN|DISPLAY_STATIC_REMINDER`. So the **static reminder path** (title/body from Intent extras) is **not** used; the worker is using the path that loads content from Room/legacy and runs the JIT freshness check. That path is used when `is_static_reminder` is false or when `title`/`body` are missing from the WorkManager input.
|
||||
|
||||
Conclusion: when the alarm fires after reboot, the receiver either gets an Intent **without** (or with cleared) `title`, `body`, and `is_static_reminder`, or the WorkManager input is built without them, so the worker falls back to Room/legacy (and possibly to NativeFetcher), which produces fallback text.
|
||||
|
||||
---
|
||||
|
||||
## Root cause (plugin side)
|
||||
|
||||
### 1. PendingIntent extras may not survive reboot
|
||||
|
||||
On Android, when an alarm is scheduled, the system stores the PendingIntent. After a **device reboot**, the alarm is restored from persisted state, but it is possible that **Intent extras** (e.g. `title`, `body`, `is_static_reminder`) are **not** persisted or are stripped when the broadcast is delivered. So when `DailyNotificationReceiver.onReceive` runs after reboot, `intent.getStringExtra("title")` and `intent.getStringExtra("body")` may be null, and `intent.getBooleanExtra("is_static_reminder", false)` may be false. The receiver still has `notification_id` (so the work is enqueued with that id), but the Worker input has no static reminder data, so the worker correctly takes the “load from Room / JIT” path. If the content then comes from Room with wrong/fallback data, or from the app’s NativeFetcher (which returns placeholder text), the user sees fallback text.
|
||||
|
||||
### 2. Boot/force-stop recovery uses hardcoded title/body
|
||||
|
||||
In `ReactivationManager.rescheduleAlarmForBoot` and `rescheduleAlarm` (and similarly in `BootReceiver` if it ever reschedules), the config used for rescheduling is:
|
||||
|
||||
```kotlin
|
||||
val config = UserNotificationConfig(
|
||||
...
|
||||
title = "Daily Notification",
|
||||
body = "Your daily update is ready",
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
So whenever recovery **does** reschedule (e.g. after force-stop or in a code path that replaces the alarm), the new Intent carries this fallback text. In the **current** log, boot recovery **skips** rescheduling (duplicate found), so this is not the path that ran. But if in other builds or OEMs recovery does reschedule, or if a future change replaces the PendingIntent after reboot, the same bug would appear. So recovery should not use hardcoded strings when the schedule has known title/body.
|
||||
|
||||
### 3. Schedule entity does not store title/body
|
||||
|
||||
`Schedule` in `DatabaseSchema.kt` has no `title` or `body` fields. So after reboot there is no way to recover the user’s message from the plugin DB when:
|
||||
|
||||
- The Intent extras are missing (post-reboot delivery), or
|
||||
- Recovery needs to reschedule and should use the same title/body as before.
|
||||
|
||||
The plugin **does** store a `NotificationContentEntity` (with title/body) when scheduling in `NotifyReceiver`, keyed by `notificationId`. So in principle the worker could get the right text by loading that entity when the Intent lacks title/body. That only works if:
|
||||
|
||||
- The worker is given the same `notification_id` that was used when storing the entity, and
|
||||
- The entity was actually written and not overwritten by another path (e.g. prefetch/fallback).
|
||||
|
||||
If after reboot the delivered Intent has a different or missing `notification_id`, or the Room lookup fails (e.g. different id convention, DB not ready), the worker would fall back to legacy storage or fetcher, hence fallback text.
|
||||
|
||||
---
|
||||
|
||||
## Recommended fix (in the plugin)
|
||||
|
||||
### A. Persist title/body for static reminders and use when extras are missing
|
||||
|
||||
1. **Persist title/body (and optionally sound/vibration/priority) for static reminders**
|
||||
- Either extend the `Schedule` entity with `title`, `body` (and optionally other display fields), or ensure there is a single, authoritative `NotificationContentEntity` per schedule/notification id that is written at schedule time and not overwritten by prefetch/fallback.
|
||||
- When the app calls `scheduleDailyNotification` with a static reminder, store these values (already done for `NotificationContentEntity` in `NotifyReceiver`; ensure the same id is used for lookup after reboot).
|
||||
|
||||
2. **In `DailyNotificationReceiver.enqueueNotificationWork`**
|
||||
- If the Intent has `notification_id` but **missing** `title`/`body` (or they are empty), or `is_static_reminder` is false but the schedule is known to be a static reminder:
|
||||
- Resolve the schedule/notification id (e.g. from `schedule_id` extra if present, or from `notification_id` if it matches a known pattern).
|
||||
- Load title/body (and other display fields) from the plugin DB (Schedule or NotificationContentEntity).
|
||||
- If found, pass them into the Worker input and set `is_static_reminder = true` so the worker uses the static reminder path with the correct text.
|
||||
|
||||
3. **In `DailyNotificationWorker.handleDisplayNotification`**
|
||||
- When loading content from Room by `notification_id`, if the entity exists and has title/body, use it as-is for display and **skip** replacing it with JIT/fetcher content for that run (or treat it as static for this display so JIT doesn’t overwrite user text with fetcher fallback).
|
||||
|
||||
This way, even if the broadcast Intent loses extras after reboot, the receiver or worker can still show the user’s message from persisted storage.
|
||||
|
||||
### B. Use persisted title/body in boot/force-stop recovery
|
||||
|
||||
- In `ReactivationManager.rescheduleAlarmForBoot`, `rescheduleAlarm`, and any similar recovery path that builds a `UserNotificationConfig`:
|
||||
- Load the schedule (and associated title/body) from the DB (e.g. from `Schedule` if extended, or from `NotificationContentEntity` by schedule/notification id).
|
||||
- If title/body exist, use them in the config instead of `"Daily Notification"` / `"Your daily update is ready"`.
|
||||
- Only use the hardcoded fallback when no persisted title/body exist (e.g. legacy schedules).
|
||||
|
||||
This ensures that any time recovery reschedules an alarm, the user’s custom message is preserved.
|
||||
|
||||
### C. Ensure one canonical content record per static reminder
|
||||
|
||||
- Ensure that for a given static reminder schedule, the `NotificationContentEntity` written at schedule time (in `NotifyReceiver`) is the one used for display when the alarm fires (including after reboot), and that prefetch/fallback paths do not overwrite that entity for the same logical notification (e.g. same schedule id or same notification id). If the worker currently loads by `notification_id`, ensure that id is stable and matches what was stored at schedule time.
|
||||
|
||||
---
|
||||
|
||||
## App-side behavior (no change required for this bug)
|
||||
|
||||
- The app calls `scheduleDailyNotification` once with `id: "daily_timesafari_reminder"`, `title`, and `body`. It does not reschedule after reboot; the plugin’s boot recovery and alarm delivery are entirely on the plugin side.
|
||||
- The app’s `TimeSafariNativeFetcher` returns placeholder text; that is only used when the plugin takes the “fetch content” path. Fixing the plugin so that after reboot the static reminder path (or Room content with user title/body) is used will prevent that placeholder from appearing for the user’s reminder.
|
||||
|
||||
---
|
||||
|
||||
## Verification after fix
|
||||
|
||||
1. Set a daily reminder with a **distinct** custom message (e.g. “My custom reminder text”).
|
||||
2. **Restart the device** (full reboot).
|
||||
3. Wait until the scheduled time (or set it 1–2 minutes ahead for a quick test).
|
||||
4. Confirm that the notification shows **“My custom reminder text”** (or the chosen title), not “Daily Notification” / “Your daily update is ready” or the NativeFetcher placeholder.
|
||||
5. In logcat, after the notification fires, you should see either:
|
||||
- `DN|DISPLAY_STATIC_REMINDER` with the correct title, or
|
||||
- A path that loads content from Room and displays it without overwriting with fetcher fallback.
|
||||
|
||||
---
|
||||
|
||||
## Files to consider in the plugin
|
||||
|
||||
- **NotifyReceiver.kt** – Already stores `NotificationContentEntity` at schedule time; ensure the same `notificationId` used in the PendingIntent is the one used for this entity so post-reboot lookup by `notification_id` finds it.
|
||||
- **DailyNotificationReceiver.java** – In `enqueueNotificationWork`, add a fallback: if Intent has `notification_id` but no (or empty) `title`/`body`, look up title/body from DB (by `schedule_id` or `notification_id`) and pass them into Worker input with `is_static_reminder = true`.
|
||||
- **DailyNotificationWorker.java** – When loading from Room for a given `notification_id`, prefer that entity for display and avoid overwriting with JIT/fetcher content when the content is for a static reminder (e.g. same id as a schedule that was created as static).
|
||||
- **ReactivationManager.kt** – In `rescheduleAlarmForBoot` and `rescheduleAlarm`, load title/body from Schedule or NotificationContentEntity and use them in `UserNotificationConfig` instead of hardcoded strings.
|
||||
- **DatabaseSchema.kt** (optional) – If you prefer to keep title/body on the schedule, add `title` and `body` (and optionally other display fields) to the `Schedule` entity and persist them when the app calls `scheduleDailyNotification`.
|
||||
|
||||
---
|
||||
|
||||
## Short summary for Cursor (plugin-side)
|
||||
|
||||
**Bug:** After Android device restart, the daily notification still fires but shows fallback text instead of the user-set message. Logs show the worker uses the non-static path (`JIT_FRESH`, no `DISPLAY_STATIC_REMINDER`), so Intent extras (title/body/is_static_reminder) are likely missing after reboot.
|
||||
|
||||
**Fix:** (1) When the receiver has `notification_id` but missing title/body, look up title/body from the plugin DB (Schedule or NotificationContentEntity) and pass them into the Worker as static reminder data. (2) In boot/force-stop recovery, load title/body from DB and use them when rescheduling instead of hardcoded “Daily Notification” / “Your daily update is ready”. (3) Ensure the NotificationContentEntity written at schedule time is the one used for display after reboot (same id, not overwritten by prefetch/fallback).
|
||||
169
doc/plugin-feedback-android-rollover-after-reboot.md
Normal file
169
doc/plugin-feedback-android-rollover-after-reboot.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Plugin feedback: Android rollover notification may not fire after device restart (app not launched)
|
||||
|
||||
**Date:** 2026-02-24 18:24
|
||||
**Target repo:** daily-notification-plugin
|
||||
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||
**Platform:** Android
|
||||
|
||||
## Summary
|
||||
|
||||
Boot recovery can **skip** rescheduling after a device restart (it sees an “existing PendingIntent” and skips). Whether the next notification **fails to fire** as a result depends on **whether the alarm survived the reboot**: Android’s documented behavior is that AlarmManager alarms do **not** persist across reboot, but on some devices/builds they **do** (implementation-dependent). So: **(1)** *Set schedule → restart shortly after → wait for first notification:* On at least one device, the initial notification **fired** even though boot recovery skipped (alarm had survived reboot). On devices where alarms are cleared, that initial notification would not fire. **(2)** *Set schedule → first notification fires → restart → wait for rollover:* Same logic—if the rollover alarm is cleared and boot recovery skips, the rollover won’t fire. The **fix** (always reschedule in the boot path, skip idempotence there) remains correct: it makes behavior reliable regardless of alarm persistence. See [Scenario 1: observed behavior](#scenario-1-observed-behavior) and [Two distinct scenarios](#two-distinct-scenarios-same-bug-different-victim-notification).
|
||||
|
||||
---
|
||||
|
||||
## Definitions
|
||||
|
||||
- **Rollover (in this doc):** The next occurrence of the daily notification. Concretely: when today’s alarm fires, the plugin runs `scheduleNextNotification()` and sets an alarm for the same time the next day. That “next day” alarm is the rollover.
|
||||
- **Boot recovery:** When the device boots, the plugin’s `BootReceiver` receives `BOOT_COMPLETED` (and/or `LOCKED_BOOT_COMPLETED`) and calls into the plugin to reschedule alarms from persisted schedule data.
|
||||
|
||||
---
|
||||
|
||||
## Android behavior: alarm persistence across reboot is implementation-dependent
|
||||
|
||||
- **Documented behavior:** AlarmManager alarms are **not** guaranteed to persist across a full device reboot; the platform may clear them when the device is turned off and rebooted. Apps are expected to reschedule on `BOOT_COMPLETED`.
|
||||
- **Observed behavior:** On some devices or Android builds, alarms (e.g. from `setAlarmClock()`) **do** survive reboot. So whether the next notification fires after a reboot when boot recovery **skips** depends on the device: if the alarm survived, it can still fire; if it was cleared, it will not fire until the app is opened and reschedules.
|
||||
|
||||
So the **reliable** way to guarantee the next notification fires after reboot is for boot recovery to **always** call `AlarmManager.setAlarmClock()` (or equivalent) again, and not to skip based on “existing PendingIntent.”
|
||||
|
||||
---
|
||||
|
||||
## Scenario 1: observed behavior (schedule → restart → wait for first notification)
|
||||
|
||||
Logcat from a real test (schedule set, device restarted shortly after, app not launched):
|
||||
|
||||
**Before reboot (initial schedule):**
|
||||
```
|
||||
02-24 18:56:36 ... Scheduling next daily alarm: id=daily_timesafari_reminder, nextRun=2026-02-24 19:00:00, source=INITIAL_SETUP
|
||||
02-24 18:56:36 ... Scheduling OS alarm: ... requestCode=53438, scheduleId=daily_timesafari_reminder ...
|
||||
```
|
||||
|
||||
**After reboot (boot recovery):**
|
||||
```
|
||||
02-24 18:56:48 ... Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=2026-02-24 19:00:00, source=BOOT_RECOVERY
|
||||
02-24 18:56:48 ... Existing PendingIntent found for requestCode=53438 - alarm already scheduled
|
||||
```
|
||||
|
||||
**At scheduled time (19:00:00):**
|
||||
```
|
||||
02-24 19:00:00 ... DailyNotificationReceiver: DN|RECEIVE_START action=org.timesafari.daily.NOTIFICATION
|
||||
02-24 19:00:00 ... DailyNotificationWorker: DN|DISPLAY_NOTIF_OK ...
|
||||
02-24 19:00:01 ... DN|ROLLOVER next=1772017200000 scheduleId=daily_rollover_1771930801007 ...
|
||||
```
|
||||
|
||||
So in this run, **boot recovery skipped** (duplicate + existing PendingIntent), but the **initial notification still fired** at 19:00. That implies the alarm **survived the reboot** on this device. On devices where alarms are cleared on reboot, the same skip would mean the initial notification would **not** fire. Conclusion: scenario 1 failure is **device-dependent**; the fix (always reschedule on boot) removes that dependence.
|
||||
|
||||
---
|
||||
|
||||
## Two distinct scenarios (same bug, different “victim” notification)
|
||||
|
||||
The same boot-recovery skip can affect either the **initial** notification or the **rollover** notification, depending on when the user restarts and whether the alarm survived reboot:
|
||||
|
||||
| # | User sequence | What is lost on reboot (if alarms cleared) | What fails if boot recovery skips **and** alarm was cleared |
|
||||
|---|----------------|--------------------------------------------|--------------------------------------------------------------|
|
||||
| **1** | Set schedule → **restart shortly after** → wait for first notification | Alarm for the **first** occurrence (e.g. 19:00 same day). | **Initial** notification never fires. *(Observed on one device: alarm survived, so notification fired despite skip.)* |
|
||||
| **2** | Set schedule → **first notification fires** (rollover set) → restart → wait for next day | Alarm for the **rollover** (next day, e.g. `daily_rollover_*`). | **Rollover** notification never fires. |
|
||||
|
||||
- **Scenario 1:** User configures a daily reminder, then reboots before the first fire. If the alarm is cleared on reboot and boot recovery skips, the first notification never fires. If the alarm survives (as in the logcat above), it can still fire.
|
||||
- **Scenario 2:** After the first fire, the plugin creates a **new** schedule (e.g. `daily_rollover_1771930801007`) and sets an alarm for the next day. If the device reboots, that rollover alarm may or may not persist. If it is cleared and boot recovery only reschedules the primary `daily_timesafari_reminder` (and skips), or does not reschedule the rollover, the rollover notification may not fire.
|
||||
|
||||
In both cases the **fix** is the same: in the boot recovery path, skip the “existing PendingIntent” idempotence check so the plugin always re-registers the alarm(s) after reboot, making behavior reliable regardless of whether the OEM clears alarms.
|
||||
|
||||
---
|
||||
|
||||
## Daily notification flow (relevant parts)
|
||||
|
||||
1. **Initial schedule (app):** User sets a daily time (e.g. 09:00). App calls `scheduleDailyNotification({ time, title, body, id })`. Plugin stores schedule and sets an alarm for the next occurrence (e.g. tomorrow 09:00 if today 09:00 has passed).
|
||||
2. **When the alarm fires:** `DailyNotificationReceiver` runs, shows the notification, and the plugin calls `scheduleNextNotification()` (rollover), which schedules the **next day** at the same time via `NotifyReceiver.scheduleExactNotification(..., ScheduleSource.ROLLOVER_ON_FIRE)`.
|
||||
3. **After reboot:** No alarm exists. `BootReceiver` runs (without the app being launched). It should load the schedule from the DB, compute the next run time, and call the same scheduling path to re-register the alarm with AlarmManager.
|
||||
|
||||
If step 3 does **not** actually register an alarm (because boot recovery skips), and the device **cleared** alarms on reboot, the next notification will not fire until the user opens the app. If the alarm survived reboot (device-dependent), it can still fire despite the skip.
|
||||
|
||||
---
|
||||
|
||||
## Evidence that boot recovery can skip rescheduling
|
||||
|
||||
Boot recovery repeatedly logs that it is **skipping** reschedule (see also `doc/plugin-feedback-android-post-reboot-fallback-text.md` and the Scenario 1 logcat above):
|
||||
|
||||
```
|
||||
Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=..., source=BOOT_RECOVERY
|
||||
Existing PendingIntent found for requestCode=53438 - alarm already scheduled
|
||||
```
|
||||
|
||||
So boot recovery **does not** call `AlarmManager.setAlarmClock()` in those runs; it relies on “existing PendingIntent” and skips. The “existing PendingIntent” comes from the plugin’s idempotence check (e.g. `PendingIntent.getBroadcast(..., FLAG_NO_CREATE)`), which can still return non-null after reboot (e.g. cached by package/requestCode/Intent identity). That does **not** prove an alarm is still registered: on some devices alarms are cleared on reboot, so after a skip there would be no alarm and the next notification would not fire. On other devices (as in the Scenario 1 test above) the alarm can survive, so the notification still fires despite the skip. So the **risk** of a missed notification after reboot is **device-dependent**; the fix (always reschedule on boot) removes that dependence.
|
||||
|
||||
---
|
||||
|
||||
## Root cause (plugin side)
|
||||
|
||||
1. **Idempotence in `scheduleExactNotification`:** Before scheduling, the plugin checks for an “existing” PendingIntent (and possibly DB state). If found, it skips scheduling to avoid duplicates.
|
||||
2. **Boot recovery uses the same path:** When `BootReceiver` runs, it calls into the same scheduling logic with `source = BOOT_RECOVERY` and **without** skipping the idempotence check (default `skipPendingIntentIdempotence = false` or equivalent).
|
||||
3. **After reboot:** The “existing PendingIntent” check can still succeed (e.g. cached), so boot recovery skips and does not call `AlarmManager.setAlarmClock()`. On devices where alarms are cleared on reboot, no alarm is re-registered and the next notification will not fire until the app is opened. On devices where alarms survive (as in the Scenario 1 test), the notification can still fire.
|
||||
|
||||
So the **reliable** behavior is: **boot recovery should always re-register the alarm after reboot** (e.g. by skipping the PendingIntent idempotence check in the boot path), so that the app does not depend on implementation-dependent alarm persistence.
|
||||
|
||||
---
|
||||
|
||||
## Recommended fix (in the plugin)
|
||||
|
||||
**Idea:** In the **boot recovery** path only, force a real reschedule and avoid the “existing PendingIntent” skip. After reboot there is no alarm; treating it as “already scheduled” is wrong.
|
||||
|
||||
**Concrete options:**
|
||||
|
||||
1. **Skip PendingIntent idempotence when source is BOOT_RECOVERY**
|
||||
When calling `NotifyReceiver.scheduleExactNotification` from boot recovery (e.g. from `ReactivationManager.rescheduleAlarmForBoot` or from `BootReceiver`), pass a flag so that the “existing PendingIntent” check is **skipped** (e.g. `skipPendingIntentIdempotence = true` or a dedicated `forceRescheduleAfterBoot = true`).
|
||||
That way, boot recovery always calls `AlarmManager.setAlarmClock()` (or equivalent) and re-registers the alarm, even if `PendingIntent.getBroadcast(..., FLAG_NO_CREATE)` still returns non-null from a pre-reboot cache.
|
||||
|
||||
2. **Separate boot path that never skips**
|
||||
Alternatively, implement a dedicated “reschedule for boot” path that does not go through the same idempotence branch as user/manual reschedule. That path should always compute the next run time from the persisted schedule and call AlarmManager to set the alarm, without checking for an “existing” PendingIntent.
|
||||
|
||||
3. **Do not rely on PendingIntent existence as “alarm is set” after reboot**
|
||||
If the plugin currently infers “alarm already scheduled” from “PendingIntent exists,” that inference is wrong after reboot. Either skip that check when the call is from boot recovery, or after reboot always re-register and only use idempotence for in-process duplicate prevention (e.g. when the user taps “Save” twice in a short time).
|
||||
|
||||
**Recommendation:** Option 1 is the smallest change: in the boot recovery call site(s), pass `skipPendingIntentIdempotence = true` (or the equivalent flag) so that scheduling is not skipped and the alarm is always re-registered after reboot.
|
||||
|
||||
**Will this cause duplicate alarms when the alarm survived reboot?** No. When boot recovery calls `setAlarmClock()` (or equivalent), it uses the same `scheduleId` and thus the same `requestCode` and same Intent (and hence the same logical PendingIntent) as the existing alarm. On Android, setting an alarm with a PendingIntent that matches one already registered **replaces** that alarm; it does not add a second one. So you end up with one alarm either way—either the one that survived reboot (now effectively “confirmed” by the second call) or the one just set if the previous one had been cleared. No duplicate notifications.
|
||||
|
||||
---
|
||||
|
||||
## Verification after fix
|
||||
|
||||
1. Schedule a daily notification for a time a few minutes in the future (or use a test build that allows short intervals).
|
||||
2. Let it fire once so the plugin schedules the rollover (next day).
|
||||
3. **Restart the device** and do **not** open the app.
|
||||
4. Wait until the next scheduled time (next day, or the same day if testing with a second alarm a few minutes later).
|
||||
5. Confirm that the notification **does** fire.
|
||||
6. In logcat after reboot, you should see boot recovery **not** logging “Skipping duplicate schedule” / “Existing PendingIntent found” for this schedule, and you should see the alarm being set (e.g. “Scheduling OS alarm” or similar).
|
||||
|
||||
---
|
||||
|
||||
## App-side behavior
|
||||
|
||||
No change is required in the consuming app for this bug. The app does not reschedule after reboot; that is the plugin’s responsibility via `BootReceiver` and boot recovery. Fixing the plugin so that boot recovery always re-registers the alarm (and does not skip due to PendingIntent idempotence) is sufficient.
|
||||
|
||||
---
|
||||
|
||||
## Short summary for plugin maintainers
|
||||
|
||||
**Issue:** After an Android device restart, boot recovery skips rescheduling when it finds an “existing PendingIntent.” On devices where AlarmManager clears alarms on reboot, that skip means the next daily notification (initial or rollover) will not fire until the app is opened. On devices where alarms survive reboot, the notification can still fire (as observed in a Scenario 1 test). So the failure is device-dependent; the plugin should not rely on alarm persistence.
|
||||
|
||||
**Fix:** In the boot recovery path, when calling `scheduleExactNotification` (or the equivalent), pass a flag to **skip** the “existing PendingIntent” idempotence check (e.g. `skipPendingIntentIdempotence = true`), so that the alarm is always re-registered after reboot and behavior is reliable on all devices.
|
||||
|
||||
---
|
||||
|
||||
## For Cursor (plugin repo) — actionable fix
|
||||
|
||||
Use this section when applying the fix in the **daily-notification-plugin** repo (e.g. with Cursor).
|
||||
|
||||
**Goal:** When rescheduling after boot, **always** register the alarm with AlarmManager. Do not skip because “existing PendingIntent” was found (that check can be true after reboot even though the alarm was cleared).
|
||||
|
||||
**Change:** At every call site where the plugin invokes `NotifyReceiver.scheduleExactNotification` (or the Kotlin equivalent) for **boot recovery** (i.e. when the schedule source is `BOOT_RECOVERY` or the call is from `BootReceiver` / `ReactivationManager.rescheduleAlarmForBoot`), pass **`skipPendingIntentIdempotence = true`** so that the idempotence check is skipped and the alarm is always set.
|
||||
|
||||
**Files to look at (plugin Android code):**
|
||||
|
||||
- **ReactivationManager.kt** — Find `rescheduleAlarmForBoot` (or similar). It likely calls `NotifyReceiver.scheduleExactNotification(...)`. Ensure that call passes `skipPendingIntentIdempotence = true` (and `source = ScheduleSource.BOOT_RECOVERY` if applicable).
|
||||
- **BootReceiver.kt** — If it calls `scheduleExactNotification` or invokes ReactivationManager for boot, ensure that path passes `skipPendingIntentIdempotence = true`.
|
||||
|
||||
**Method signature (for reference):**
|
||||
`NotifyReceiver.scheduleExactNotification(context, triggerAtMillis, config, isStaticReminder, reminderId, scheduleId, source, skipPendingIntentIdempotence)`. The last parameter is what must be `true` for boot recovery.
|
||||
|
||||
**Verification:** After the change, trigger a device reboot (app not launched), then inspect logcat. You should **not** see “Skipping duplicate schedule” / “Existing PendingIntent found” for `source=BOOT_RECOVERY`; you should see “Scheduling OS alarm” (or equivalent) so the alarm is re-registered.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Plugin feedback: Android rollover — two notifications, neither with user content
|
||||
|
||||
**Date:** 2026-02-26 18:03
|
||||
**Target repo:** daily-notification-plugin
|
||||
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||
**Platform:** Android
|
||||
|
||||
## Summary
|
||||
|
||||
When waiting for the rollover notification at the scheduled time:
|
||||
|
||||
1. **Two different notifications fired** — one ~3 minutes before the schedule (21:53), one on the dot (21:56). They showed different text (neither the user’s).
|
||||
2. **Neither notification contained the user-set content** — both used Room/fallback content (`DN|DISPLAY_USE_ROOM_CONTENT`, `skip JIT`), not the static reminder path.
|
||||
3. **Main-thread DB access** — receiver logged `db_fallback_failed` with "Cannot access database on the main thread".
|
||||
|
||||
Fixes are required in the **daily-notification-plugin** (and optionally one app-side improvement). This doc gives the diagnosis and recommended changes.
|
||||
|
||||
---
|
||||
|
||||
## Evidence from Logcat
|
||||
|
||||
Filter: `DNP-SCHEDULE`, `DailyNotificationWorker`, `DailyNotificationReceiver`.
|
||||
|
||||
### First notification (21:53 — 3 minutes before schedule)
|
||||
|
||||
- `display=68ea176c-c9c0-4ef3-bd0c-61b67c8a3982` (UUID-style id)
|
||||
- `DN|WORK_ENQUEUE db_fallback_failed` — DB access on main thread when building work input
|
||||
- `DN|DISPLAY_USE_ROOM_CONTENT id=68ea176c-... (skip JIT)` — content from Room, not static reminder
|
||||
- After display: `DN|ROLLOVER next=1772113980000 scheduleId=daily_rollover_1772027581028 static=false`
|
||||
- New schedule created for **next day at 21:53**
|
||||
|
||||
### Second notification (21:56 — on the dot)
|
||||
|
||||
- `display=notify_1772027760000` (time-based id; 1772027760000 = 2026-02-25 21:56)
|
||||
- `DN|DISPLAY_USE_ROOM_CONTENT id=notify_1772027760000 (skip JIT)` — again Room content, not user text
|
||||
- After display: `DN|ROLLOVER next=1772114160000 scheduleId=daily_rollover_1772027760210 static=false`
|
||||
- New schedule created for **next day at 21:56**
|
||||
|
||||
So the user’s chosen time was **21:56**. The 21:53 alarm was a **separate** schedule (from a previous rollover or prefetch that used 21:53).
|
||||
|
||||
---
|
||||
|
||||
## Root causes
|
||||
|
||||
### 1. Two alarms for two different times
|
||||
|
||||
- **21:53** — Alarm with `notification_id` = UUID (`68ea176c-...`). This matches the “prefetch fallback” or “legacy scheduler” path: when prefetch fails or a rollover is created with a time that doesn’t match the **current** user schedule, the plugin can schedule an alarm with a **random UUID** and default content (see `doc/plugin-feedback-android-duplicate-reminder-notification.md`). So at some point an alarm was set for 21:53 (e.g. a previous day’s rollover for 21:53, or a prefetch that scheduled fallback for 21:53).
|
||||
- **21:56** — Alarm with `notification_id` = `notify_1772027760000`. This is the “real” schedule (user chose 21:56). The id is time-based, not the app’s static reminder id `daily_timesafari_reminder`.
|
||||
|
||||
So there are **two logical schedules** active: one for 21:53 (stale or from prefetch) and one for 21:56. When the user reschedules to 21:56, the plugin must **cancel all previous alarms** for this reminder, including any rollover or prefetch-created alarm for 21:53 (and any other `daily_rollover_*` or UUID-based alarms that belong to the same logical reminder). Otherwise both fire and the user sees two notifications with different text.
|
||||
|
||||
**Plugin fix:** When the app calls `scheduleDailyNotification` with a given `scheduleId` (e.g. `daily_timesafari_reminder`):
|
||||
|
||||
- Cancel **every** alarm that belongs to this reminder: the main schedule, all rollover schedules (`daily_rollover_*` that map to this reminder), and any prefetch-scheduled display alarm (UUID) that was created for this reminder.
|
||||
- For static reminders, **do not** enqueue prefetch work that will create a second alarm (see duplicate-reminder doc). If prefetch is already disabled for static reminders, then the 21:53 UUID alarm likely came from an **old rollover** (previous day’s fire at 21:53). So rollover must either (a) use a **stable** schedule id that gets cancelled when the user reschedules (e.g. same `scheduleId` or a known prefix), or (b) the plugin must cancel by “logical reminder” (e.g. all schedules whose next run is for this reminder) when the user sets a new time.
|
||||
|
||||
### 2. User content not used (USE_ROOM_CONTENT, skip JIT)
|
||||
|
||||
- There is **no** `DN|DISPLAY_STATIC_REMINDER` in the logs. So the worker did **not** receive (or use) static reminder title/body.
|
||||
- Both runs show `DN|DISPLAY_USE_ROOM_CONTENT ... (skip JIT)`: content is loaded from Room by `notification_id` and JIT/fetcher is skipped. So the worker is using **Room content keyed by the run’s notification_id** (UUID or `notify_*`), not by the app’s reminder id `daily_timesafari_reminder`.
|
||||
|
||||
The app stores title/body when it calls `scheduleDailyNotification`; the plugin should store that in a way that survives rollover and is used when the alarm fires. If the **Intent** carries `notification_id` = `notify_1772027760000` (or a UUID) and no title/body (e.g. after reboot or when the rollover PendingIntent doesn’t carry extras), the worker looks up Room by that id. The entity for `daily_timesafari_reminder` (user title/body) is a **different** key, so the worker either finds nothing or finds fallback content written by prefetch for that run.
|
||||
|
||||
**Plugin fix (see also `doc/plugin-feedback-android-post-reboot-fallback-text.md`):**
|
||||
|
||||
- **Receiver:** When the Intent has `notification_id` but **missing** title/body (or `is_static_reminder` is false), resolve the “logical” reminder id (e.g. from `schedule_id` extra, or from a mapping: rollover schedule id → `daily_timesafari_reminder`, or from NotificationContentEntity by schedule id). Load title/body from DB (Schedule or NotificationContentEntity) for that reminder and pass them into the Worker with `is_static_reminder = true`.
|
||||
- **Worker:** When displaying, if input has static reminder title/body, use them and do not overwrite with Room content keyed by run-specific id. When loading from Room by `notification_id`, if the run’s id is a rollover or time-based id, also look up the **canonical** reminder id (e.g. `daily_timesafari_reminder`) and prefer that entity’s title/body if present, so rollover displays user text.
|
||||
- **Rollover scheduling:** When scheduling the next day’s alarm (ROLLOVER_ON_FIRE), pass title/body (or a stable reminder id) so the next fire’s Intent or Worker input can resolve user content. Optionally store title/body on the Schedule entity so boot recovery and rollover can always load them.
|
||||
|
||||
### 3. Main-thread database access
|
||||
|
||||
- `DN|WORK_ENQUEUE db_fallback_failed id=68ea176c-... err=Cannot access database on the main thread...`
|
||||
|
||||
The receiver is trying to read from the DB (e.g. to fill in title/body when extras are missing) on the main thread. Room disallows this.
|
||||
|
||||
**Plugin fix:** In `DailyNotificationReceiver.enqueueNotificationWork`, do **not** call Room/DB on the main thread. Either (a) enqueue the work with the Intent extras only and let the **Worker** load title/body from DB on a background thread, or (b) use a coroutine/background executor in the receiver to load from DB and then enqueue work with the result. Prefer (a) unless the receiver must decide work parameters synchronously.
|
||||
|
||||
---
|
||||
|
||||
## Relation to existing docs
|
||||
|
||||
- **Duplicate reminder** (`doc/plugin-feedback-android-duplicate-reminder-notification.md`): Prefetch should not schedule a second alarm for static reminders. That would prevent a **second** alarm at the **same** time. Here we also have a **second** alarm at a **different** time (21:53 vs 21:56), so in addition the plugin must cancel **all** alarms for the reminder when the user reschedules (including old rollover times).
|
||||
- **Post-reboot fallback text** (`doc/plugin-feedback-android-post-reboot-fallback-text.md`): Same idea — resolve title/body from DB when Intent lacks them; use canonical reminder id / NotificationContentEntity so rollover and post-reboot show user text.
|
||||
- **Rollover after reboot** (`doc/plugin-feedback-android-rollover-after-reboot.md`): Boot recovery should always re-register alarms. Not the direct cause of “two notifications at two times” but relevant for consistency.
|
||||
|
||||
---
|
||||
|
||||
## App-side behavior
|
||||
|
||||
- The app calls `scheduleDailyNotification` once with `id: "daily_timesafari_reminder"`, `time`, `title`, and `body`. It does not manage rollover or prefetch; that is all plugin-side.
|
||||
- **Optional app-side mitigation:** When the user **changes** the reminder time (or turns the reminder off then on with a new time), the app could call a plugin API to “cancel all daily notification alarms for this app” before calling `scheduleDailyNotification` again, if the plugin exposes such a method. That would reduce the chance of leftover 21:53 alarms. The **correct** fix is still plugin-side: when scheduling for `daily_timesafari_reminder`, cancel every existing alarm that belongs to that reminder (including rollover and prefetch-created ones).
|
||||
|
||||
---
|
||||
|
||||
## Verification after plugin fixes
|
||||
|
||||
1. Set a daily reminder for 21:56 with **distinct** custom title/body.
|
||||
2. Wait for the notification (or set it 1–2 minutes ahead). **One** notification at 21:56 with your custom text.
|
||||
3. Let it fire once so rollover is scheduled for next day 21:56. Optionally reboot; next day **one** notification at 21:56 with your custom text.
|
||||
4. Change time to 21:58 and save. Wait until 21:56 and 21:58: **no** notification at 21:56; **one** at 21:58 with your text.
|
||||
5. Logcat: no `db_fallback_failed`; for the display that shows user text, either `DN|DISPLAY_STATIC_REMINDER` or Room lookup by canonical id with user title/body.
|
||||
|
||||
---
|
||||
|
||||
## Short summary for plugin maintainers
|
||||
|
||||
- **Two notifications:** Two different alarms were active (21:53 and 21:56). When the user sets 21:56, the plugin must cancel **all** alarms for this reminder (main + rollover + any prefetch-created), not only the “primary” schedule. For static reminders, prefetch must not schedule a second alarm (see duplicate-reminder doc).
|
||||
- **Wrong content:** Worker used Room content keyed by run id (UUID / `notify_*`), not the app’s reminder id. Resolve canonical reminder id and load title/body from DB in receiver or worker; pass static reminder data into Worker when Intent lacks it; when scheduling rollover, preserve title/body (or stable reminder id) so the next fire shows user text.
|
||||
- **Main-thread DB:** Receiver must not access Room on the main thread; move DB read to Worker or background in receiver.
|
||||
|
||||
---
|
||||
|
||||
## For Cursor (plugin repo) — actionable handoff
|
||||
|
||||
Use this section when applying fixes in the **daily-notification-plugin** repo (e.g. with Cursor). You can paste or @-mention this doc as context.
|
||||
|
||||
**Goal:** (1) Only one notification at the user’s chosen time, with user-set title/body. (2) No main-thread DB access in the receiver.
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **Cancel all alarms for the reminder when the app reschedules**
|
||||
When `scheduleDailyNotification` is called with a given `scheduleId` (e.g. `daily_timesafari_reminder`), cancel every alarm that belongs to this reminder: the main schedule, all rollover schedules (`daily_rollover_*` that correspond to this reminder), and any prefetch-created display alarm (UUID). That prevents a second notification at a stale time (e.g. 21:53 when the user set 21:56).
|
||||
|
||||
2. **Static reminder: no second alarm from prefetch**
|
||||
For static reminders, do not enqueue prefetch work that schedules a second alarm (see duplicate-reminder doc). Prefetch is for “fetch content then display”; for static reminders the single NotifyReceiver alarm is enough.
|
||||
|
||||
3. **Use user title/body when displaying (receiver + worker)**
|
||||
When the Intent has `notification_id` but missing title/body (or `is_static_reminder` false), resolve the canonical reminder id (e.g. from `schedule_id`, or rollover id → reminder id, or NotificationContentEntity by schedule). Load title/body from DB and pass into Worker with `is_static_reminder = true`. In the worker, when displaying rollover or time-based runs, prefer content for the canonical reminder id so user text is shown. When scheduling rollover (ROLLOVER_ON_FIRE), pass or persist title/body (or stable reminder id) so the next day’s fire can resolve them.
|
||||
|
||||
4. **No DB on main thread in receiver**
|
||||
In `DailyNotificationReceiver.enqueueNotificationWork`, do not call Room/DB on the main thread. Either enqueue work with Intent extras only and let the Worker load title/body on a background thread, or use a coroutine/background executor in the receiver before enqueueing.
|
||||
|
||||
**Files to look at (plugin Android):** ScheduleHelper / NotifyReceiver (cancel all alarms for reminder; schedule with correct id); DailyNotificationReceiver (no main-thread DB; optionally pass static reminder data from DB on background thread); DailyNotificationWorker (use static reminder input; resolve canonical id from Room when run id is rollover/notify_*); DailyNotificationFetchWorker (do not schedule second alarm for static reminders).
|
||||
104
doc/plugin-feedback-android-rollover-interval-bugs.md
Normal file
104
doc/plugin-feedback-android-rollover-interval-bugs.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Plugin feedback: Android rollover interval – two bugs (logcat evidence)
|
||||
|
||||
**Date:** 2026-03-04
|
||||
**Target:** daily-notification-plugin (Android)
|
||||
**Feature:** `rolloverIntervalMinutes` (e.g. 10 minutes for testing)
|
||||
**Result:** Two bugs prevent rollover notifications from firing every 10 minutes when the user does not open the app.
|
||||
|
||||
---
|
||||
|
||||
## Test setup
|
||||
|
||||
- Schedule set with **rolloverIntervalMinutes=10** (e.g. first run 20:05).
|
||||
- Expected: notification at 20:05, 20:15, 20:25, 20:35, 20:40 (after user edit), 20:50, 21:00, 21:10, 21:20, …
|
||||
- User did **not** open the app between 20:25 and 20:36, or between 21:10 and 21:20.
|
||||
|
||||
---
|
||||
|
||||
## Bug 1: Rollover interval not applied when the firing run is a rollover schedule
|
||||
|
||||
### Observed
|
||||
|
||||
- **20:25** – Notification fired (room content; work id UUID, scheduleId `daily_rollover_1772540701872`).
|
||||
- **20:35** – **No notification.**
|
||||
|
||||
### Logcat evidence (20:25 fire)
|
||||
|
||||
There is **no** `DN|ROLLOVER_INTERVAL` or `DN|ROLLOVER_NEXT using_interval_minutes=10` in this block. Next run is set to **next day** at 20:25, not today 20:35:
|
||||
|
||||
```
|
||||
03-03 20:25:01.844 D/DailyNotificationWorker: DN|RESCHEDULE_START id=29e1e984-d8b2-49ea-bb69-68b923fe4428
|
||||
03-03 20:25:01.874 D/DailyNotificationWorker: DN|ROLLOVER next=1772627100000 scheduleId=daily_rollover_1772540701872 static=false
|
||||
03-03 20:25:01.928 I/DNP-SCHEDULE: Scheduling next daily alarm: id=daily_rollover_1772540701872, nextRun=2026-03-04 20:25:00, source=ROLLOVER_ON_FIRE
|
||||
```
|
||||
|
||||
Compare with a fire that **does** use the interval (e.g. 20:15):
|
||||
|
||||
```
|
||||
03-03 20:15:01.860 D/DailyNotificationWorker: DN|ROLLOVER_INTERVAL scheduleId=daily_timesafari_reminder minutes=10
|
||||
03-03 20:15:01.862 D/DailyNotificationWorker: DN|ROLLOVER_NEXT using_interval_minutes=10 next=1772540700870
|
||||
03-03 20:15:01.870 D/DailyNotificationWorker: DN|ROLLOVER next=1772540700870 scheduleId=daily_timesafari_reminder static=false
|
||||
```
|
||||
|
||||
### Root cause
|
||||
|
||||
When the notification that just fired was scheduled from a **previous** rollover (i.e. work id is UUID / scheduleId is `daily_rollover_*`), the rollover path appears to use **+24 hours** and never reads or applies the stored `rolloverIntervalMinutes`. The interval is only applied when the firing schedule is the main/canonical one (e.g. `daily_timesafari_reminder`).
|
||||
|
||||
### Required fix
|
||||
|
||||
When scheduling the next run after a notification fires (rollover path), **always** resolve the **logical** schedule (e.g. map `daily_rollover_*` back to the main schedule id) and read the stored `rolloverIntervalMinutes` for that reminder. If present and > 0, set next trigger = current trigger + that many minutes (using the same logic as the path that already logs `ROLLOVER_INTERVAL` / `ROLLOVER_NEXT`). Only use +24 hours when the interval is absent or 0.
|
||||
|
||||
---
|
||||
|
||||
## Bug 2: ROLLOVER_ON_FIRE reschedule skipped as “duplicate” so next alarm is never set
|
||||
|
||||
### Observed
|
||||
|
||||
- **21:10** – Notification fired; worker correctly computes next = 21:20 (epoch 1772544000862).
|
||||
- **21:20** – **No notification.**
|
||||
|
||||
### Logcat evidence (21:10 fire)
|
||||
|
||||
Worker applies interval and requests next at 21:20; schedule layer skips and does **not** set the alarm:
|
||||
|
||||
```
|
||||
03-03 21:10:01.281 D/DailyNotificationWorker: DN|ROLLOVER_INTERVAL scheduleId=daily_timesafari_reminder minutes=10
|
||||
03-03 21:10:01.284 D/DailyNotificationWorker: DN|ROLLOVER_NEXT using_interval_minutes=10 next=1772544000862
|
||||
03-03 21:10:01.294 D/DailyNotificationWorker: DN|ROLLOVER next=1772544000862 scheduleId=daily_timesafari_reminder static=false
|
||||
03-03 21:10:01.313 W/DNP-SCHEDULE: Skipping duplicate schedule: id=daily_timesafari_reminder, nextRun=2026-03-03 21:20:00, source=ROLLOVER_ON_FIRE
|
||||
03-03 21:10:01.314 W/DNP-SCHEDULE: Existing PendingIntent found for requestCode=53438 - alarm already scheduled
|
||||
03-03 21:10:01.332 I/DailyNotificationWorker: DN|RESCHEDULE_OK ...
|
||||
```
|
||||
|
||||
So the worker reports RESCHEDULE_OK, but the scheduler did **not** call through to set the OS alarm for 21:20. The “existing” PendingIntent was for the alarm that **just fired** (21:10). Idempotence is preventing the **update** to the new trigger time.
|
||||
|
||||
### Root cause
|
||||
|
||||
Duplicate/idempotence logic (e.g. “Existing PendingIntent found for requestCode=53438”) is applied in a way that skips scheduling when the same schedule id is used with a **new** trigger time. For `source=ROLLOVER_ON_FIRE`, the same schedule id is **supposed** to be updated to a new trigger time every time a rollover fires. Skipping when only the trigger time changes breaks the rollover chain.
|
||||
|
||||
### Required fix
|
||||
|
||||
For `source=ROLLOVER_ON_FIRE`, do **not** skip scheduling when the only “match” is the same schedule id with a **different** `nextRun`/trigger time. Either:
|
||||
|
||||
- Treat “same schedule id, different trigger time” as an **update**: cancel the existing alarm (or PendingIntent) for that schedule and set the new one for the new trigger time, or
|
||||
- In the idempotence check, require that the **existing** alarm’s trigger time equals the **requested** trigger time before skipping; if the requested time is different, proceed with cancel + set.
|
||||
|
||||
After the fix, when the 21:10 alarm fires and the worker requests next at 21:20, the schedule layer should cancel the 21:10 alarm and set a new alarm for 21:20 (same schedule id, new trigger).
|
||||
|
||||
---
|
||||
|
||||
## Desired behavior (for reference)
|
||||
|
||||
Once both bugs are fixed:
|
||||
|
||||
- Rollover notifications should keep being scheduled every `rolloverIntervalMinutes` (e.g. 10 minutes) **without the user opening the app** between fires.
|
||||
- Flow: alarm fires → Receiver → Worker (display + reschedule) → schedule layer sets next alarm. All of this runs when the alarm fires; no app launch required.
|
||||
|
||||
---
|
||||
|
||||
## Summary table
|
||||
|
||||
| Time | Expected | Actual | Bug |
|
||||
|--------|------------------------|---------------|-----|
|
||||
| 20:35 | Rollover notification | No notification | **Bug 1:** Rollover from `daily_rollover_*` path uses +24h instead of `rolloverIntervalMinutes`. |
|
||||
| 21:20 | Rollover notification | No notification | **Bug 2:** Schedule layer skips with “Skipping duplicate schedule” / “Existing PendingIntent found”; 21:20 alarm never set. |
|
||||
108
doc/plugin-fix-scheduleExactNotification-calls.md
Normal file
108
doc/plugin-fix-scheduleExactNotification-calls.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Plugin fix: Update Java call sites for scheduleExactNotification (8th parameter)
|
||||
|
||||
## Problem
|
||||
|
||||
After adding the 8th parameter `skipPendingIntentIdempotence: Boolean = false` to `NotifyReceiver.scheduleExactNotification()` in NotifyReceiver.kt, the Java callers still pass only 7 arguments. That causes a compilation error when building an app that depends on the plugin:
|
||||
|
||||
```
|
||||
error: method scheduleExactNotification in class NotifyReceiver cannot be applied to given types;
|
||||
required: Context,long,UserNotificationConfig,boolean,String,String,ScheduleSource,boolean
|
||||
found: Context,long,UserNotificationConfig,boolean,<null>,String,ScheduleSource
|
||||
reason: actual and formal argument lists differ in length
|
||||
```
|
||||
|
||||
**Affected files (in the plugin repo):**
|
||||
- `android/src/main/java/org/timesafari/dailynotification/DailyNotificationReceiver.java`
|
||||
- `android/src/main/java/org/timesafari/dailynotification/DailyNotificationWorker.java`
|
||||
|
||||
## Current Kotlin signature (NotifyReceiver.kt)
|
||||
|
||||
```kotlin
|
||||
fun scheduleExactNotification(
|
||||
context: Context,
|
||||
triggerAtMillis: Long,
|
||||
config: UserNotificationConfig,
|
||||
isStaticReminder: Boolean = false,
|
||||
reminderId: String? = null,
|
||||
scheduleId: String? = null,
|
||||
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE,
|
||||
skipPendingIntentIdempotence: Boolean = false // 8th parameter
|
||||
)
|
||||
```
|
||||
|
||||
## Required change
|
||||
|
||||
In both Java files, add the **8th argument** to every call to `NotifyReceiver.scheduleExactNotification(...)`.
|
||||
|
||||
### 1. DailyNotificationReceiver.java
|
||||
|
||||
**Location:** around line 441, inside `scheduleNextNotification()`.
|
||||
|
||||
**Current call:**
|
||||
```java
|
||||
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
nextScheduledTime,
|
||||
config,
|
||||
false, // isStaticReminder
|
||||
null, // reminderId
|
||||
scheduleId,
|
||||
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
|
||||
);
|
||||
```
|
||||
|
||||
**Fixed call (add 8th argument):**
|
||||
```java
|
||||
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
nextScheduledTime,
|
||||
config,
|
||||
false, // isStaticReminder
|
||||
null, // reminderId
|
||||
scheduleId,
|
||||
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
|
||||
false // skipPendingIntentIdempotence – rollover path does not skip
|
||||
);
|
||||
```
|
||||
|
||||
### 2. DailyNotificationWorker.java
|
||||
|
||||
**Location:** around line 584, inside `scheduleNextNotification()`.
|
||||
|
||||
**Current call:**
|
||||
```java
|
||||
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
getApplicationContext(),
|
||||
nextScheduledTime,
|
||||
config,
|
||||
false, // isStaticReminder
|
||||
null, // reminderId
|
||||
scheduleId,
|
||||
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
|
||||
);
|
||||
```
|
||||
|
||||
**Fixed call (add 8th argument):**
|
||||
```java
|
||||
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
getApplicationContext(),
|
||||
nextScheduledTime,
|
||||
config,
|
||||
false, // isStaticReminder
|
||||
null, // reminderId
|
||||
scheduleId,
|
||||
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
|
||||
false // skipPendingIntentIdempotence – rollover path does not skip
|
||||
);
|
||||
```
|
||||
|
||||
## Other call sites
|
||||
|
||||
Kotlin call sites (NotifyReceiver.kt, DailyNotificationPlugin.kt, ReactivationManager.kt, BootReceiver.kt) use **named parameters**, so they already get the default for `skipPendingIntentIdempotence` and do not need changes. Only the **Java** call sites use positional arguments, so only the two files above need the 8th argument added. If you add new Java call sites later, pass the 8th parameter explicitly: `false` for rollover/fire paths, `true` only where the caller has just cancelled this schedule and you intend to skip the PendingIntent idempotence check.
|
||||
|
||||
## Verification
|
||||
|
||||
After updating the plugin:
|
||||
|
||||
1. Build the plugin (e.g. `./gradlew :timesafari-daily-notification-plugin:compileDebugJavaWithJavac` or full Android build from a consuming app).
|
||||
2. Ensure there are no “actual and formal argument lists differ in length” errors.
|
||||
148
doc/plugin-spec-rollover-interval-minutes.md
Normal file
148
doc/plugin-spec-rollover-interval-minutes.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Plugin spec: Configurable rollover interval (e.g. 10 minutes for testing)
|
||||
|
||||
**Date:** 2026-03-03
|
||||
**Target repo:** daily-notification-plugin
|
||||
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||
**Platforms:** iOS and Android
|
||||
|
||||
## Summary
|
||||
|
||||
The consuming app needs to support **rapid testing** of daily notification rollover on device. Today, after a notification fires, the plugin always schedules the next occurrence **24 hours** later. We need an **optional** parameter so the app can request a different interval (e.g. **10 minutes**) for dev/testing. When that parameter is present, the plugin must:
|
||||
|
||||
1. Use the given interval (in minutes) when scheduling the **next** occurrence after a notification fires (rollover).
|
||||
2. **Persist** that interval with the schedule so that it survives **device reboot** and is used again when:
|
||||
- Boot recovery reschedules alarms from stored data, and
|
||||
- Any subsequent rollover runs (after the next notification fires).
|
||||
|
||||
If the interval is not persisted, then after a device restart the plugin would no longer know to use 10 minutes and would fall back to 24 hours; rapid testing after reboot would break. So persistence is a **required** part of this feature.
|
||||
|
||||
---
|
||||
|
||||
## API contract (app → plugin)
|
||||
|
||||
### Method: `scheduleDailyNotification` (or equivalent used for the app’s daily reminder)
|
||||
|
||||
**Add an optional parameter:**
|
||||
|
||||
- **Name:** `rolloverIntervalMinutes` (or equivalent, e.g. `repeatIntervalMinutes`).
|
||||
- **Type:** `number` (integer), optional.
|
||||
- **Meaning:** When the scheduled notification fires, schedule the **next** occurrence this many **minutes** after the current trigger time (instead of 24 hours). When **absent** or not provided, behavior is unchanged: next occurrence is **24 hours** later (current behavior).
|
||||
|
||||
**Example (pseudocode):**
|
||||
|
||||
- App calls: `scheduleDailyNotification({ id, time, title, body, ..., rolloverIntervalMinutes: 10 })`.
|
||||
- Plugin stores the schedule **including** `rolloverIntervalMinutes: 10`.
|
||||
- When the notification fires, plugin computes next trigger = current trigger + 10 minutes (instead of + 24 hours), and schedules that.
|
||||
- When the device reboots, boot recovery loads the schedule, sees `rolloverIntervalMinutes: 10`, and uses it when (a) computing the next run time for reschedule and (b) any future rollover after the next fire.
|
||||
|
||||
**Example (normal production, no param):**
|
||||
|
||||
- App calls: `scheduleDailyNotification({ id, time, title, body })` (no `rolloverIntervalMinutes`).
|
||||
- Plugin stores the schedule with no interval (or default 24h).
|
||||
- Rollover and boot recovery behave as today: next occurrence 24 hours later.
|
||||
|
||||
---
|
||||
|
||||
## Persistence requirement (critical for device restart)
|
||||
|
||||
The rollover interval must be **stored with the schedule** in the plugin’s persistent storage (e.g. Room on Android, UserDefaults/DB on iOS), not only kept in memory. Concretely:
|
||||
|
||||
1. **When the app calls `scheduleDailyNotification` with `rolloverIntervalMinutes`:**
|
||||
- Persist that value in the same place you persist the rest of the schedule (e.g. Schedule entity, or equivalent table/row that is read on boot and on rollover).
|
||||
|
||||
2. **When computing the next occurrence (rollover path, after a notification fires):**
|
||||
- Read the stored `rolloverIntervalMinutes` for that schedule.
|
||||
- If present and > 0: next trigger = current trigger + `rolloverIntervalMinutes` minutes.
|
||||
- If absent or 0: next trigger = current trigger + 24 hours (existing behavior).
|
||||
|
||||
3. **When boot recovery runs (after device restart):**
|
||||
- Load schedules from persistent storage (including the stored `rolloverIntervalMinutes`).
|
||||
- When rescheduling each alarm, use the stored interval to compute the next run time (same logic as rollover: if interval is set, use it; otherwise 24 hours).
|
||||
- When the next notification fires after reboot, the rollover path will again read the same stored value, so the 10-minute (or whatever) interval continues to apply.
|
||||
|
||||
4. **When the app calls `scheduleDailyNotification` without `rolloverIntervalMinutes` (or to turn off fast rollover):**
|
||||
- Overwrite the stored schedule so that the interval is cleared or set to default (24h). Subsequent rollovers and boot recovery then use 24 hours again.
|
||||
|
||||
**Why this matters:** Without persisting the interval, a device restart would lose the “10 minutes” setting; rollover and boot recovery would have no way to know to use 10 minutes and would default to 24 hours. Rapid testing after reboot would not work.
|
||||
|
||||
---
|
||||
|
||||
## Platform-specific notes
|
||||
|
||||
### Android
|
||||
|
||||
- **Storage:** Add `rollover_interval_minutes` (or equivalent) to the Schedule entity (or wherever the app’s reminder schedule is stored) and persist it when handling `scheduleDailyNotification`. Use it in:
|
||||
- Rollover path (e.g. when scheduling next alarm after notification fires).
|
||||
- Boot recovery path (when rebuilding alarms from DB after `BOOT_COMPLETED`).
|
||||
- **Next trigger:** Current trigger time + `rolloverIntervalMinutes` minutes (using `Calendar` or equivalent so DST/timezone is handled correctly; same care as for 24h rollover).
|
||||
|
||||
### iOS
|
||||
|
||||
- **Storage:** Add the same field to whatever persistent structure holds the schedule (e.g. the same place that stores time, title, body, id). Persist it when the app calls the schedule method.
|
||||
- **Rollover:** In `scheduleNextNotification()` (or equivalent), read the stored interval; if set, use `Calendar.date(byAdding: .minute, value: rolloverIntervalMinutes, to: currentDate)` (or equivalent) instead of adding 24 hours.
|
||||
- **App launch / recovery:** If the plugin has any path that restores or reschedules after app launch or system events, use the stored interval there as well so behavior is consistent.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases and defaults
|
||||
|
||||
- **Parameter absent:** Do not change current behavior. Next occurrence = 24 hours later.
|
||||
- **Parameter = 0 or negative:** Treat as “use default”; same as absent (24 hours).
|
||||
- **Parameter > 0 (e.g. 10):** Next occurrence = current trigger + that many minutes.
|
||||
- **Existing schedules (created before this feature):** No stored interval → treat as 24 hours. No migration required beyond “missing field = default”.
|
||||
|
||||
---
|
||||
|
||||
## App-side behavior (for context)
|
||||
|
||||
- The app will only pass `rolloverIntervalMinutes` when a **dev-only** setting is enabled (e.g. “Use 10-minute rollover for testing” in the Notifications section). Production users will not set it.
|
||||
- The app will pass it on every `scheduleDailyNotification` call when the user has that setting on (first-time enable and edit). When the user turns the setting off, the app will call `scheduleDailyNotification` without the parameter (so the plugin can persist “no interval” / 24h).
|
||||
|
||||
---
|
||||
|
||||
## Verification (plugin repo)
|
||||
|
||||
1. **Rollover with interval:** Schedule with `rolloverIntervalMinutes: 10`. Trigger the notification (or wait). Confirm the **next** scheduled time is ~10 minutes after the current trigger (not 24 hours). Let it fire again; confirm the following occurrence is again ~10 minutes later.
|
||||
2. **Persistence:** Schedule with `rolloverIntervalMinutes: 10`, then **restart the device** (do not open the app). After boot, confirm (via logs or next fire) that the rescheduled alarm uses the 10-minute interval (e.g. next fire is 10 minutes after the last stored trigger, not 24 hours). After that notification fires, confirm the **next** rollover is still 10 minutes later.
|
||||
3. **Default:** Schedule without `rolloverIntervalMinutes`. Confirm next occurrence is 24 hours later. Reboot; confirm boot recovery still uses 24 hours.
|
||||
4. **Turn off:** Schedule with 10 minutes, then have the app call `scheduleDailyNotification` again with the same id/time but **no** `rolloverIntervalMinutes`. Confirm stored interval is cleared and next rollover is 24 hours.
|
||||
|
||||
---
|
||||
|
||||
## Short summary for plugin maintainers
|
||||
|
||||
- **New optional parameter:** `rolloverIntervalMinutes?: number` on the schedule method used for the app’s daily reminder.
|
||||
- **When set (e.g. 10):** After a notification fires, schedule the next occurrence in that many **minutes** instead of 24 hours.
|
||||
- **Must persist:** Store the value with the schedule in the plugin’s DB/storage. Use it in **rollover** and in **boot recovery** so that after a device restart the same interval is used. Without persistence, the feature would not work after reboot.
|
||||
- **When absent:** Behavior unchanged (24-hour rollover). No migration needed for existing schedules.
|
||||
|
||||
---
|
||||
|
||||
## For Cursor (plugin repo) — actionable handoff
|
||||
|
||||
Use this section when implementing this feature in the **daily-notification-plugin** repo (e.g. with Cursor). You can paste or @-mention this doc as context.
|
||||
|
||||
**Goal:** Support an optional `rolloverIntervalMinutes` (or equivalent) on the daily reminder schedule API. When provided (e.g. `10`), schedule the next occurrence that many minutes after the current trigger instead of 24 hours. **Persist this value** with the schedule so that rollover and boot recovery both use it; after a device restart, the same interval must still apply.
|
||||
|
||||
**Concrete tasks:**
|
||||
|
||||
1. **API:** In the plugin interface used by the app (e.g. `scheduleDailyNotification`), add an optional parameter `rolloverIntervalMinutes?: number`. Document that when absent, next occurrence is 24 hours (current behavior).
|
||||
|
||||
2. **Storage (Android):** In the Schedule entity (or equivalent), add a column/field for the rollover interval (e.g. `rollover_interval_minutes` nullable Int). When handling `scheduleDailyNotification`, persist the value if present; if absent, store null or 0 to mean “24 hours”.
|
||||
|
||||
3. **Storage (iOS):** Add the same field to the persistent structure that holds the reminder schedule. Persist it when the app calls the schedule method.
|
||||
|
||||
4. **Rollover (both platforms):** In the code that runs when a scheduled notification fires and schedules the next occurrence:
|
||||
- Read the stored `rolloverIntervalMinutes` for that schedule.
|
||||
- If present and > 0: next trigger = current trigger + that many minutes (using Calendar/date APIs that respect timezone/DST).
|
||||
- Else: next trigger = current trigger + 24 hours (existing behavior).
|
||||
- Persist the same interval on the new schedule record so the next rollover still uses it.
|
||||
|
||||
5. **Boot recovery (both platforms):** In the path that runs after device reboot and reschedules from stored data:
|
||||
- Load the stored `rolloverIntervalMinutes` with each schedule.
|
||||
- When computing “next run time” for reschedule, use the same logic: if interval set, current trigger + that many minutes; else + 24 hours.
|
||||
- Do not rely on in-memory state; always read from persisted storage so behavior is correct after restart.
|
||||
|
||||
6. **Clearing the interval:** When the app calls `scheduleDailyNotification` without `rolloverIntervalMinutes` (e.g. user turned off “fast rollover” in the app), overwrite the stored schedule so the interval field is null/0. Subsequent rollovers and boot recovery then use 24 hours.
|
||||
|
||||
7. **Tests:** Add or extend tests for: (a) rollover with 10 minutes, (b) boot recovery with stored 10-minute interval, (c) default 24h when parameter absent or cleared.
|
||||
528
doc/shared-image-plugin-implementation-plan.md
Normal file
528
doc/shared-image-plugin-implementation-plan.md
Normal file
@@ -0,0 +1,528 @@
|
||||
# Shared Image Plugin Implementation Plan
|
||||
|
||||
**Date:** 2025-12-03 15:40:38 PST
|
||||
**Status:** Planning
|
||||
**Goal:** Replace temp file approach with native Capacitor plugins for iOS and Android
|
||||
|
||||
## Minimum OS Version Compatibility Analysis
|
||||
|
||||
### Current Project Configuration:
|
||||
- **iOS Deployment Target**: 13.0 (Podfile and Xcode project)
|
||||
- **Android minSdkVersion**: 23 (API 23 - Android 6.0 Marshmallow) ✅ **Upgraded**
|
||||
- **Capacitor Version**: 6.2.0
|
||||
|
||||
### Capacitor 6 Requirements:
|
||||
- **iOS**: Requires iOS 13.0+ ✅ **Compatible** (current: 13.0)
|
||||
- **Android**: Requires API 23+ ✅ **Compatible** (current: API 23)
|
||||
|
||||
### Plugin API Compatibility:
|
||||
|
||||
#### iOS Plugin APIs:
|
||||
- ✅ `CAPPlugin` base class: Available in iOS 13.0+ (Capacitor requirement)
|
||||
- ✅ `CAPPluginCall`: Available in iOS 13.0+ (Capacitor requirement)
|
||||
- ✅ `UserDefaults(suiteName:)`: Available since iOS 8.0 (well below iOS 13.0)
|
||||
- ✅ `@objc` annotations: Available since iOS 8.0
|
||||
- ✅ Swift 5.0: Compatible with iOS 13.0+
|
||||
|
||||
**Conclusion**: iOS 13.0 is fully compatible with the plugin implementation. **No iOS version update required.**
|
||||
|
||||
#### Android Plugin APIs:
|
||||
- ✅ `Plugin` base class: Available in API 21+ (Capacitor requirement)
|
||||
- ✅ `PluginCall`: Available in API 21+ (Capacitor requirement)
|
||||
- ✅ `SharedPreferences`: Available since API 1 (works on all Android versions)
|
||||
- ✅ `@CapacitorPlugin` annotation: Available in API 21+ (Capacitor requirement)
|
||||
- ✅ `@PluginMethod` annotation: Available in API 21+ (Capacitor requirement)
|
||||
|
||||
**Conclusion**: Android API 23 is fully compatible with the plugin implementation and officially meets Capacitor 6 requirements. ✅ **No Android version concerns.**
|
||||
|
||||
### Share Extension Compatibility:
|
||||
- **iOS Share Extension**: Uses same deployment target as main app (iOS 13.0)
|
||||
- **App Group**: Available since iOS 8.0, fully compatible
|
||||
- No additional version requirements for share extension functionality
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the migration from the current temp file approach to implementing dedicated Capacitor plugins for handling shared images. This will eliminate file I/O, polling, and timing issues, providing a more direct and reliable native-to-JS bridge.
|
||||
|
||||
## Current Implementation Issues
|
||||
|
||||
### Temp File Approach Problems:
|
||||
1. **Timing Issues**: Requires polling with exponential backoff to wait for file creation
|
||||
2. **Race Conditions**: File may not exist when JS checks, or may be read multiple times
|
||||
3. **File Management**: Need to delete temp files after reading to prevent re-processing
|
||||
4. **Platform Differences**: Different directories (Documents vs Data) add complexity
|
||||
5. **Error Handling**: File I/O errors can be hard to debug
|
||||
6. **Performance**: File system operations are slower than direct native calls
|
||||
|
||||
## Proposed Solution: Capacitor Plugins
|
||||
|
||||
### Benefits:
|
||||
- ✅ Direct native-to-JS communication (no file I/O)
|
||||
- ✅ Synchronous/async method calls (no polling needed)
|
||||
- ✅ Type-safe TypeScript interfaces
|
||||
- ✅ Better error handling and debugging
|
||||
- ✅ Lower latency
|
||||
- ✅ More maintainable and follows Capacitor best practices
|
||||
|
||||
## Implementation Layout
|
||||
|
||||
### 1. iOS Plugin Implementation
|
||||
|
||||
#### 1.1 Create iOS Plugin File
|
||||
**Location:** `ios/App/App/SharedImagePlugin.swift`
|
||||
|
||||
**Structure:**
|
||||
```swift
|
||||
import Foundation
|
||||
import Capacitor
|
||||
|
||||
@objc(SharedImagePlugin)
|
||||
public class SharedImagePlugin: CAPPlugin {
|
||||
private let appGroupIdentifier = "group.app.timesafari.share"
|
||||
|
||||
@objc func getSharedImage(_ call: CAPPluginCall) {
|
||||
// Read from App Group UserDefaults
|
||||
// Return base64 and fileName
|
||||
// Clear data after reading
|
||||
}
|
||||
|
||||
@objc func hasSharedImage(_ call: CAPPluginCall) {
|
||||
// Check if shared image exists without reading it
|
||||
// Useful for quick checks
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Use existing `getSharedImageData()` logic from AppDelegate
|
||||
- Return data as JSObject with `base64` and `fileName` keys
|
||||
- Clear UserDefaults after reading to prevent re-reading
|
||||
- Handle errors gracefully with `call.reject()`
|
||||
- **Version Compatibility**: Works with iOS 13.0+ (current deployment target)
|
||||
|
||||
#### 1.2 Register Plugin in iOS
|
||||
**Location:** `ios/App/App/AppDelegate.swift`
|
||||
|
||||
**Changes:**
|
||||
- Remove `writeSharedImageToTempFile()` method
|
||||
- Remove temp file writing from `application(_:open:options:)`
|
||||
- Remove temp file writing from `checkForSharedImageOnActivation()`
|
||||
- Keep `getSharedImageData()` method (or move to plugin)
|
||||
- Plugin auto-registers via Capacitor's plugin system
|
||||
|
||||
**Note:** Capacitor plugins are auto-discovered if they follow naming conventions and are in the app bundle.
|
||||
|
||||
### 2. Android Plugin Implementation
|
||||
|
||||
#### 2.1 Create Android Plugin File
|
||||
**Location:** `android/app/src/main/java/app/timesafari/sharedimage/SharedImagePlugin.java`
|
||||
|
||||
**Structure:**
|
||||
```java
|
||||
package app.timesafari.sharedimage;
|
||||
|
||||
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 {
|
||||
|
||||
@PluginMethod
|
||||
public void getSharedImage(PluginCall call) {
|
||||
// Read from SharedPreferences or Intent extras
|
||||
// Return base64 and fileName
|
||||
// Clear data after reading
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void hasSharedImage(PluginCall call) {
|
||||
// Check if shared image exists without reading it
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Use SharedPreferences to store shared image data between share intent and plugin call
|
||||
- Store base64 and fileName when processing share intent
|
||||
- Read and clear in `getSharedImage()` method
|
||||
- Handle Intent extras if app was just launched
|
||||
- **Version Compatibility**: Works with Android API 22+ (current minSdkVersion)
|
||||
|
||||
#### 2.2 Update MainActivity
|
||||
**Location:** `android/app/src/main/java/app/timesafari/MainActivity.java`
|
||||
|
||||
**Changes:**
|
||||
- Remove `writeSharedImageToTempFile()` method
|
||||
- Remove `TEMP_FILE_NAME` constant
|
||||
- Update `processSharedImage()` to store in SharedPreferences instead of file
|
||||
- Register plugin: `registerPlugin(SharedImagePlugin.class);`
|
||||
- Store shared image data in SharedPreferences when processing share intent
|
||||
|
||||
**SharedPreferences Approach:**
|
||||
```java
|
||||
// In processSharedImage():
|
||||
SharedPreferences prefs = getSharedPreferences("shared_image", MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = prefs.edit();
|
||||
editor.putString("base64", base64String);
|
||||
editor.putString("fileName", actualFileName);
|
||||
editor.putBoolean("hasSharedImage", true);
|
||||
editor.apply();
|
||||
```
|
||||
|
||||
### 3. TypeScript/JavaScript Integration
|
||||
|
||||
#### 3.1 Create TypeScript Plugin Definition
|
||||
**Location:** `src/plugins/SharedImagePlugin.ts` (new file)
|
||||
|
||||
**Structure:**
|
||||
```typescript
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
export interface SharedImageResult {
|
||||
base64: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface SharedImagePlugin {
|
||||
getSharedImage(): Promise<SharedImageResult | null>;
|
||||
hasSharedImage(): Promise<{ hasImage: boolean }>;
|
||||
}
|
||||
|
||||
const SharedImage = registerPlugin<SharedImagePlugin>('SharedImage', {
|
||||
web: () => import('./SharedImagePlugin.web').then(m => new m.SharedImagePluginWeb()),
|
||||
});
|
||||
|
||||
export * from './definitions';
|
||||
export { SharedImage };
|
||||
```
|
||||
|
||||
#### 3.2 Create Web Implementation (for development)
|
||||
**Location:** `src/plugins/SharedImagePlugin.web.ts` (new file)
|
||||
|
||||
**Structure:**
|
||||
```typescript
|
||||
import { WebPlugin } from '@capacitor/core';
|
||||
import type { SharedImagePlugin, SharedImageResult } from './definitions';
|
||||
|
||||
export class SharedImagePluginWeb extends WebPlugin implements SharedImagePlugin {
|
||||
async getSharedImage(): Promise<SharedImageResult | null> {
|
||||
// Return null for web platform
|
||||
return null;
|
||||
}
|
||||
|
||||
async hasSharedImage(): Promise<{ hasImage: boolean }> {
|
||||
return { hasImage: false };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 Create Type Definitions
|
||||
**Location:** `src/plugins/definitions.ts` (new file)
|
||||
|
||||
**Structure:**
|
||||
```typescript
|
||||
export interface SharedImageResult {
|
||||
base64: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface SharedImagePlugin {
|
||||
getSharedImage(): Promise<SharedImageResult | null>;
|
||||
hasSharedImage(): Promise<{ hasImage: boolean }>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4 Update main.capacitor.ts
|
||||
**Location:** `src/main.capacitor.ts`
|
||||
|
||||
**Changes:**
|
||||
- Remove `pollForFileExistence()` function
|
||||
- Remove temp file reading logic from `checkAndStoreNativeSharedImage()`
|
||||
- Replace with direct plugin call:
|
||||
|
||||
```typescript
|
||||
async function checkAndStoreNativeSharedImage(): Promise<{
|
||||
success: boolean;
|
||||
fileName?: string;
|
||||
}> {
|
||||
if (isProcessingSharedImage) {
|
||||
logger.debug("[Main] ⏸️ Shared image processing already in progress, skipping");
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
isProcessingSharedImage = true;
|
||||
|
||||
try {
|
||||
if (!Capacitor.isNativePlatform() ||
|
||||
(Capacitor.getPlatform() !== "ios" && Capacitor.getPlatform() !== "android")) {
|
||||
isProcessingSharedImage = false;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
// Direct plugin call - no polling needed!
|
||||
const { SharedImage } = await import('./plugins/SharedImagePlugin');
|
||||
const result = await SharedImage.getSharedImage();
|
||||
|
||||
if (result && result.base64) {
|
||||
await storeSharedImageInTempDB(result.base64, result.fileName);
|
||||
isProcessingSharedImage = false;
|
||||
return { success: true, fileName: result.fileName };
|
||||
}
|
||||
|
||||
isProcessingSharedImage = false;
|
||||
return { success: false };
|
||||
} catch (error) {
|
||||
logger.error("[Main] Error checking for native shared image:", error);
|
||||
isProcessingSharedImage = false;
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Remove:**
|
||||
- `pollForFileExistence()` function (lines 71-98)
|
||||
- All Filesystem plugin imports related to temp file reading
|
||||
- Temp file path constants and directory logic
|
||||
|
||||
### 4. Data Flow Comparison
|
||||
|
||||
#### Current (Temp File) Flow:
|
||||
```
|
||||
Share Extension/Intent
|
||||
↓
|
||||
Native writes temp file
|
||||
↓
|
||||
JS polls for file existence (with retries)
|
||||
↓
|
||||
JS reads file via Filesystem plugin
|
||||
↓
|
||||
JS parses JSON
|
||||
↓
|
||||
JS deletes temp file
|
||||
↓
|
||||
JS stores in temp DB
|
||||
```
|
||||
|
||||
#### New (Plugin) Flow:
|
||||
```
|
||||
Share Extension/Intent
|
||||
↓
|
||||
Native stores in UserDefaults/SharedPreferences
|
||||
↓
|
||||
JS calls plugin.getSharedImage()
|
||||
↓
|
||||
Native reads and clears data
|
||||
↓
|
||||
Native returns data directly
|
||||
↓
|
||||
JS stores in temp DB
|
||||
```
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
### New Files to Create:
|
||||
1. `ios/App/App/SharedImagePlugin.swift` - iOS plugin implementation
|
||||
2. `android/app/src/main/java/app/timesafari/sharedimage/SharedImagePlugin.java` - Android plugin
|
||||
3. `src/plugins/SharedImagePlugin.ts` - TypeScript plugin registration
|
||||
4. `src/plugins/SharedImagePlugin.web.ts` - Web fallback implementation
|
||||
5. `src/plugins/definitions.ts` - TypeScript type definitions
|
||||
|
||||
### Files to Modify:
|
||||
1. `ios/App/App/AppDelegate.swift` - Remove temp file writing
|
||||
2. `android/app/src/main/java/app/timesafari/MainActivity.java` - Remove temp file writing, add SharedPreferences
|
||||
3. `src/main.capacitor.ts` - Replace temp file logic with plugin calls
|
||||
|
||||
### Files to Remove:
|
||||
- No files need to be deleted, but code will be removed from existing files
|
||||
|
||||
## Implementation Considerations
|
||||
|
||||
### 1. Data Storage Strategy
|
||||
|
||||
#### iOS:
|
||||
- **Current**: App Group UserDefaults (already working)
|
||||
- **Plugin**: Read from same UserDefaults, no changes needed
|
||||
- **Clearing**: Clear immediately after reading in plugin method
|
||||
|
||||
#### Android:
|
||||
- **Current**: Temp file in app's internal files directory
|
||||
- **New**: SharedPreferences (persistent key-value store)
|
||||
- **Alternative**: Could use Intent extras if app is launched fresh, but SharedPreferences is more reliable for backgrounded apps
|
||||
|
||||
### 2. Timing and Lifecycle
|
||||
|
||||
#### When to Check for Shared Images:
|
||||
1. **App Launch**: Check in `checkForSharedImageAndNavigate()` (already exists)
|
||||
2. **App Becomes Active**: Check in `appStateChange` listener (already exists)
|
||||
3. **Deep Link**: Check in `handleDeepLink()` for empty path URLs (already exists)
|
||||
|
||||
#### Plugin Call Timing:
|
||||
- Plugin calls are synchronous from JS perspective
|
||||
- No polling needed - native side handles data availability
|
||||
- If no data exists, plugin returns `null` immediately
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
#### Plugin Error Scenarios:
|
||||
- **No shared image**: Return `null` (not an error)
|
||||
- **Data corruption**: Return error via `call.reject()`
|
||||
- **Missing permissions**: Return error (shouldn't happen with App Group/SharedPreferences)
|
||||
|
||||
#### JS Error Handling:
|
||||
- Wrap plugin calls in try-catch
|
||||
- Log errors appropriately
|
||||
- Don't crash app if plugin fails
|
||||
|
||||
### 4. Backward Compatibility
|
||||
|
||||
#### Migration Path:
|
||||
- Keep temp file code temporarily (commented out) for rollback
|
||||
- Test thoroughly on both platforms
|
||||
- Remove temp file code after verification
|
||||
|
||||
### 5. Testing Considerations
|
||||
|
||||
#### Test Cases:
|
||||
1. **Share from Photos app** → Verify image appears in app
|
||||
2. **Share while app is backgrounded** → Verify image appears when app becomes active
|
||||
3. **Share while app is closed** → Verify image appears on app launch
|
||||
4. **Multiple rapid shares** → Verify only latest image is processed
|
||||
5. **Share then close app before processing** → Verify image persists
|
||||
6. **Share then clear app data** → Verify graceful handling
|
||||
|
||||
#### Edge Cases:
|
||||
- Very large images (memory concerns)
|
||||
- Multiple images shared simultaneously
|
||||
- App killed by OS before processing
|
||||
- Network interruptions during processing
|
||||
|
||||
### 6. Performance Considerations
|
||||
|
||||
#### Benefits:
|
||||
- **Latency**: Direct calls vs file I/O (faster)
|
||||
- **CPU**: No polling overhead
|
||||
- **Memory**: No temp file storage
|
||||
- **Battery**: Less file system activity
|
||||
|
||||
#### Potential Issues:
|
||||
- Large base64 strings in memory (same as current approach)
|
||||
- UserDefaults/SharedPreferences size limits (shouldn't be an issue for single image)
|
||||
|
||||
### 7. Type Safety
|
||||
|
||||
#### TypeScript Benefits:
|
||||
- Full type checking for plugin methods
|
||||
- Autocomplete in IDE
|
||||
- Compile-time error checking
|
||||
- Better developer experience
|
||||
|
||||
### 8. Plugin Registration
|
||||
|
||||
#### iOS:
|
||||
- Capacitor auto-discovers plugins via naming convention
|
||||
- Ensure plugin is in app target (not extension target)
|
||||
- No manual registration needed in AppDelegate
|
||||
|
||||
#### Android:
|
||||
- Register in `MainActivity.onCreate()`:
|
||||
```java
|
||||
registerPlugin(SharedImagePlugin.class);
|
||||
```
|
||||
|
||||
### 9. Capacitor Version Compatibility
|
||||
|
||||
#### Check Current Version:
|
||||
- Verify Capacitor version supports custom plugins
|
||||
- Ensure plugin API hasn't changed
|
||||
- Test with current Capacitor version first
|
||||
|
||||
### 10. Build and Deployment
|
||||
|
||||
#### Build Steps:
|
||||
1. Create plugin files
|
||||
2. Register Android plugin in MainActivity
|
||||
3. Update TypeScript code
|
||||
4. Test on iOS simulator
|
||||
5. Test on Android emulator
|
||||
6. Test on physical devices
|
||||
7. Remove temp file code
|
||||
8. Update documentation
|
||||
|
||||
#### Deployment:
|
||||
- No changes to build scripts needed
|
||||
- No changes to CI/CD needed
|
||||
- No changes to app configuration needed
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### Phase 1: Create Plugins (Non-Breaking)
|
||||
1. Create iOS plugin file
|
||||
2. Create Android plugin file
|
||||
3. Create TypeScript definitions
|
||||
4. Register Android plugin
|
||||
5. Test plugins independently (don't use in main code yet)
|
||||
|
||||
### Phase 2: Update JS Integration (Breaking)
|
||||
1. Create TypeScript plugin wrapper
|
||||
2. Update `checkAndStoreNativeSharedImage()` to use plugin
|
||||
3. Remove temp file reading logic
|
||||
4. Test on both platforms
|
||||
|
||||
### Phase 3: Cleanup Native Code (Breaking)
|
||||
1. Remove temp file writing from iOS AppDelegate
|
||||
2. Remove temp file writing from Android MainActivity
|
||||
3. Update to use SharedPreferences on Android
|
||||
4. Test thoroughly
|
||||
|
||||
### Phase 4: Final Cleanup
|
||||
1. Remove `pollForFileExistence()` function
|
||||
2. Remove Filesystem imports related to temp files
|
||||
3. Update comments and documentation
|
||||
4. Final testing
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise:
|
||||
1. Revert JS changes to use temp file approach
|
||||
2. Re-enable temp file writing in native code
|
||||
3. Keep plugins for future migration attempt
|
||||
4. Document issues encountered
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ Plugin methods work on both iOS and Android
|
||||
✅ No polling or file I/O needed
|
||||
✅ Shared images appear correctly in app
|
||||
✅ No memory leaks or performance issues
|
||||
✅ Error handling works correctly
|
||||
✅ All test cases pass
|
||||
✅ Code is cleaner and more maintainable
|
||||
|
||||
## Additional Notes
|
||||
|
||||
### iOS App Group:
|
||||
- Current App Group ID: `group.app.timesafari.share`
|
||||
- Ensure plugin has access to same App Group
|
||||
- Share Extension already writes to this App Group
|
||||
|
||||
### Android Share Intent:
|
||||
- Current implementation handles `ACTION_SEND` and `ACTION_SEND_MULTIPLE`
|
||||
- SharedPreferences key: `shared_image` (or similar)
|
||||
- Store both base64 and fileName
|
||||
|
||||
### Future Enhancements:
|
||||
- Consider adding event listeners for real-time notifications
|
||||
- Could add method to clear shared image without reading
|
||||
- Could add method to get image metadata without full data
|
||||
|
||||
## References
|
||||
|
||||
- [Capacitor Plugin Development Guide](https://capacitorjs.com/docs/plugins)
|
||||
- Existing plugin example: `SafeAreaPlugin.java`
|
||||
- Current temp file implementation: `main.capacitor.ts` lines 166-271
|
||||
- iOS AppDelegate: `ios/App/App/AppDelegate.swift`
|
||||
- Android MainActivity: `android/app/src/main/java/app/timesafari/MainActivity.java`
|
||||
|
||||
329
doc/shared-image-plugin-pre-implementation-decisions.md
Normal file
329
doc/shared-image-plugin-pre-implementation-decisions.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Shared Image Plugin - Pre-Implementation Decision Checklist
|
||||
|
||||
**Date:** 2025-12-03
|
||||
**Status:** Pre-Implementation Planning
|
||||
**Purpose:** Identify and document decisions needed before implementing SharedImagePlugin
|
||||
|
||||
## ✅ Completed Decisions
|
||||
|
||||
### 1. Minimum OS Versions
|
||||
- ✅ **iOS**: Keep at 13.0 (no changes needed)
|
||||
- ✅ **Android**: Upgraded from API 22 to API 23 (completed)
|
||||
- ✅ **Rationale**: Meets Capacitor 6 requirements, minimal device impact
|
||||
|
||||
### 2. Data Storage Strategy
|
||||
- ✅ **iOS**: Use App Group UserDefaults (already implemented in Share Extension)
|
||||
- ✅ **Android**: Use SharedPreferences (to be implemented)
|
||||
- ✅ **Rationale**: Direct, efficient, no file I/O needed
|
||||
|
||||
## 🔍 Decisions Needed Before Implementation
|
||||
|
||||
### 1. Plugin Method Design
|
||||
|
||||
#### Decision: What methods should the plugin expose?
|
||||
|
||||
**Options:**
|
||||
- **Option A (Minimal)**: Only `getSharedImage()` - read and clear in one call
|
||||
- **Option B (Recommended)**: `getSharedImage()` + `hasSharedImage()` - allows checking without reading
|
||||
- **Option C (Extended)**: Add `clearSharedImage()` - explicit clearing without reading
|
||||
|
||||
**Recommendation:** **Option B**
|
||||
- `getSharedImage()`: Returns `{ base64: string, fileName: string } | null`
|
||||
- `hasSharedImage()`: Returns `{ hasImage: boolean }` - useful for quick checks
|
||||
|
||||
**Decision Needed:** ✅ Confirm Option B or choose alternative
|
||||
|
||||
---
|
||||
|
||||
### 2. Error Handling Strategy
|
||||
|
||||
#### Decision: How should the plugin handle errors?
|
||||
|
||||
**Options:**
|
||||
- **Option A**: Return `null` for all errors (no shared image = no error)
|
||||
- **Option B**: Use `call.reject()` for actual errors, return `null` only when no image exists
|
||||
- **Option C**: Return error object in result: `{ error: string } | { base64: string, fileName: string }`
|
||||
|
||||
**Recommendation:** **Option B**
|
||||
- `getSharedImage()` returns `null` when no image exists (normal case)
|
||||
- `call.reject()` for actual errors (UserDefaults unavailable, data corruption, etc.)
|
||||
- Clear distinction between "no data" (normal) vs "error" (exceptional)
|
||||
|
||||
**Decision Needed:** ✅ Confirm Option B or choose alternative
|
||||
|
||||
---
|
||||
|
||||
### 3. Data Clearing Strategy
|
||||
|
||||
#### Decision: When should shared image data be cleared?
|
||||
|
||||
**Current Behavior (temp file approach):**
|
||||
- Data cleared after reading (immediate)
|
||||
|
||||
**Options:**
|
||||
- **Option A**: Clear immediately after reading (current behavior)
|
||||
- **Option B**: Clear on next read (allow re-reading until consumed)
|
||||
- **Option C**: Clear after successful storage in temp DB (JS confirms receipt)
|
||||
|
||||
**Recommendation:** **Option A** (immediate clearing)
|
||||
- Prevents accidental re-reading
|
||||
- Simpler implementation
|
||||
- Matches current behavior
|
||||
- If JS fails to store, user can share again
|
||||
|
||||
**Decision Needed:** ✅ Confirm Option A or choose alternative
|
||||
|
||||
---
|
||||
|
||||
### 4. iOS Plugin Registration
|
||||
|
||||
#### Decision: How should the iOS plugin be registered?
|
||||
|
||||
**Options:**
|
||||
- **Option A**: Auto-discovery (Capacitor finds plugins by naming convention)
|
||||
- **Option B**: Manual registration in AppDelegate
|
||||
- **Option C**: Hybrid (auto-discovery with manual registration as fallback)
|
||||
|
||||
**Recommendation:** **Option A** (auto-discovery)
|
||||
- Follows Capacitor best practices
|
||||
- Less code to maintain
|
||||
- Other plugins in project use auto-discovery (SafeAreaPlugin uses manual, but that's older pattern)
|
||||
|
||||
**Note:** Need to verify plugin naming convention:
|
||||
- Class name: `SharedImagePlugin`
|
||||
- File name: `SharedImagePlugin.swift`
|
||||
- Location: `ios/App/App/SharedImagePlugin.swift`
|
||||
|
||||
**Decision Needed:** ✅ Confirm Option A, or if auto-discovery doesn't work, use Option B
|
||||
|
||||
---
|
||||
|
||||
### 5. TypeScript Interface Design
|
||||
|
||||
#### Decision: What should the TypeScript interface look like?
|
||||
|
||||
**Proposed Interface:**
|
||||
```typescript
|
||||
export interface SharedImageResult {
|
||||
base64: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface SharedImagePlugin {
|
||||
getSharedImage(): Promise<SharedImageResult | null>;
|
||||
hasSharedImage(): Promise<{ hasImage: boolean }>;
|
||||
}
|
||||
```
|
||||
|
||||
**Questions:**
|
||||
- Should `fileName` be optional? (Currently always provided, but could be empty string)
|
||||
- Should we include metadata (image size, MIME type)?
|
||||
- Should `hasSharedImage()` return more info (like fileName without reading)?
|
||||
|
||||
**Recommendation:** Keep simple for now:
|
||||
- `fileName` is always a string (may be default "shared-image.jpg")
|
||||
- No metadata initially (can add later if needed)
|
||||
- `hasSharedImage()` only returns boolean (keep it lightweight)
|
||||
|
||||
**Decision Needed:** ✅ Confirm interface design or request changes
|
||||
|
||||
---
|
||||
|
||||
### 6. Android Data Storage Timing
|
||||
|
||||
#### Decision: When should Android store shared image data in SharedPreferences?
|
||||
|
||||
**Current Flow:**
|
||||
1. Share intent received in MainActivity
|
||||
2. Image processed and written to temp file
|
||||
3. JS reads temp file
|
||||
|
||||
**New Flow Options:**
|
||||
- **Option A**: Store in SharedPreferences immediately when share intent received (in `processSharedImage()`)
|
||||
- **Option B**: Store when plugin is first called (lazy loading)
|
||||
- **Option C**: Store in both places during transition (backward compatibility)
|
||||
|
||||
**Recommendation:** **Option A** (immediate storage)
|
||||
- Data available immediately when plugin is called
|
||||
- No timing issues
|
||||
- Matches iOS pattern (data stored by Share Extension)
|
||||
|
||||
**Implementation:**
|
||||
- Update `processSharedImage()` in MainActivity to store in SharedPreferences
|
||||
- Remove temp file writing
|
||||
- Plugin reads from SharedPreferences
|
||||
|
||||
**Decision Needed:** ✅ Confirm Option A
|
||||
|
||||
---
|
||||
|
||||
### 7. Migration Strategy
|
||||
|
||||
#### Decision: How to handle the transition from temp file to plugin?
|
||||
|
||||
**Options:**
|
||||
- **Option A (Clean Break)**: Remove temp file code immediately, use plugin only
|
||||
- **Option B (Gradual)**: Support both approaches temporarily, remove temp file later
|
||||
- **Option C (Feature Flag)**: Use feature flag to switch between approaches
|
||||
|
||||
**Recommendation:** **Option A** (clean break)
|
||||
- Simpler implementation
|
||||
- Less code to maintain
|
||||
- Temp file approach is buggy anyway (why we're replacing it)
|
||||
- Can rollback via git if needed
|
||||
|
||||
**Rollback Plan:**
|
||||
- Keep temp file code in git history
|
||||
- If plugin has issues, can revert commit
|
||||
- Test thoroughly before removing temp file code
|
||||
|
||||
**Decision Needed:** ✅ Confirm Option A
|
||||
|
||||
---
|
||||
|
||||
### 8. Plugin Naming
|
||||
|
||||
#### Decision: What should the plugin be named?
|
||||
|
||||
**Options:**
|
||||
- **Option A**: `SharedImage` (matches file/class names)
|
||||
- **Option B**: `SharedImagePlugin` (more explicit)
|
||||
- **Option C**: `NativeShare` (more generic, could handle other share types)
|
||||
|
||||
**Recommendation:** **Option A** (`SharedImage`)
|
||||
- Matches Capacitor naming conventions (plugins are referenced without "Plugin" suffix)
|
||||
- Examples: `Capacitor.Plugins.Camera`, `Capacitor.Plugins.Filesystem`
|
||||
- TypeScript: `SharedImage.getSharedImage()`
|
||||
|
||||
**Decision Needed:** ✅ Confirm Option A
|
||||
|
||||
---
|
||||
|
||||
### 9. iOS: Reuse getSharedImageData() or Move to Plugin?
|
||||
|
||||
#### Decision: Should the plugin reuse AppDelegate's `getSharedImageData()` or implement its own?
|
||||
|
||||
**Current Code:**
|
||||
- `AppDelegate.getSharedImageData()` exists and works
|
||||
- Reads from App Group UserDefaults
|
||||
- Clears data after reading
|
||||
|
||||
**Options:**
|
||||
- **Option A**: Plugin calls `getSharedImageData()` from AppDelegate
|
||||
- **Option B**: Plugin implements its own logic (duplicate code)
|
||||
- **Option C**: Move `getSharedImageData()` to a shared utility, both use it
|
||||
|
||||
**Recommendation:** **Option C** (shared utility)
|
||||
- DRY principle
|
||||
- Single source of truth
|
||||
- But: May be overkill for simple logic
|
||||
|
||||
**Alternative Recommendation:** **Option B** (plugin implements own logic)
|
||||
- Plugin is self-contained
|
||||
- No dependency on AppDelegate
|
||||
- Logic is simple (just UserDefaults read/clear)
|
||||
- Can remove `getSharedImageData()` from AppDelegate after migration
|
||||
|
||||
**Decision:** ✅ **Option C** (shared utility) - **CONFIRMED**
|
||||
- Create shared utility for reading from App Group UserDefaults
|
||||
- Both AppDelegate and plugin use the shared utility
|
||||
- Single source of truth for shared image data access
|
||||
|
||||
---
|
||||
|
||||
### 10. Android: SharedPreferences Key Names
|
||||
|
||||
#### Decision: What keys should be used in SharedPreferences?
|
||||
|
||||
**Proposed Keys:**
|
||||
- `shared_image_base64` - Base64 string
|
||||
- `shared_image_file_name` - File name
|
||||
- `shared_image_ready` - Boolean flag (optional, for quick checks)
|
||||
|
||||
**Alternative:**
|
||||
- Use a single JSON object: `shared_image_data` = `{ base64: "...", fileName: "..." }`
|
||||
|
||||
**Recommendation:** Separate keys (first option)
|
||||
- Simpler to read/write
|
||||
- No JSON parsing needed
|
||||
- Matches iOS pattern (separate UserDefaults keys)
|
||||
- Flag is optional but useful for `hasSharedImage()`
|
||||
|
||||
**Decision Needed:** ✅ Confirm key naming or request changes
|
||||
|
||||
---
|
||||
|
||||
### 11. Testing Strategy
|
||||
|
||||
#### Decision: What testing approach should we use?
|
||||
|
||||
**Options:**
|
||||
- **Option A**: Manual testing only
|
||||
- **Option B**: Manual + automated unit tests for plugin methods
|
||||
- **Option C**: Manual + integration tests
|
||||
|
||||
**Recommendation:** **Option A** (manual testing) for now
|
||||
- Plugins are hard to unit test (require native environment)
|
||||
- Manual testing is sufficient for initial implementation
|
||||
- Can add automated tests later if needed
|
||||
|
||||
**Test Scenarios:**
|
||||
1. Share image from Photos app → Verify appears in app
|
||||
2. Share while app backgrounded → Verify appears when app becomes active
|
||||
3. Share while app closed → Verify appears on app launch
|
||||
4. Multiple rapid shares → Verify only latest is processed
|
||||
5. Share then close app before processing → Verify data persists
|
||||
6. Share then clear app data → Verify graceful handling
|
||||
|
||||
**Decision Needed:** ✅ Confirm testing approach
|
||||
|
||||
---
|
||||
|
||||
### 12. Documentation Updates
|
||||
|
||||
#### Decision: What documentation needs updating?
|
||||
|
||||
**Files to Update:**
|
||||
- ✅ Implementation plan (this document)
|
||||
- ⚠️ `doc/native-share-target-implementation.md` - Update to reflect plugin approach
|
||||
- ⚠️ `doc/ios-share-implementation-status.md` - Mark plugin as implemented
|
||||
- ⚠️ Code comments in `main.capacitor.ts` - Update to reflect plugin usage
|
||||
|
||||
**Decision Needed:** ✅ Confirm documentation update list
|
||||
|
||||
---
|
||||
|
||||
## Summary of Decisions Needed
|
||||
|
||||
| # | Decision | Recommendation | Status |
|
||||
|---|----------|----------------|--------|
|
||||
| 1 | Plugin Methods | Option B: `getSharedImage()` + `hasSharedImage()` | ✅ Confirmed |
|
||||
| 2 | Error Handling | Option B: `null` for no data, `reject()` for errors | ✅ Confirmed |
|
||||
| 3 | Data Clearing | Option A: Clear immediately after reading | ✅ Confirmed |
|
||||
| 4 | iOS Registration | Option A: Auto-discovery | ✅ Confirmed |
|
||||
| 5 | TypeScript Interface | Proposed interface (see above) | ✅ Confirmed |
|
||||
| 6 | Android Storage Timing | Option A: Store immediately on share intent | ✅ Confirmed |
|
||||
| 7 | Migration Strategy | Option A: Clean break, remove temp file code | ✅ Confirmed |
|
||||
| 8 | Plugin Naming | Option A: `SharedImage` | ✅ Confirmed |
|
||||
| 9 | iOS Code Reuse | Option C: Shared utility | ✅ Confirmed |
|
||||
| 10 | Android Key Names | Separate keys: `shared_image_base64`, `shared_image_file_name` | ✅ Confirmed |
|
||||
| 11 | Testing Strategy | Option A: Manual testing | ✅ Confirmed |
|
||||
| 12 | Documentation | Update listed files | ✅ Confirmed |
|
||||
| - | Multiple Images | Single image only (SharedPhotoView requirement) | ✅ Confirmed |
|
||||
| - | Backward Compatibility | No temp file backward compatibility | ✅ Confirmed |
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review this checklist** and confirm or modify recommendations
|
||||
2. **Make decisions** on all pending items
|
||||
3. **Update implementation plan** with confirmed decisions
|
||||
4. **Begin implementation** with clear specifications
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
- Are there any edge cases not covered?
|
||||
- Should we support multiple images (currently only first image)?
|
||||
- Should we add image metadata (size, MIME type) in the future?
|
||||
- Do we need backward compatibility with temp file approach?
|
||||
- Should plugin methods be synchronous or async? (Capacitor plugins are async by default)
|
||||
|
||||
76
doc/xcode-26-cocoapods-workaround.md
Normal file
76
doc/xcode-26-cocoapods-workaround.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Xcode 26 / CocoaPods Compatibility Workaround
|
||||
|
||||
**Date:** 2025-01-27
|
||||
**Issue:** CocoaPods `xcodeproj` gem (1.27.0) doesn't support Xcode 26's project format version 70
|
||||
|
||||
## The Problem
|
||||
|
||||
Xcode 26.1.1 uses project format version 70, but the `xcodeproj` gem (1.27.0) only supports up to version 56. This causes CocoaPods to fail with:
|
||||
|
||||
```
|
||||
ArgumentError - [Xcodeproj] Unable to find compatibility version string for object version `70`.
|
||||
```
|
||||
|
||||
## Solutions
|
||||
|
||||
### Option 1: Temporarily Downgrade Project Format (Recommended for Now)
|
||||
|
||||
**Before running `pod install` or `npm run build:ios`:**
|
||||
|
||||
1. Edit `ios/App/App.xcodeproj/project.pbxproj`
|
||||
2. Change line 6 from: `objectVersion = 70;` to: `objectVersion = 56;`
|
||||
3. Run your build/sync command
|
||||
4. Change it back to: `objectVersion = 70;` (Xcode will likely change it back automatically)
|
||||
|
||||
**Warning:** Xcode may automatically upgrade the format back to 70 when you open the project. This is okay - just repeat the process when needed.
|
||||
|
||||
### Option 2: Wait for xcodeproj Update
|
||||
|
||||
The `xcodeproj` gem maintainers will eventually release a version that supports format 70. You can:
|
||||
- Check for updates: `bundle update xcodeproj`
|
||||
- Monitor: https://github.com/CocoaPods/Xcodeproj/issues
|
||||
|
||||
### Option 3: Use Xcode Directly (Bypass CocoaPods for Now)
|
||||
|
||||
Since the Share Extension is already set up:
|
||||
1. Open the project in Xcode
|
||||
2. Build directly from Xcode (Product → Build)
|
||||
3. Skip `npm run build:ios` for now
|
||||
4. Test the Share Extension functionality
|
||||
|
||||
### Option 4: Automated Workaround (Integrated into Build Script) ✅
|
||||
|
||||
The workaround is now **automatically integrated** into `scripts/build-ios.sh`. When you run:
|
||||
|
||||
```bash
|
||||
npm run build:ios
|
||||
```
|
||||
|
||||
The build script will:
|
||||
1. Automatically detect if the project format is version 70
|
||||
2. Temporarily downgrade to version 56
|
||||
3. Run `pod install`
|
||||
4. Restore to version 70
|
||||
5. Continue with the build
|
||||
|
||||
**No manual steps required!** The workaround is transparent and only applies when needed.
|
||||
|
||||
To remove the workaround in the future:
|
||||
1. Check if `xcodeproj` gem supports format 70: `bundle exec gem list xcodeproj`
|
||||
2. Test if `pod install` works without the workaround
|
||||
3. If it works, remove the `run_pod_install_with_workaround()` function from `scripts/build-ios.sh`
|
||||
4. Replace it with a simple `pod install` call
|
||||
|
||||
## Current Status
|
||||
|
||||
- ✅ Share Extension target exists
|
||||
- ✅ Share Extension files are in place
|
||||
- ✅ Workaround integrated into build script
|
||||
- ✅ `npm run build:ios` works automatically
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Use `npm run build:ios`** - the workaround is handled automatically. No manual intervention needed.
|
||||
|
||||
Once `xcodeproj` is updated to support format 70, the workaround can be removed from the build script.
|
||||
|
||||
13
electron/package-lock.json
generated
13
electron/package-lock.json
generated
@@ -56,6 +56,7 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-6.0.2.tgz",
|
||||
"integrity": "sha512-sj+2SPLu7E/3dM3xxcWwfNomG+aQHuN96/EFGrOtp4Dv30/2y5oIPyi6hZGjQGjPc5GDNoTQwW7vxWNzybjuMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jeep-sqlite": "^2.7.2"
|
||||
},
|
||||
@@ -129,6 +130,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.1.tgz",
|
||||
"integrity": "sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
@@ -1069,6 +1071,7 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -2874,16 +2877,6 @@
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no, interactive-widget=overlays-content" />
|
||||
|
||||
<!-- CORS headers removed to allow images from any domain -->
|
||||
|
||||
@@ -13,4 +13,4 @@
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 70;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -15,8 +15,35 @@
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; };
|
||||
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */; };
|
||||
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
C86585DD2ED456DE00824752 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = C86585D42ED456DE00824752;
|
||||
remoteInfo = TimeSafariShareExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
C86585E02ED456DE00824752 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
@@ -28,10 +55,39 @@
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
90DCAFB4D8948F7A50C13800 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimeSafariShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C86585E52ED4577F00824752 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImageUtility.swift; sourceTree = "<group>"; };
|
||||
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImagePlugin.swift; sourceTree = "<group>"; };
|
||||
E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||
EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = TimeSafariShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
504EC3011FED79650016851F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
@@ -41,6 +97,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C86585D22ED456DE00824752 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -56,6 +119,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3061FED79650016851F /* App */,
|
||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
BA325FFCDCE8D334E5C7AEBE /* Pods */,
|
||||
4B546315E668C7A13939F417 /* Frameworks */,
|
||||
@@ -66,6 +130,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -73,6 +138,9 @@
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */,
|
||||
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */,
|
||||
C86585E52ED4577F00824752 /* App.entitlements */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
@@ -108,16 +176,40 @@
|
||||
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
|
||||
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
|
||||
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
|
||||
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
C86585DE2ED456DE00824752 /* PBXTargetDependency */,
|
||||
);
|
||||
name = App;
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
C86585D42ED456DE00824752 /* TimeSafariShareExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */;
|
||||
buildPhases = (
|
||||
C86585D12ED456DE00824752 /* Sources */,
|
||||
C86585D22ED456DE00824752 /* Frameworks */,
|
||||
C86585D32ED456DE00824752 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
|
||||
);
|
||||
name = TimeSafariShareExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = TimeSafariShareExtension;
|
||||
productReference = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -125,7 +217,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 920;
|
||||
LastSwiftUpdateCheck = 2610;
|
||||
LastUpgradeCheck = 1630;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
@@ -133,6 +225,9 @@
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
C86585D42ED456DE00824752 = {
|
||||
CreatedOnToolsVersion = 26.1.1;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||
@@ -149,6 +244,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
C86585D42ED456DE00824752 /* TimeSafariShareExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -167,6 +263,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C86585D32ED456DE00824752 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
@@ -253,12 +356,29 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */,
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C86585D12ED456DE00824752 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
C86585DE2ED456DE00824752 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
|
||||
targetProxy = C86585DD2ED456DE00824752 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
@@ -402,8 +522,9 @@
|
||||
baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -413,7 +534,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.7;
|
||||
MARKETING_VERSION = 1.3.8;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -429,8 +550,9 @@
|
||||
baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -440,7 +562,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.7;
|
||||
MARKETING_VERSION = 1.3.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -450,6 +572,80 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
C86585E12ED456DE00824752 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.3.8;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
C86585E22ED456DE00824752 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.3.8;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -471,6 +667,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
C86585E12ED456DE00824752 /* Debug */,
|
||||
C86585E22ED456DE00824752 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||
|
||||
77
ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme
Normal file
77
ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme
Normal file
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1630"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "504EC3031FED79650016851F"
|
||||
BuildableName = "App.app"
|
||||
BlueprintName = "App"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "504EC3031FED79650016851F"
|
||||
BuildableName = "App.app"
|
||||
BlueprintName = "App"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "504EC3031FED79650016851F"
|
||||
BuildableName = "App.app"
|
||||
BlueprintName = "App"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
10
ios/App/App/App.entitlements
Normal file
10
ios/App/App/App.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.timesafari.share</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,20 +1,64 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import CapacitorCommunitySqlite
|
||||
import UserNotifications
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Set notification center delegate so notifications show in foreground and rollover is triggered
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
// Initialize SQLite
|
||||
//let sqlite = SQLite()
|
||||
//sqlite.initialize()
|
||||
|
||||
// Register SharedImage plugin manually after bridge is ready
|
||||
// Try multiple times with increasing delays to ensure bridge is initialized
|
||||
var attempts = 0
|
||||
let maxAttempts = 5
|
||||
|
||||
func tryRegister() {
|
||||
attempts += 1
|
||||
if registerSharedImagePlugin() {
|
||||
print("[AppDelegate] ✅ Plugin registration successful on attempt \(attempts)")
|
||||
} else if attempts < maxAttempts {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(attempts) * 0.5) {
|
||||
tryRegister()
|
||||
}
|
||||
} else {
|
||||
print("[AppDelegate] ⚠️ Failed to register plugin after \(maxAttempts) attempts")
|
||||
}
|
||||
}
|
||||
|
||||
// Start registration attempts
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
tryRegister()
|
||||
}
|
||||
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func registerSharedImagePlugin() -> Bool {
|
||||
guard let window = self.window,
|
||||
let bridgeVC = window.rootViewController as? CAPBridgeViewController,
|
||||
let bridge = bridgeVC.bridge else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Create plugin instance
|
||||
// The @objc(SharedImage) annotation makes it available as "SharedImage" to Objective-C
|
||||
// which matches the JavaScript registration name
|
||||
let pluginInstance = SharedImagePlugin()
|
||||
bridge.registerPluginInstance(pluginInstance)
|
||||
print("[AppDelegate] ✅ Registered SharedImagePlugin (exposed as 'SharedImage' via @objc annotation)")
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
@@ -32,6 +76,54 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
|
||||
// Re-set notification delegate when app becomes active (in case Capacitor resets it)
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
// Check for shared image from Share Extension when app becomes active
|
||||
checkForSharedImageOnActivation()
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
/// Show notifications when app is in foreground and post DailyNotificationDelivered for rollover.
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
let userInfo = notification.request.content.userInfo
|
||||
if let notificationId = userInfo["notification_id"] as? String,
|
||||
let scheduledTime = userInfo["scheduled_time"] as? Int64 {
|
||||
NotificationCenter.default.post(
|
||||
name: NSNotification.Name("DailyNotificationDelivered"),
|
||||
object: nil,
|
||||
userInfo: ["notification_id": notificationId, "scheduled_time": scheduledTime]
|
||||
)
|
||||
}
|
||||
if #available(iOS 14.0, *) {
|
||||
completionHandler([.banner, .sound, .badge])
|
||||
} else {
|
||||
completionHandler([.alert, .sound, .badge])
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle notification tap/interaction.
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for shared image when app launches or becomes active
|
||||
* This allows the app to detect shared images without requiring a deep link
|
||||
* Note: JavaScript will read the shared image via SharedImagePlugin, so we just check the flag
|
||||
*/
|
||||
private func checkForSharedImageOnActivation() {
|
||||
// Check if shared photo is ready
|
||||
if SharedImageUtility.isSharedPhotoReady() {
|
||||
// Clear the flag
|
||||
SharedImageUtility.clearSharedPhotoReadyFlag()
|
||||
|
||||
// Post notification for JavaScript to handle navigation
|
||||
// JavaScript will read the shared image via SharedImagePlugin
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SharedPhotoReady"), object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
@@ -41,6 +133,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
||||
// Note: Share Extension opens app with timesafari:// (empty path), which is handled by JavaScript
|
||||
// via the appUrlOpen listener in main.capacitor.ts
|
||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
@@ -50,5 +144,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -58,5 +58,19 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>org.timesafari.dailynotification.fetch</string>
|
||||
<string>org.timesafari.dailynotification.notify</string>
|
||||
<string>org.timesafari.dailynotification.content-fetch</string>
|
||||
<string>org.timesafari.dailynotification.notification-delivery</string>
|
||||
</array>
|
||||
<key>NSUserNotificationAlertStyle</key>
|
||||
<string>alert</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
66
ios/App/App/SharedImagePlugin.swift
Normal file
66
ios/App/App/SharedImagePlugin.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// SharedImagePlugin.swift
|
||||
// App
|
||||
//
|
||||
// Capacitor plugin for accessing shared image data from Share Extension
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Capacitor
|
||||
|
||||
@objc(SharedImage)
|
||||
public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
// MARK: - CAPBridgedPlugin Conformance
|
||||
|
||||
public var identifier: String {
|
||||
return "SharedImage"
|
||||
}
|
||||
|
||||
public var jsName: String {
|
||||
return "SharedImage"
|
||||
}
|
||||
|
||||
public var pluginMethods: [CAPPluginMethod] {
|
||||
return [
|
||||
CAPPluginMethod(#selector(getSharedImage(_:)), returnType: .promise),
|
||||
CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Plugin Methods
|
||||
|
||||
/**
|
||||
* Get shared image data from App Group UserDefaults
|
||||
* Returns base64 string and fileName, or null if no image exists
|
||||
* Clears the data after reading to prevent re-reading
|
||||
*/
|
||||
@objc public func getSharedImage(_ call: CAPPluginCall) {
|
||||
guard let sharedData = SharedImageUtility.getSharedImageData() else {
|
||||
// No shared image exists - return null (not an error)
|
||||
call.resolve([
|
||||
"base64": NSNull(),
|
||||
"fileName": NSNull()
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
// Return the shared image data
|
||||
call.resolve([
|
||||
"base64": sharedData["base64"] ?? "",
|
||||
"fileName": sharedData["fileName"] ?? ""
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if shared image exists without reading it
|
||||
* Useful for quick checks before calling getSharedImage()
|
||||
*/
|
||||
@objc public func hasSharedImage(_ call: CAPPluginCall) {
|
||||
let hasImage = SharedImageUtility.hasSharedImage()
|
||||
call.resolve([
|
||||
"hasImage": hasImage
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
107
ios/App/App/SharedImageUtility.swift
Normal file
107
ios/App/App/SharedImageUtility.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// SharedImageUtility.swift
|
||||
// App
|
||||
//
|
||||
// Shared utility for accessing shared image data from App Group container
|
||||
// Images are stored as files in the App Group container to avoid UserDefaults size limits
|
||||
// Used by both AppDelegate and SharedImagePlugin
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class SharedImageUtility {
|
||||
private static let appGroupIdentifier = "group.app.timesafari.share"
|
||||
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||
private static let sharedPhotoFilePathKey = "sharedPhotoFilePath"
|
||||
private static let sharedPhotoReadyKey = "sharedPhotoReady"
|
||||
|
||||
/// Get the App Group container URL for accessing shared files
|
||||
private static var appGroupContainerURL: URL? {
|
||||
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared image data from App Group container file
|
||||
* All images are stored as files for consistency and to avoid UserDefaults size limits
|
||||
* Clears the data after reading to prevent re-reading
|
||||
*
|
||||
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
|
||||
*/
|
||||
static func getSharedImageData() -> [String: String]? {
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get file path and filename from UserDefaults
|
||||
guard let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey),
|
||||
let containerURL = appGroupContainerURL else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) ?? "shared-image.jpg"
|
||||
let fileURL = containerURL.appendingPathComponent(filePath)
|
||||
|
||||
// Read image data from file
|
||||
guard let imageData = try? Data(contentsOf: fileURL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert file data to base64 for JavaScript consumption
|
||||
let base64String = imageData.base64EncodedString()
|
||||
|
||||
// Clear the shared data after reading
|
||||
userDefaults.removeObject(forKey: sharedPhotoFilePathKey)
|
||||
userDefaults.removeObject(forKey: sharedPhotoFileNameKey)
|
||||
|
||||
// Remove the file
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
|
||||
userDefaults.synchronize()
|
||||
|
||||
return ["base64": base64String, "fileName": fileName]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if shared image exists without reading it
|
||||
*
|
||||
* @returns true if shared image file exists, false otherwise
|
||||
*/
|
||||
static func hasSharedImage() -> Bool {
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier),
|
||||
let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey),
|
||||
let containerURL = appGroupContainerURL else {
|
||||
return false
|
||||
}
|
||||
|
||||
let fileURL = containerURL.appendingPathComponent(filePath)
|
||||
return FileManager.default.fileExists(atPath: fileURL.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if shared photo ready flag is set
|
||||
* This flag is set by the Share Extension when image is ready
|
||||
*
|
||||
* @returns true if flag is set, false otherwise
|
||||
*/
|
||||
static func isSharedPhotoReady() -> Bool {
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return userDefaults.bool(forKey: sharedPhotoReadyKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the shared photo ready flag
|
||||
* Called after processing the shared image
|
||||
*/
|
||||
static func clearSharedPhotoReadyFlag() {
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
return
|
||||
}
|
||||
|
||||
userDefaults.removeObject(forKey: sharedPhotoReadyKey)
|
||||
userDefaults.synchronize()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ def capacitor_pods
|
||||
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
|
||||
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
|
||||
pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker'
|
||||
pod 'TimesafariDailyNotificationPlugin', :path => '../../node_modules/@timesafari/daily-notification-plugin'
|
||||
end
|
||||
|
||||
target 'App' do
|
||||
|
||||
@@ -86,6 +86,8 @@ PODS:
|
||||
- SQLCipher/common (4.9.0)
|
||||
- SQLCipher/standard (4.9.0):
|
||||
- SQLCipher/common
|
||||
- TimesafariDailyNotificationPlugin (2.0.0):
|
||||
- Capacitor
|
||||
- ZIPFoundation (0.9.19)
|
||||
|
||||
DEPENDENCIES:
|
||||
@@ -100,6 +102,7 @@ DEPENDENCIES:
|
||||
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
|
||||
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
|
||||
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)"
|
||||
- "TimesafariDailyNotificationPlugin (from `../../node_modules/@timesafari/daily-notification-plugin`)"
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@@ -141,6 +144,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@capacitor/status-bar"
|
||||
CapawesomeCapacitorFilePicker:
|
||||
:path: "../../node_modules/@capawesome/capacitor-file-picker"
|
||||
TimesafariDailyNotificationPlugin:
|
||||
:path: "../../node_modules/@timesafari/daily-notification-plugin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
|
||||
@@ -167,8 +172,9 @@ SPEC CHECKSUMS:
|
||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
|
||||
TimesafariDailyNotificationPlugin: 3c12e8c39fc27f689f56cf4e57230a8c28611fcc
|
||||
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
|
||||
|
||||
PODFILE CHECKSUM: 5fa870b031c7c4e0733e2f96deaf81866c75ff7d
|
||||
PODFILE CHECKSUM: 6d92bfa46c6c2d31d19b8c0c38f56a8ae9fd222f
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
21
ios/App/TimeSafariShareExtension/Info.plist
Normal file
21
ios/App/TimeSafariShareExtension/Info.plist
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
207
ios/App/TimeSafariShareExtension/ShareViewController.swift
Normal file
207
ios/App/TimeSafariShareExtension/ShareViewController.swift
Normal file
@@ -0,0 +1,207 @@
|
||||
//
|
||||
// ShareViewController.swift
|
||||
// TimeSafariShareExtension
|
||||
//
|
||||
// Created by Aardimus on 11/24/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
private let appGroupIdentifier = "group.app.timesafari.share"
|
||||
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||
private let sharedPhotoFilePathKey = "sharedPhotoFilePath"
|
||||
private let sharedImageFileName = "shared-image"
|
||||
|
||||
/// Get the App Group container URL for storing shared files
|
||||
private var appGroupContainerURL: URL? {
|
||||
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Set a minimal background (transparent or loading indicator)
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
// Process image immediately without showing UI
|
||||
processAndOpenApp()
|
||||
}
|
||||
|
||||
private func processAndOpenApp() {
|
||||
// extensionContext is automatically available on UIViewController when used as extension principal class
|
||||
guard let context = extensionContext,
|
||||
let inputItems = context.inputItems as? [NSExtensionItem] else {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
|
||||
processSharedImage(from: inputItems) { [weak self] success in
|
||||
guard let self = self, let context = self.extensionContext else {
|
||||
return
|
||||
}
|
||||
|
||||
if success {
|
||||
// Set flag that shared photo is ready
|
||||
self.setSharedPhotoReadyFlag()
|
||||
// Open the main app (using minimal URL - app will detect shared data on activation)
|
||||
self.openMainApp()
|
||||
}
|
||||
|
||||
// Complete immediately - no UI shown
|
||||
context.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func setSharedPhotoReadyFlag() {
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
return
|
||||
}
|
||||
userDefaults.set(true, forKey: "sharedPhotoReady")
|
||||
userDefaults.synchronize()
|
||||
}
|
||||
|
||||
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
|
||||
// Find the first image attachment
|
||||
for item in items {
|
||||
guard let attachments = item.attachments else {
|
||||
continue
|
||||
}
|
||||
|
||||
for attachment in attachments {
|
||||
// Skip non-image attachments
|
||||
guard attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to load raw data first to preserve original format
|
||||
// This preserves the original image format without conversion
|
||||
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
|
||||
guard let self = self else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
if error != nil {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle different image data types
|
||||
// loadItem(forTypeIdentifier:) typically returns URL or Data, not UIImage
|
||||
var imageData: Data?
|
||||
var fileName: String = "shared-image"
|
||||
|
||||
if let url = data as? URL {
|
||||
// Most common case: Image provided as file URL - read raw data to preserve format
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
// Read raw data directly to preserve original format
|
||||
imageData = try? Data(contentsOf: url)
|
||||
fileName = url.lastPathComponent
|
||||
|
||||
// Fallback: if raw data read fails, try UIImage and convert to PNG (lossless)
|
||||
if imageData == nil, let image = UIImage(contentsOfFile: url.path) {
|
||||
imageData = image.pngData()
|
||||
fileName = self.getFileNameWithExtension(url.lastPathComponent, newExtension: "png")
|
||||
}
|
||||
} else if let data = data as? Data {
|
||||
// Less common: Image provided as raw Data - use directly to preserve format
|
||||
imageData = data
|
||||
fileName = attachment.suggestedName ?? "shared-image"
|
||||
}
|
||||
|
||||
guard let finalImageData = imageData else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Store image as file in App Group container
|
||||
if self.storeImageData(finalImageData, fileName: fileName) {
|
||||
completion(true)
|
||||
} else {
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
return // Process only the first image
|
||||
}
|
||||
}
|
||||
|
||||
// No image found
|
||||
completion(false)
|
||||
}
|
||||
|
||||
/// Helper to get filename with a new extension, preserving base name
|
||||
private func getFileNameWithExtension(_ originalName: String, newExtension: String) -> String {
|
||||
if let nameWithoutExt = originalName.components(separatedBy: ".").first, !nameWithoutExt.isEmpty {
|
||||
return "\(nameWithoutExt).\(newExtension)"
|
||||
}
|
||||
return "shared-image.\(newExtension)"
|
||||
}
|
||||
|
||||
/// Store image data as a file in the App Group container
|
||||
/// All images are stored as files regardless of size for consistency and simplicity
|
||||
/// Returns true if successful, false otherwise
|
||||
private func storeImageData(_ imageData: Data, fileName: String) -> Bool {
|
||||
guard let containerURL = appGroupContainerURL else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Create file URL in the container using the actual filename
|
||||
// Extract extension from fileName if present, otherwise use sharedImageFileName
|
||||
let actualFileName = fileName.isEmpty ? sharedImageFileName : fileName
|
||||
let fileURL = containerURL.appendingPathComponent(actualFileName)
|
||||
|
||||
// Remove old file if it exists
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
|
||||
// Write image data to file
|
||||
do {
|
||||
try imageData.write(to: fileURL)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
// Store file path and filename in UserDefaults (small data, safe to store)
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Store relative path and filename
|
||||
userDefaults.set(actualFileName, forKey: sharedPhotoFilePathKey)
|
||||
userDefaults.set(fileName, forKey: sharedPhotoFileNameKey)
|
||||
|
||||
// Clean up any old base64 data that might exist
|
||||
userDefaults.removeObject(forKey: "sharedPhotoBase64")
|
||||
|
||||
userDefaults.synchronize()
|
||||
return true
|
||||
}
|
||||
|
||||
private func openMainApp() {
|
||||
// Open the main app with minimal URL - app will detect shared data on activation
|
||||
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
|
||||
}
|
||||
|
||||
// Fallback: use extension context
|
||||
extensionContext?.open(url, completionHandler: nil)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.timesafari.share</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
7885
package-lock.json
generated
7885
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "timesafari",
|
||||
"version": "1.1.0-beta",
|
||||
"description": "Time Safari Application",
|
||||
"version": "1.3.8-beta",
|
||||
"description": "Gift Economies Application",
|
||||
"author": {
|
||||
"name": "Time Safari Team"
|
||||
"name": "Gift Economies Team"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||
@@ -27,8 +27,8 @@
|
||||
"auto-run:android": "./scripts/auto-run.sh --platform=android",
|
||||
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
|
||||
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
|
||||
"build:capacitor:sync": "npm run build:capacitor && npx cap sync",
|
||||
"build:native": "vite build && npx cap sync && npx capacitor-assets generate",
|
||||
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
|
||||
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && npx capacitor-assets generate",
|
||||
"assets:config": "npx tsx scripts/assets-config.ts",
|
||||
"assets:validate": "npx tsx scripts/assets-validator.ts",
|
||||
"assets:validate:android": "./scripts/build-android.sh --assets-only",
|
||||
@@ -106,7 +106,7 @@
|
||||
"guard": "bash ./scripts/build-arch-guard.sh",
|
||||
"guard:test": "bash ./scripts/build-arch-guard.sh --staged",
|
||||
"guard:setup": "npm run prepare && echo '✅ Build Architecture Guard is now active!'",
|
||||
"clean:android": "./scripts/clean-android.sh",
|
||||
"clean:android": "./scripts/uninstall-android.sh",
|
||||
"clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true",
|
||||
"clean:electron": "./scripts/build-electron.sh --clean",
|
||||
"clean:all": "npm run clean:ios && npm run clean:android && npm run clean:electron",
|
||||
@@ -136,7 +136,6 @@
|
||||
"*.{js,ts,vue,css,json,yml,yaml}": "eslint --fix || true",
|
||||
"*.{md,markdown,mdc}": "markdownlint-cli2 --fix"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.1",
|
||||
"@capacitor-community/sqlite": "6.0.2",
|
||||
@@ -157,15 +156,17 @@
|
||||
"@ethersproject/hdnode": "^5.7.0",
|
||||
"@ethersproject/wallet": "^5.8.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||
"@jlongster/sql.js": "^1.6.7",
|
||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.15.0",
|
||||
"@peculiar/asn1-ecc": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@simplewebauthn/server": "^10.0.0",
|
||||
"@timesafari/daily-notification-plugin": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
|
||||
"@tweenjs/tween.js": "^21.1.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@veramo/core": "^5.6.0",
|
||||
@@ -188,6 +189,7 @@
|
||||
"dexie-export-import": "^4.1.4",
|
||||
"did-jwt": "^7.4.7",
|
||||
"did-resolver": "^4.1.0",
|
||||
"diff": "^8.0.2",
|
||||
"dotenv": "^16.0.3",
|
||||
"electron-builder": "^26.0.12",
|
||||
"ethereum-cryptography": "^2.1.3",
|
||||
@@ -201,9 +203,10 @@
|
||||
"lru-cache": "^10.2.0",
|
||||
"luxon": "^3.4.4",
|
||||
"merkletreejs": "^0.3.11",
|
||||
"nostr-tools": "^2.15.0",
|
||||
"notiwind": "^2.0.2",
|
||||
"papaparse": "^5.4.1",
|
||||
"pina": "^0.20.2204228",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
"qr-code-generator-vue3": "^1.4.21",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -220,6 +223,7 @@
|
||||
"vue": "3.5.13",
|
||||
"vue-axios": "^3.5.2",
|
||||
"vue-facing-decorator": "3.0.4",
|
||||
"vue-markdown-render": "^2.2.1",
|
||||
"vue-picture-cropper": "^0.7.0",
|
||||
"vue-qrcode-reader": "^5.5.3",
|
||||
"vue-router": "^4.5.0",
|
||||
@@ -231,11 +235,13 @@
|
||||
"@commitlint/cli": "^18.6.1",
|
||||
"@commitlint/config-conventional": "^18.6.2",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/dom-webcodecs": "^0.1.7",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^20.14.11",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"@types/ramda": "^0.29.11",
|
||||
|
||||
@@ -21,7 +21,7 @@ export default defineConfig({
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
workers: 3,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: [
|
||||
['list'],
|
||||
@@ -57,7 +57,7 @@ export default defineConfig({
|
||||
// },
|
||||
{
|
||||
name: 'chromium',
|
||||
testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
|
||||
testMatch: /^(?!.*\/(35-record-gift-from-image-share)\.spec\.ts).+\.spec\.ts$/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
permissions: ["clipboard-read"],
|
||||
@@ -65,7 +65,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
|
||||
testMatch: /^(?!.*\/(35-record-gift-from-image-share)\.spec\.ts).+\.spec\.ts$/,
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
|
||||
46
public/manifest.webmanifest
Normal file
46
public/manifest.webmanifest
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"icons": [
|
||||
{
|
||||
"src": "../icons/icon-48.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "48x48",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-72.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "72x72",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-96.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "96x96",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-128.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "128x128",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-192.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-256.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-512.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
60
scripts/README-restore-local-plugins.md
Normal file
60
scripts/README-restore-local-plugins.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Restore Local Capacitor Plugins
|
||||
|
||||
## Overview
|
||||
|
||||
The `restore-local-plugins.js` script ensures that local custom Capacitor plugins (`SafeArea` and `SharedImage`) are automatically restored to `android/app/src/main/assets/capacitor.plugins.json` after running `npx cap sync android`.
|
||||
|
||||
## Why This Is Needed
|
||||
|
||||
The `capacitor.plugins.json` file is auto-generated by Capacitor during `npx cap sync` and gets overwritten, removing any manually added local plugins. This script automatically restores them.
|
||||
|
||||
## Usage
|
||||
|
||||
### Automatic (Recommended)
|
||||
|
||||
The script is automatically run by:
|
||||
- `./scripts/build-android.sh` (after `cap sync`)
|
||||
- `npm run build:capacitor:sync`
|
||||
- `npm run build:native`
|
||||
|
||||
### Manual
|
||||
|
||||
If you run `npx cap sync android` directly, you can restore plugins manually:
|
||||
|
||||
```bash
|
||||
node scripts/restore-local-plugins.js
|
||||
```
|
||||
|
||||
## What It Does
|
||||
|
||||
1. Reads `android/app/src/main/assets/capacitor.plugins.json`
|
||||
2. Checks if local plugins (`SafeArea` and `SharedImage`) are present
|
||||
3. Adds any missing local plugins
|
||||
4. Preserves the existing JSON format
|
||||
|
||||
## Local Plugins
|
||||
|
||||
The following local plugins are automatically restored:
|
||||
|
||||
- **SafeArea**: `app.timesafari.safearea.SafeAreaPlugin`
|
||||
- **SharedImage**: `app.timesafari.sharedimage.SharedImagePlugin`
|
||||
|
||||
## Adding New Local Plugins
|
||||
|
||||
To add a new local plugin, edit `scripts/restore-local-plugins.js` and add it to the `LOCAL_PLUGINS` array:
|
||||
|
||||
```javascript
|
||||
const LOCAL_PLUGINS = [
|
||||
// ... existing plugins ...
|
||||
{
|
||||
pkg: 'YourPluginName',
|
||||
classpath: 'app.timesafari.yourpackage.YourPluginClass'
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The script is idempotent - running it multiple times won't create duplicates
|
||||
- The script preserves the existing JSON formatting (tabs, etc.)
|
||||
- If the plugins file doesn't exist, the script will exit with an error (run `npx cap sync android` first)
|
||||
389
scripts/avd-resource-checker.sh
Executable file
389
scripts/avd-resource-checker.sh
Executable file
@@ -0,0 +1,389 @@
|
||||
#!/bin/bash
|
||||
# avd-resource-checker.sh
|
||||
# Author: Matthew Raymer
|
||||
# Date: 2025-01-27
|
||||
# Description: Check system resources and recommend optimal AVD configuration
|
||||
|
||||
set -e
|
||||
|
||||
# Source common utilities
|
||||
source "$(dirname "$0")/common.sh"
|
||||
|
||||
# Colors for output
|
||||
RED_COLOR='\033[0;31m'
|
||||
GREEN_COLOR='\033[0;32m'
|
||||
YELLOW_COLOR='\033[1;33m'
|
||||
BLUE_COLOR='\033[0;34m'
|
||||
NC_COLOR='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
local color=$1
|
||||
local message=$2
|
||||
echo -e "${color}${message}${NC_COLOR}"
|
||||
}
|
||||
|
||||
# Function to get system memory in MB
|
||||
get_system_memory() {
|
||||
if command -v free >/dev/null 2>&1; then
|
||||
free -m | awk 'NR==2{print $2}'
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to get available memory in MB
|
||||
get_available_memory() {
|
||||
if command -v free >/dev/null 2>&1; then
|
||||
free -m | awk 'NR==2{print $7}'
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to get CPU core count
|
||||
get_cpu_cores() {
|
||||
if command -v nproc >/dev/null 2>&1; then
|
||||
nproc
|
||||
elif [ -f /proc/cpuinfo ]; then
|
||||
grep -c ^processor /proc/cpuinfo
|
||||
else
|
||||
echo "1"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check GPU capabilities
|
||||
check_gpu_capabilities() {
|
||||
local gpu_type="unknown"
|
||||
local gpu_memory="0"
|
||||
|
||||
# Check for NVIDIA GPU
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
gpu_type="nvidia"
|
||||
gpu_memory=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1 || echo "0")
|
||||
print_status $GREEN_COLOR "✓ NVIDIA GPU detected (${gpu_memory}MB VRAM)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for AMD GPU
|
||||
if command -v rocm-smi >/dev/null 2>&1; then
|
||||
gpu_type="amd"
|
||||
print_status $GREEN_COLOR "✓ AMD GPU detected"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for Intel GPU
|
||||
if lspci 2>/dev/null | grep -i "vga.*intel" >/dev/null; then
|
||||
gpu_type="intel"
|
||||
print_status $YELLOW_COLOR "✓ Intel integrated GPU detected"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check for generic GPU
|
||||
if lspci 2>/dev/null | grep -i "vga" >/dev/null; then
|
||||
gpu_type="generic"
|
||||
print_status $YELLOW_COLOR "✓ Generic GPU detected"
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_status $RED_COLOR "✗ No GPU detected"
|
||||
return 2
|
||||
}
|
||||
|
||||
# Function to check if hardware acceleration is available
|
||||
check_hardware_acceleration() {
|
||||
local gpu_capable=$1
|
||||
|
||||
if [ $gpu_capable -eq 0 ]; then
|
||||
print_status $GREEN_COLOR "✓ Hardware acceleration recommended"
|
||||
return 0
|
||||
elif [ $gpu_capable -eq 1 ]; then
|
||||
print_status $YELLOW_COLOR "⚠ Limited hardware acceleration"
|
||||
return 1
|
||||
else
|
||||
print_status $RED_COLOR "✗ No hardware acceleration available"
|
||||
return 2
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to recommend AVD configuration
|
||||
recommend_avd_config() {
|
||||
local total_memory=$1
|
||||
local available_memory=$2
|
||||
local cpu_cores=$3
|
||||
local gpu_capable=$4
|
||||
|
||||
print_status $BLUE_COLOR "\n=== AVD Configuration Recommendation ==="
|
||||
|
||||
# Calculate recommended memory (leave 2GB for system)
|
||||
local system_reserve=2048
|
||||
local recommended_memory=$((available_memory - system_reserve))
|
||||
|
||||
# Cap memory at reasonable limits
|
||||
if [ $recommended_memory -gt 4096 ]; then
|
||||
recommended_memory=4096
|
||||
elif [ $recommended_memory -lt 1024 ]; then
|
||||
recommended_memory=1024
|
||||
fi
|
||||
|
||||
# Calculate recommended cores (leave 2 cores for system)
|
||||
local recommended_cores=$((cpu_cores - 2))
|
||||
if [ $recommended_cores -lt 1 ]; then
|
||||
recommended_cores=1
|
||||
elif [ $recommended_cores -gt 4 ]; then
|
||||
recommended_cores=4
|
||||
fi
|
||||
|
||||
# Determine GPU setting
|
||||
local gpu_setting=""
|
||||
case $gpu_capable in
|
||||
0) gpu_setting="-gpu host" ;;
|
||||
1) gpu_setting="-gpu swiftshader_indirect" ;;
|
||||
2) gpu_setting="-gpu swiftshader_indirect" ;;
|
||||
esac
|
||||
|
||||
# Generate recommendation
|
||||
print_status $GREEN_COLOR "Recommended AVD Configuration:"
|
||||
echo " Memory: ${recommended_memory}MB"
|
||||
echo " Cores: ${recommended_cores}"
|
||||
echo " GPU: ${gpu_setting}"
|
||||
|
||||
# Get AVD name from function parameter (passed from main)
|
||||
local avd_name=$5
|
||||
local command="emulator -avd ${avd_name} -no-audio -memory ${recommended_memory} -cores ${recommended_cores} ${gpu_setting} &"
|
||||
|
||||
print_status $BLUE_COLOR "\nGenerated Command:"
|
||||
echo " ${command}"
|
||||
|
||||
# Save to file for easy execution
|
||||
local script_file="/tmp/start-avd-${avd_name}.sh"
|
||||
cat > "$script_file" << EOF
|
||||
#!/bin/bash
|
||||
# Auto-generated AVD startup script
|
||||
# Generated by avd-resource-checker.sh on $(date)
|
||||
|
||||
echo "Starting AVD: ${avd_name}"
|
||||
echo "Memory: ${recommended_memory}MB"
|
||||
echo "Cores: ${recommended_cores}"
|
||||
echo "GPU: ${gpu_setting}"
|
||||
|
||||
${command}
|
||||
|
||||
echo "AVD started in background"
|
||||
echo "Check status with: adb devices"
|
||||
echo "View logs with: adb logcat"
|
||||
EOF
|
||||
|
||||
chmod +x "$script_file"
|
||||
print_status $GREEN_COLOR "\n✓ Startup script saved to: ${script_file}"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to test AVD startup
|
||||
test_avd_startup() {
|
||||
local avd_name=$1
|
||||
local test_duration=${2:-30}
|
||||
|
||||
print_status $BLUE_COLOR "\n=== Testing AVD Startup ==="
|
||||
|
||||
# Check if AVD exists
|
||||
if ! avdmanager list avd | grep -q "$avd_name"; then
|
||||
print_status $RED_COLOR "✗ AVD '$avd_name' not found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_status $YELLOW_COLOR "Testing AVD startup for ${test_duration} seconds..."
|
||||
|
||||
# Start emulator in test mode
|
||||
emulator -avd "$avd_name" -no-audio -no-window -no-snapshot -memory 1024 -cores 1 -gpu swiftshader_indirect &
|
||||
local emulator_pid=$!
|
||||
|
||||
# Wait for boot
|
||||
local boot_time=0
|
||||
local max_wait=$test_duration
|
||||
|
||||
while [ $boot_time -lt $max_wait ]; do
|
||||
if adb devices | grep -q "emulator.*device"; then
|
||||
print_status $GREEN_COLOR "✓ AVD booted successfully in ${boot_time} seconds"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
boot_time=$((boot_time + 2))
|
||||
done
|
||||
|
||||
# Cleanup
|
||||
kill $emulator_pid 2>/dev/null || true
|
||||
adb emu kill 2>/dev/null || true
|
||||
|
||||
if [ $boot_time -ge $max_wait ]; then
|
||||
print_status $RED_COLOR "✗ AVD failed to boot within ${test_duration} seconds"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to list available AVDs
|
||||
list_available_avds() {
|
||||
print_status $BLUE_COLOR "\n=== Available AVDs ==="
|
||||
|
||||
if ! command -v avdmanager >/dev/null 2>&1; then
|
||||
print_status $RED_COLOR "✗ avdmanager not found. Please install Android SDK command line tools."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local avd_list=$(avdmanager list avd 2>/dev/null)
|
||||
if [ -z "$avd_list" ]; then
|
||||
print_status $YELLOW_COLOR "⚠ No AVDs found. Create one with:"
|
||||
echo " avdmanager create avd --name TimeSafari_Emulator --package system-images;android-34;google_apis;x86_64"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$avd_list"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to create optimized AVD
|
||||
create_optimized_avd() {
|
||||
local avd_name=$1
|
||||
local memory=$2
|
||||
local cores=$3
|
||||
|
||||
print_status $BLUE_COLOR "\n=== Creating Optimized AVD ==="
|
||||
|
||||
# Check if system image is available
|
||||
local system_image="system-images;android-34;google_apis;x86_64"
|
||||
if ! sdkmanager --list | grep -q "$system_image"; then
|
||||
print_status $YELLOW_COLOR "Installing system image: $system_image"
|
||||
sdkmanager "$system_image"
|
||||
fi
|
||||
|
||||
# Create AVD
|
||||
print_status $YELLOW_COLOR "Creating AVD: $avd_name"
|
||||
avdmanager create avd \
|
||||
--name "$avd_name" \
|
||||
--package "$system_image" \
|
||||
--device "pixel_7" \
|
||||
--force
|
||||
|
||||
# Configure AVD
|
||||
local avd_config_file="$HOME/.android/avd/${avd_name}.avd/config.ini"
|
||||
if [ -f "$avd_config_file" ]; then
|
||||
print_status $YELLOW_COLOR "Configuring AVD settings..."
|
||||
|
||||
# Set memory
|
||||
sed -i "s/vm.heapSize=.*/vm.heapSize=${memory}/" "$avd_config_file"
|
||||
|
||||
# Set cores
|
||||
sed -i "s/hw.cpu.ncore=.*/hw.cpu.ncore=${cores}/" "$avd_config_file"
|
||||
|
||||
# Disable unnecessary features
|
||||
echo "hw.audioInput=no" >> "$avd_config_file"
|
||||
echo "hw.audioOutput=no" >> "$avd_config_file"
|
||||
echo "hw.camera.back=none" >> "$avd_config_file"
|
||||
echo "hw.camera.front=none" >> "$avd_config_file"
|
||||
echo "hw.gps=no" >> "$avd_config_file"
|
||||
echo "hw.sensors.orientation=no" >> "$avd_config_file"
|
||||
echo "hw.sensors.proximity=no" >> "$avd_config_file"
|
||||
|
||||
print_status $GREEN_COLOR "✓ AVD configured successfully"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
print_status $BLUE_COLOR "=== TimeSafari AVD Resource Checker ==="
|
||||
print_status $BLUE_COLOR "Checking system resources and recommending optimal AVD configuration\n"
|
||||
|
||||
# Get system information
|
||||
local total_memory=$(get_system_memory)
|
||||
local available_memory=$(get_available_memory)
|
||||
local cpu_cores=$(get_cpu_cores)
|
||||
|
||||
print_status $BLUE_COLOR "=== System Information ==="
|
||||
echo "Total Memory: ${total_memory}MB"
|
||||
echo "Available Memory: ${available_memory}MB"
|
||||
echo "CPU Cores: ${cpu_cores}"
|
||||
|
||||
# Check GPU capabilities
|
||||
print_status $BLUE_COLOR "\n=== GPU Analysis ==="
|
||||
check_gpu_capabilities
|
||||
local gpu_capable=$?
|
||||
|
||||
# Check hardware acceleration
|
||||
check_hardware_acceleration $gpu_capable
|
||||
local hw_accel=$?
|
||||
|
||||
# List available AVDs
|
||||
list_available_avds
|
||||
|
||||
# Get AVD name from user or use default
|
||||
local avd_name="TimeSafari_Emulator"
|
||||
if [ $# -gt 0 ]; then
|
||||
avd_name="$1"
|
||||
fi
|
||||
|
||||
# Recommend configuration
|
||||
recommend_avd_config $total_memory $available_memory $cpu_cores $gpu_capable "$avd_name"
|
||||
|
||||
# Test AVD if requested
|
||||
if [ "$2" = "--test" ]; then
|
||||
test_avd_startup "$avd_name"
|
||||
fi
|
||||
|
||||
# Create optimized AVD if requested
|
||||
if [ "$2" = "--create" ]; then
|
||||
local recommended_memory=$((available_memory - 2048))
|
||||
if [ $recommended_memory -gt 4096 ]; then
|
||||
recommended_memory=4096
|
||||
elif [ $recommended_memory -lt 1024 ]; then
|
||||
recommended_memory=1024
|
||||
fi
|
||||
|
||||
local recommended_cores=$((cpu_cores - 2))
|
||||
if [ $recommended_cores -lt 1 ]; then
|
||||
recommended_cores=1
|
||||
elif [ $recommended_cores -gt 4 ]; then
|
||||
recommended_cores=4
|
||||
fi
|
||||
|
||||
create_optimized_avd "$avd_name" $recommended_memory $recommended_cores
|
||||
fi
|
||||
|
||||
print_status $GREEN_COLOR "\n=== Resource Check Complete ==="
|
||||
print_status $YELLOW_COLOR "Tip: Use the generated startup script for consistent AVD launches"
|
||||
}
|
||||
|
||||
# Show help
|
||||
show_help() {
|
||||
echo "Usage: $0 [AVD_NAME] [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --test Test AVD startup (30 second test)"
|
||||
echo " --create Create optimized AVD with recommended settings"
|
||||
echo " --help Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Check resources and recommend config"
|
||||
echo " $0 TimeSafari_Emulator # Check resources for specific AVD"
|
||||
echo " $0 TimeSafari_Emulator --test # Test AVD startup"
|
||||
echo " $0 TimeSafari_Emulator --create # Create optimized AVD"
|
||||
echo ""
|
||||
echo "The script will:"
|
||||
echo " - Analyze system resources (RAM, CPU, GPU)"
|
||||
echo " - Recommend optimal AVD configuration"
|
||||
echo " - Generate startup command and script"
|
||||
echo " - Optionally test or create AVD"
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
||||
show_help
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
@@ -22,6 +22,7 @@
|
||||
# --sync Sync Capacitor only
|
||||
# --assets Generate assets only
|
||||
# --deploy Deploy APK to connected device
|
||||
# --uninstall Uninstall app from connected device
|
||||
# -h, --help Show this help message
|
||||
# -v, --verbose Enable verbose logging
|
||||
#
|
||||
@@ -74,6 +75,146 @@ validate_dependencies() {
|
||||
log_success "All critical dependencies validated successfully"
|
||||
}
|
||||
|
||||
# Function to detect and set JAVA_HOME for Android builds
|
||||
setup_java_home() {
|
||||
log_info "Setting up Java environment..."
|
||||
|
||||
# If JAVA_HOME is already set and valid, use it
|
||||
if [ -n "$JAVA_HOME" ] && [ -x "$JAVA_HOME/bin/java" ]; then
|
||||
log_debug "Using existing JAVA_HOME: $JAVA_HOME"
|
||||
export JAVA_HOME
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Try to find Java in Android Studio's bundled JBR
|
||||
local android_studio_jbr="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
if [ -d "$android_studio_jbr" ] && [ -x "$android_studio_jbr/bin/java" ]; then
|
||||
export JAVA_HOME="$android_studio_jbr"
|
||||
log_info "Found Java in Android Studio: $JAVA_HOME"
|
||||
if [ -x "$JAVA_HOME/bin/java" ]; then
|
||||
log_debug "Java version: $(\"$JAVA_HOME/bin/java\" -version 2>&1 | head -1 || echo 'Unable to get version')"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Try alternative Android Studio location (older versions)
|
||||
local android_studio_jre="/Applications/Android Studio.app/Contents/jre/Contents/Home"
|
||||
if [ -d "$android_studio_jre" ] && [ -x "$android_studio_jre/bin/java" ]; then
|
||||
export JAVA_HOME="$android_studio_jre"
|
||||
log_info "Found Java in Android Studio (legacy): $JAVA_HOME"
|
||||
log_debug "Java version: $(\"$JAVA_HOME/bin/java\" -version 2>&1 | head -1)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Try to use /usr/libexec/java_home on macOS
|
||||
if [ "$(uname)" = "Darwin" ] && command -v /usr/libexec/java_home >/dev/null 2>&1; then
|
||||
local java_home_output=$(/usr/libexec/java_home 2>/dev/null)
|
||||
if [ -n "$java_home_output" ] && [ -x "$java_home_output/bin/java" ]; then
|
||||
export JAVA_HOME="$java_home_output"
|
||||
log_info "Found Java via java_home utility: $JAVA_HOME"
|
||||
log_debug "Java version: $(\"$JAVA_HOME/bin/java\" -version 2>&1 | head -1)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try to find java in PATH
|
||||
if command -v java >/dev/null 2>&1; then
|
||||
local java_path=$(command -v java)
|
||||
# Resolve symlinks to find actual Java home (portable approach)
|
||||
local java_real="$java_path"
|
||||
# Try different methods to resolve symlinks
|
||||
if [ -L "$java_path" ]; then
|
||||
if command -v readlink >/dev/null 2>&1; then
|
||||
java_real=$(readlink "$java_path" 2>/dev/null || echo "$java_path")
|
||||
elif command -v realpath >/dev/null 2>&1; then
|
||||
java_real=$(realpath "$java_path" 2>/dev/null || echo "$java_path")
|
||||
fi
|
||||
fi
|
||||
local java_home_candidate=$(dirname "$(dirname "$java_real")")
|
||||
if [ -d "$java_home_candidate" ] && [ -x "$java_home_candidate/bin/java" ]; then
|
||||
export JAVA_HOME="$java_home_candidate"
|
||||
log_info "Found Java in PATH: $JAVA_HOME"
|
||||
log_debug "Java version: $(\"$JAVA_HOME/bin/java\" -version 2>&1 | head -1)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# If we get here, Java was not found
|
||||
log_error "Java Runtime not found!"
|
||||
log_error "Please ensure one of the following:"
|
||||
log_error " 1. Android Studio is installed (includes bundled Java)"
|
||||
log_error " 2. JAVA_HOME is set to a valid Java installation"
|
||||
log_error " 3. Java is available in your PATH"
|
||||
log_error ""
|
||||
log_error "On macOS, Android Studio typically includes Java at:"
|
||||
log_error " /Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to detect and set ANDROID_HOME for Android builds
|
||||
setup_android_home() {
|
||||
log_info "Setting up Android SDK environment..."
|
||||
|
||||
# If ANDROID_HOME is already set and valid, use it
|
||||
if [ -n "$ANDROID_HOME" ] && [ -d "$ANDROID_HOME" ]; then
|
||||
log_debug "Using existing ANDROID_HOME: $ANDROID_HOME"
|
||||
export ANDROID_HOME
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for local.properties file in android directory
|
||||
local local_props="android/local.properties"
|
||||
if [ -f "$local_props" ]; then
|
||||
local sdk_dir=$(grep "^sdk.dir=" "$local_props" 2>/dev/null | cut -d'=' -f2 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed "s|^file://||")
|
||||
if [ -n "$sdk_dir" ] && [ -d "$sdk_dir" ]; then
|
||||
export ANDROID_HOME="$sdk_dir"
|
||||
log_info "Found Android SDK in local.properties: $ANDROID_HOME"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try common macOS locations for Android SDK
|
||||
local common_locations=(
|
||||
"$HOME/Library/Android/sdk"
|
||||
"$HOME/Android/Sdk"
|
||||
"$HOME/.android/sdk"
|
||||
)
|
||||
|
||||
for sdk_path in "${common_locations[@]}"; do
|
||||
if [ -d "$sdk_path" ] && [ -d "$sdk_path/platform-tools" ]; then
|
||||
export ANDROID_HOME="$sdk_path"
|
||||
log_info "Found Android SDK: $ANDROID_HOME"
|
||||
|
||||
# Write to local.properties if it doesn't exist or doesn't have sdk.dir
|
||||
if [ ! -f "$local_props" ] || ! grep -q "^sdk.dir=" "$local_props" 2>/dev/null; then
|
||||
log_info "Writing Android SDK location to local.properties"
|
||||
mkdir -p android
|
||||
if [ -f "$local_props" ]; then
|
||||
echo "" >> "$local_props"
|
||||
echo "sdk.dir=$ANDROID_HOME" >> "$local_props"
|
||||
else
|
||||
echo "sdk.dir=$ANDROID_HOME" > "$local_props"
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# If we get here, Android SDK was not found
|
||||
log_error "Android SDK not found!"
|
||||
log_error "Please ensure one of the following:"
|
||||
log_error " 1. ANDROID_HOME is set to a valid Android SDK location"
|
||||
log_error " 2. Android SDK is installed at one of these locations:"
|
||||
log_error " - $HOME/Library/Android/sdk (macOS default)"
|
||||
log_error " - $HOME/Android/Sdk"
|
||||
log_error " 3. android/local.properties contains sdk.dir pointing to SDK"
|
||||
log_error ""
|
||||
log_error "You can find your SDK location in Android Studio:"
|
||||
log_error " Preferences > Appearance & Behavior > System Settings > Android SDK"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to validate Android assets and resources
|
||||
validate_android_assets() {
|
||||
log_info "Validating Android assets and resources..."
|
||||
@@ -196,6 +337,7 @@ SYNC_ONLY=false
|
||||
ASSETS_ONLY=false
|
||||
DEPLOY_APP=false
|
||||
AUTO_RUN=false
|
||||
UNINSTALL=false
|
||||
CUSTOM_API_IP=""
|
||||
|
||||
# Function to parse Android-specific arguments
|
||||
@@ -246,6 +388,9 @@ parse_android_args() {
|
||||
--auto-run)
|
||||
AUTO_RUN=true
|
||||
;;
|
||||
--uninstall)
|
||||
UNINSTALL=true
|
||||
;;
|
||||
--api-ip)
|
||||
if [ $((i + 1)) -lt ${#args[@]} ]; then
|
||||
CUSTOM_API_IP="${args[$((i + 1))]}"
|
||||
@@ -291,6 +436,7 @@ print_android_usage() {
|
||||
echo " --assets Generate assets only"
|
||||
echo " --deploy Deploy APK to connected device"
|
||||
echo " --auto-run Auto-run app after build"
|
||||
echo " --uninstall Uninstall app from connected device"
|
||||
echo " --api-ip <ip> Custom IP address for claim API (defaults to 10.0.2.2)"
|
||||
echo ""
|
||||
echo "Common Options:"
|
||||
@@ -305,6 +451,7 @@ print_android_usage() {
|
||||
echo " $0 --clean # Clean only"
|
||||
echo " $0 --sync # Sync only"
|
||||
echo " $0 --deploy # Build and deploy to device"
|
||||
echo " $0 --uninstall # Uninstall app from device"
|
||||
echo " $0 --dev # Dev build with default 10.0.2.2"
|
||||
echo " $0 --dev --api-ip 192.168.1.100 # Dev build with custom API IP"
|
||||
echo ""
|
||||
@@ -319,6 +466,18 @@ print_header "TimeSafari Android Build Process"
|
||||
# Validate dependencies before proceeding
|
||||
validate_dependencies
|
||||
|
||||
# Setup Java environment for Gradle
|
||||
setup_java_home || {
|
||||
log_error "Failed to setup Java environment. Cannot proceed with Android build."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Setup Android SDK environment for Gradle
|
||||
setup_android_home || {
|
||||
log_error "Failed to setup Android SDK environment. Cannot proceed with Android build."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Validate Android assets and resources
|
||||
validate_android_assets || {
|
||||
log_error "Android asset validation failed. Please fix the issues above and try again."
|
||||
@@ -351,8 +510,18 @@ fi
|
||||
# Setup application directories
|
||||
setup_app_directories
|
||||
|
||||
# Load environment from .env file if it exists
|
||||
load_env_file ".env"
|
||||
# Load environment-specific .env file if it exists
|
||||
env_file=".env.$BUILD_MODE"
|
||||
if [ -f "$env_file" ]; then
|
||||
load_env_file "$env_file"
|
||||
else
|
||||
log_debug "No $env_file file found, using default environment"
|
||||
fi
|
||||
|
||||
# Load .env file if it exists (fallback)
|
||||
if [ -f ".env" ]; then
|
||||
load_env_file ".env"
|
||||
fi
|
||||
|
||||
# Handle clean-only mode
|
||||
if [ "$CLEAN_ONLY" = true ]; then
|
||||
@@ -368,6 +537,7 @@ fi
|
||||
if [ "$SYNC_ONLY" = true ]; then
|
||||
log_info "Sync-only mode: syncing with Capacitor"
|
||||
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
|
||||
safe_execute "Restoring local plugins" "node scripts/restore-local-plugins.js" || exit 7
|
||||
log_success "Sync completed successfully!"
|
||||
exit 0
|
||||
fi
|
||||
@@ -407,14 +577,33 @@ safe_execute "Validating asset configuration" "npm run assets:validate" || {
|
||||
log_info "If you encounter build failures, please run 'npm install' first to ensure all dependencies are available."
|
||||
}
|
||||
|
||||
# Step 2: Clean Android app
|
||||
safe_execute "Cleaning Android app" "npm run clean:android" || exit 1
|
||||
# Step 2: Uninstall Android app
|
||||
if [ "$UNINSTALL" = true ]; then
|
||||
log_info "Uninstall: uninstalling app from device"
|
||||
safe_execute "Uninstalling Android app" "./scripts/uninstall-android.sh" || exit 1
|
||||
log_success "Uninstall completed successfully!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Step 3: Clean dist directory
|
||||
log_info "Cleaning dist directory..."
|
||||
clean_build_artifacts "dist"
|
||||
|
||||
# Step 4: Build Capacitor version with mode
|
||||
# Step 4: Run TypeScript type checking for test and production builds
|
||||
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
|
||||
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
|
||||
|
||||
if ! measure_time npm run type-check; then
|
||||
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log_success "TypeScript type checking completed for $BUILD_MODE mode"
|
||||
else
|
||||
log_debug "Skipping TypeScript type checking for development mode"
|
||||
fi
|
||||
|
||||
# Step 5: Build Capacitor version with mode
|
||||
if [ "$BUILD_MODE" = "development" ]; then
|
||||
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
||||
elif [ "$BUILD_MODE" = "test" ]; then
|
||||
@@ -423,23 +612,26 @@ elif [ "$BUILD_MODE" = "production" ]; then
|
||||
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
||||
fi
|
||||
|
||||
# Step 5: Clean Gradle build
|
||||
# Step 6: Clean Gradle build
|
||||
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
|
||||
|
||||
# Step 6: Build based on type
|
||||
# Step 7: Build based on type
|
||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
||||
elif [ "$BUILD_TYPE" = "release" ]; then
|
||||
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
|
||||
fi
|
||||
|
||||
# Step 7: Sync with Capacitor
|
||||
# Step 8: Sync with Capacitor
|
||||
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
|
||||
|
||||
# Step 8: Generate assets
|
||||
# Step 8.5: Restore local plugins (capacitor.plugins.json gets overwritten by cap sync)
|
||||
safe_execute "Restoring local plugins" "node scripts/restore-local-plugins.js" || exit 7
|
||||
|
||||
# Step 9: Generate assets
|
||||
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
|
||||
|
||||
# Step 9: Build APK/AAB if requested
|
||||
# Step 10: Build APK/AAB if requested
|
||||
if [ "$BUILD_APK" = true ]; then
|
||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
||||
@@ -452,7 +644,7 @@ if [ "$BUILD_AAB" = true ]; then
|
||||
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
|
||||
fi
|
||||
|
||||
# Step 10: Auto-run app if requested
|
||||
# Step 11: Auto-run app if requested
|
||||
if [ "$AUTO_RUN" = true ]; then
|
||||
log_step "Auto-running Android app..."
|
||||
safe_execute "Launching app" "npx cap run android" || {
|
||||
@@ -463,7 +655,7 @@ if [ "$AUTO_RUN" = true ]; then
|
||||
log_success "Android app launched successfully!"
|
||||
fi
|
||||
|
||||
# Step 11: Open Android Studio if requested
|
||||
# Step 12: Open Android Studio if requested
|
||||
if [ "$OPEN_STUDIO" = true ]; then
|
||||
safe_execute "Opening Android Studio" "npx cap open android" || exit 8
|
||||
fi
|
||||
|
||||
@@ -215,9 +215,9 @@ clean_electron_artifacts() {
|
||||
safe_execute "Cleaning Electron app directory" "rm -rf electron/app"
|
||||
fi
|
||||
|
||||
# Clean TypeScript compilation artifacts
|
||||
# Clean TypeScript compilation artifacts (exclude hand-maintained electron-plugins.js)
|
||||
if [[ -d "electron/src" ]]; then
|
||||
safe_execute "Cleaning TypeScript artifacts" "find electron/src -name '*.js' -delete 2>/dev/null || true"
|
||||
safe_execute "Cleaning TypeScript artifacts" "find electron/src -name '*.js' ! -path 'electron/src/rt/electron-plugins.js' -delete 2>/dev/null || true"
|
||||
safe_execute "Cleaning TypeScript artifacts" "find electron/src -name '*.js.map' -delete 2>/dev/null || true"
|
||||
fi
|
||||
|
||||
@@ -341,7 +341,19 @@ main_electron_build() {
|
||||
# Setup environment
|
||||
setup_build_env "electron" "$BUILD_MODE"
|
||||
setup_app_directories
|
||||
load_env_file ".env"
|
||||
|
||||
# Load environment-specific .env file if it exists
|
||||
env_file=".env.$BUILD_MODE"
|
||||
if [ -f "$env_file" ]; then
|
||||
load_env_file "$env_file"
|
||||
else
|
||||
log_debug "No $env_file file found, using default environment"
|
||||
fi
|
||||
|
||||
# Load .env file if it exists (fallback)
|
||||
if [ -f ".env" ]; then
|
||||
load_env_file ".env"
|
||||
fi
|
||||
|
||||
# Step 1: Clean Electron build artifacts
|
||||
clean_electron_artifacts
|
||||
|
||||
@@ -324,8 +324,18 @@ fi
|
||||
# Setup application directories
|
||||
setup_app_directories
|
||||
|
||||
# Load environment from .env file if it exists
|
||||
load_env_file ".env"
|
||||
# Load environment-specific .env file if it exists
|
||||
env_file=".env.$BUILD_MODE"
|
||||
if [ -f "$env_file" ]; then
|
||||
load_env_file "$env_file"
|
||||
else
|
||||
log_debug "No $env_file file found, using default environment"
|
||||
fi
|
||||
|
||||
# Load .env file if it exists (fallback)
|
||||
if [ -f ".env" ]; then
|
||||
load_env_file ".env"
|
||||
fi
|
||||
|
||||
# Validate iOS environment
|
||||
validate_ios_environment
|
||||
@@ -339,10 +349,56 @@ if [ "$CLEAN_ONLY" = true ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Xcode 26 / CocoaPods workaround for cap sync (used by sync-only and full build)
|
||||
# Temporarily downgrade project.pbxproj objectVersion 70 -> 56 so pod install succeeds.
|
||||
run_cap_sync_with_workaround() {
|
||||
local PROJECT_FILE="ios/App/App.xcodeproj/project.pbxproj"
|
||||
|
||||
if [ ! -f "$PROJECT_FILE" ]; then
|
||||
log_error "Project file not found: $PROJECT_FILE (run full build first?)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local current_version
|
||||
current_version=$(grep "objectVersion" "$PROJECT_FILE" | head -1 | grep -o "[0-9]\+" || echo "")
|
||||
|
||||
if [ -z "$current_version" ]; then
|
||||
log_error "Could not determine project format version for Capacitor sync"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$current_version" = "70" ]; then
|
||||
log_debug "Applying Xcode 26 workaround for Capacitor sync: temporarily downgrading to format 56"
|
||||
|
||||
if ! sed -i '' 's/objectVersion = 70;/objectVersion = 56;/' "$PROJECT_FILE"; then
|
||||
log_error "Failed to downgrade project format for Capacitor sync"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Running Capacitor sync..."
|
||||
if ! npx cap sync ios; then
|
||||
log_error "Capacitor sync failed"
|
||||
sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_debug "Restoring project format to 70 after Capacitor sync..."
|
||||
sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true
|
||||
log_success "Capacitor sync completed successfully"
|
||||
else
|
||||
log_debug "Project format is $current_version, running Capacitor sync normally"
|
||||
if ! npx cap sync ios; then
|
||||
log_error "Capacitor sync failed"
|
||||
return 1
|
||||
fi
|
||||
log_success "Capacitor sync completed successfully"
|
||||
fi
|
||||
}
|
||||
|
||||
# Handle sync-only mode
|
||||
if [ "$SYNC_ONLY" = true ]; then
|
||||
log_info "Sync-only mode: syncing with Capacitor"
|
||||
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
|
||||
log_info "Sync-only mode: syncing with Capacitor (with Xcode 26 workaround if needed)"
|
||||
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
|
||||
log_success "Sync completed successfully!"
|
||||
exit 0
|
||||
fi
|
||||
@@ -371,7 +427,21 @@ safe_execute "Cleaning iOS build" "clean_ios_build" || exit 1
|
||||
log_info "Cleaning dist directory..."
|
||||
clean_build_artifacts "dist"
|
||||
|
||||
# Step 4: Build Capacitor version with mode
|
||||
# Step 4: Run TypeScript type checking for test and production builds
|
||||
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
|
||||
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
|
||||
|
||||
if ! measure_time npm run type-check; then
|
||||
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log_success "TypeScript type checking completed for $BUILD_MODE mode"
|
||||
else
|
||||
log_debug "Skipping TypeScript type checking for development mode"
|
||||
fi
|
||||
|
||||
# Step 5: Build Capacitor version with mode
|
||||
if [ "$BUILD_MODE" = "development" ]; then
|
||||
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
||||
elif [ "$BUILD_MODE" = "test" ]; then
|
||||
@@ -380,16 +450,117 @@ elif [ "$BUILD_MODE" = "production" ]; then
|
||||
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
||||
fi
|
||||
|
||||
# Step 5: Sync with Capacitor
|
||||
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
|
||||
# Step 6: Fix Daily Notification Plugin podspec name (must run before pod install)
|
||||
# ===================================================================
|
||||
# The Podfile expects TimesafariDailyNotificationPlugin.podspec, but the plugin
|
||||
# package only includes CapacitorDailyNotification.podspec. This script creates
|
||||
# the expected podspec file before CocoaPods tries to resolve dependencies.
|
||||
# ===================================================================
|
||||
log_info "Fixing Daily Notification Plugin podspec name..."
|
||||
if [ -f "./scripts/fix-daily-notification-podspec.sh" ]; then
|
||||
if ./scripts/fix-daily-notification-podspec.sh; then
|
||||
log_success "Daily Notification Plugin podspec created"
|
||||
else
|
||||
log_warn "Failed to create podspec (may already exist)"
|
||||
fi
|
||||
else
|
||||
log_warn "fix-daily-notification-podspec.sh not found, skipping"
|
||||
fi
|
||||
|
||||
# Step 6: Generate assets
|
||||
# Step 6.5: Install CocoaPods dependencies (with Xcode 26 workaround)
|
||||
# ===================================================================
|
||||
# WORKAROUND: Xcode 26 / CocoaPods Compatibility Issue
|
||||
# ===================================================================
|
||||
# Xcode 26 uses project format version 70, but CocoaPods' xcodeproj gem
|
||||
# (1.27.0) only supports up to version 56. This causes pod install to fail.
|
||||
#
|
||||
# This workaround temporarily downgrades the project format to 56, runs
|
||||
# pod install, then restores it to 70. Xcode will automatically upgrade
|
||||
# it back to 70 when opened, which is fine.
|
||||
#
|
||||
# NOTE: Both explicit pod install AND Capacitor sync (which runs pod install
|
||||
# internally) need this workaround. run_pod_install_with_workaround() is below;
|
||||
# run_cap_sync_with_workaround() is defined earlier (used by --sync and Step 6.6).
|
||||
#
|
||||
# TO REMOVE THIS WORKAROUND IN THE FUTURE:
|
||||
# 1. Check if xcodeproj gem has been updated: bundle exec gem list xcodeproj
|
||||
# 2. Test if pod install works without the workaround
|
||||
# 3. If it works, remove run_pod_install_with_workaround() and run_cap_sync_with_workaround()
|
||||
# 4. Replace with:
|
||||
# - safe_execute "Installing CocoaPods dependencies" "cd ios/App && bundle exec pod install && cd ../.." || exit 6
|
||||
# - safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
|
||||
# 5. Update this comment to indicate the workaround has been removed
|
||||
# ===================================================================
|
||||
run_pod_install_with_workaround() {
|
||||
local PROJECT_FILE="ios/App/App.xcodeproj/project.pbxproj"
|
||||
|
||||
log_info "Installing CocoaPods dependencies (with Xcode 26 workaround)..."
|
||||
|
||||
# Check if project file exists
|
||||
if [ ! -f "$PROJECT_FILE" ]; then
|
||||
log_error "Project file not found: $PROJECT_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check current format version
|
||||
local current_version=$(grep "objectVersion" "$PROJECT_FILE" | head -1 | grep -o "[0-9]\+" || echo "")
|
||||
|
||||
if [ -z "$current_version" ]; then
|
||||
log_error "Could not determine project format version"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_debug "Current project format version: $current_version"
|
||||
|
||||
# Only apply workaround if format is 70
|
||||
if [ "$current_version" = "70" ]; then
|
||||
log_debug "Applying Xcode 26 workaround: temporarily downgrading to format 56"
|
||||
|
||||
# Downgrade to format 56 (supported by CocoaPods)
|
||||
if ! sed -i '' 's/objectVersion = 70;/objectVersion = 56;/' "$PROJECT_FILE"; then
|
||||
log_error "Failed to downgrade project format"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Run pod install
|
||||
log_info "Running pod install..."
|
||||
if ! (cd ios/App && bundle exec pod install && cd ../..); then
|
||||
log_error "pod install failed"
|
||||
# Try to restore format even on failure
|
||||
sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Restore to format 70
|
||||
log_debug "Restoring project format to 70..."
|
||||
if ! sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE"; then
|
||||
log_warn "Failed to restore project format to 70 (Xcode will upgrade it automatically)"
|
||||
fi
|
||||
|
||||
log_success "CocoaPods dependencies installed successfully"
|
||||
else
|
||||
# Format is not 70, run pod install normally
|
||||
log_debug "Project format is $current_version, running pod install normally"
|
||||
if ! (cd ios/App && bundle exec pod install && cd ../..); then
|
||||
log_error "pod install failed"
|
||||
return 1
|
||||
fi
|
||||
log_success "CocoaPods dependencies installed successfully"
|
||||
fi
|
||||
}
|
||||
|
||||
safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaround" || exit 6
|
||||
|
||||
# Step 6.6: Sync with Capacitor (uses run_cap_sync_with_workaround defined above for Xcode 26)
|
||||
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
|
||||
|
||||
# Step 7: Generate assets
|
||||
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
||||
|
||||
# Step 7: Build iOS app
|
||||
# Step 8: Build iOS app
|
||||
safe_execute "Building iOS app" "build_ios_app" || exit 5
|
||||
|
||||
# Step 8: Build IPA/App if requested
|
||||
# Step 9: Build IPA/App if requested
|
||||
if [ "$BUILD_IPA" = true ]; then
|
||||
log_info "Building IPA package..."
|
||||
cd ios/App
|
||||
@@ -416,12 +587,12 @@ if [ "$BUILD_APP" = true ]; then
|
||||
log_success "App bundle built successfully"
|
||||
fi
|
||||
|
||||
# Step 9: Auto-run app if requested
|
||||
# Step 10: Auto-run app if requested
|
||||
if [ "$AUTO_RUN" = true ]; then
|
||||
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
|
||||
fi
|
||||
|
||||
# Step 10: Open Xcode if requested
|
||||
# Step 11: Open Xcode if requested
|
||||
if [ "$OPEN_STUDIO" = true ]; then
|
||||
safe_execute "Opening Xcode" "npx cap open ios" || exit 8
|
||||
fi
|
||||
|
||||
70
scripts/check-alarm-logs.sh
Executable file
70
scripts/check-alarm-logs.sh
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# check-alarm-logs.sh
|
||||
# Author: Matthew Raymer
|
||||
# Description: Check logs around a specific time to see if alarm fired
|
||||
|
||||
# Function to find adb command
|
||||
find_adb() {
|
||||
# Check if adb is in PATH
|
||||
if command -v adb >/dev/null 2>&1; then
|
||||
echo "adb"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for ANDROID_HOME
|
||||
if [ -n "$ANDROID_HOME" ] && [ -x "$ANDROID_HOME/platform-tools/adb" ]; then
|
||||
echo "$ANDROID_HOME/platform-tools/adb"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for local.properties
|
||||
local local_props="android/local.properties"
|
||||
if [ -f "$local_props" ]; then
|
||||
local sdk_dir=$(grep "^sdk.dir=" "$local_props" 2>/dev/null | cut -d'=' -f2 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed "s|^file://||")
|
||||
if [ -n "$sdk_dir" ] && [ -x "$sdk_dir/platform-tools/adb" ]; then
|
||||
echo "$sdk_dir/platform-tools/adb"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try common macOS locations
|
||||
local common_locations=(
|
||||
"$HOME/Library/Android/sdk"
|
||||
"$HOME/Android/Sdk"
|
||||
"$HOME/.android/sdk"
|
||||
)
|
||||
|
||||
for sdk_path in "${common_locations[@]}"; do
|
||||
if [ -x "$sdk_path/platform-tools/adb" ]; then
|
||||
echo "$sdk_path/platform-tools/adb"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Not found
|
||||
return 1
|
||||
}
|
||||
|
||||
# Find adb
|
||||
ADB_CMD=$(find_adb)
|
||||
if [ $? -ne 0 ] || [ -z "$ADB_CMD" ]; then
|
||||
echo "Error: adb command not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checking logs for alarm activity..."
|
||||
echo "Looking for: DN|RECEIVE_START, AlarmManager, DailyNotification, timesafari"
|
||||
echo ""
|
||||
|
||||
# Check recent logs for alarm-related activity
|
||||
echo "=== Recent alarm/receiver logs ==="
|
||||
"$ADB_CMD" logcat -d | grep -iE "DN|RECEIVE_START|RECEIVE_ERR|alarm.*timesafari|daily.*notification|com\.timesafari\.daily" | tail -20
|
||||
|
||||
echo ""
|
||||
echo "=== All AlarmManager activity (last 50 lines) ==="
|
||||
"$ADB_CMD" logcat -d | grep -i "AlarmManager" | tail -50
|
||||
|
||||
echo ""
|
||||
echo "=== Check if alarm is still scheduled ==="
|
||||
echo "Run this to see all scheduled alarms:"
|
||||
echo " $ADB_CMD shell dumpsys alarm | grep -A 5 timesafari"
|
||||
@@ -116,7 +116,7 @@ echo "=============================="
|
||||
|
||||
# Analyze critical files identified in the assessment
|
||||
critical_files=(
|
||||
src/components/MembersList.vue"
|
||||
src/components/MeetingMembersList.vue"
|
||||
"src/views/ContactsView.vue"
|
||||
src/views/OnboardMeetingSetupView.vue"
|
||||
src/db/databaseUtil.ts"
|
||||
|
||||
35
scripts/fix-daily-notification-podspec.sh
Executable file
35
scripts/fix-daily-notification-podspec.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Fix Daily Notification Plugin Podspec Name
|
||||
# Creates a podspec with the expected name for Capacitor sync
|
||||
|
||||
PLUGIN_DIR="node_modules/@timesafari/daily-notification-plugin"
|
||||
PODSPEC_ACTUAL="CapacitorDailyNotification.podspec"
|
||||
PODSPEC_EXPECTED="TimesafariDailyNotificationPlugin.podspec"
|
||||
|
||||
if [ -f "$PLUGIN_DIR/$PODSPEC_ACTUAL" ] && [ ! -f "$PLUGIN_DIR/$PODSPEC_EXPECTED" ]; then
|
||||
echo "Creating podspec: $PODSPEC_EXPECTED"
|
||||
cat > "$PLUGIN_DIR/$PODSPEC_EXPECTED" << 'EOF'
|
||||
require 'json'
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'TimesafariDailyNotificationPlugin'
|
||||
s.version = package['version']
|
||||
s.summary = package['description']
|
||||
s.license = package['license']
|
||||
s.homepage = package['repository']['url']
|
||||
s.author = package['author']
|
||||
s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
|
||||
s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}'
|
||||
s.ios.deployment_target = '13.0'
|
||||
s.dependency 'Capacitor'
|
||||
s.swift_version = '5.1'
|
||||
end
|
||||
EOF
|
||||
echo "✓ Podspec created successfully"
|
||||
elif [ -f "$PLUGIN_DIR/$PODSPEC_EXPECTED" ]; then
|
||||
echo "ℹ Podspec already exists"
|
||||
else
|
||||
echo "⚠ Actual podspec not found at $PLUGIN_DIR/$PODSPEC_ACTUAL"
|
||||
fi
|
||||
@@ -38,7 +38,7 @@ The `pre-commit` hook automatically checks for debug code when committing to pro
|
||||
- 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
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ SKIP_PATTERNS=(
|
||||
"^test-.*/" # Test directories (must end with /)
|
||||
"^\.git/" # Git directory
|
||||
"^node_modules/" # Dependencies
|
||||
"^docs/" # Documentation
|
||||
"^doc/" # Documentation
|
||||
"^\.cursor/" # Cursor IDE files
|
||||
"\.md$" # Markdown files
|
||||
"\.txt$" # Text files
|
||||
|
||||
78
scripts/restore-local-plugins.js
Executable file
78
scripts/restore-local-plugins.js
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Restore Local Capacitor Plugins
|
||||
*
|
||||
* This script ensures that local custom plugins (SafeArea and SharedImage)
|
||||
* are present in capacitor.plugins.json after `npx cap sync` runs.
|
||||
*
|
||||
* The capacitor.plugins.json file is auto-generated by Capacitor and gets
|
||||
* overwritten during sync, so we need to restore our local plugins.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/restore-local-plugins.js
|
||||
*
|
||||
* This should be run after `npx cap sync android` or `npx cap sync ios`
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLUGINS_FILE = path.join(__dirname, '../android/app/src/main/assets/capacitor.plugins.json');
|
||||
|
||||
// Local plugins that need to be added
|
||||
const LOCAL_PLUGINS = [
|
||||
{
|
||||
pkg: 'SafeArea',
|
||||
classpath: 'app.timesafari.safearea.SafeAreaPlugin'
|
||||
},
|
||||
{
|
||||
pkg: 'SharedImage',
|
||||
classpath: 'app.timesafari.sharedimage.SharedImagePlugin'
|
||||
}
|
||||
];
|
||||
|
||||
function restoreLocalPlugins() {
|
||||
try {
|
||||
// Read the current plugins file
|
||||
if (!fs.existsSync(PLUGINS_FILE)) {
|
||||
console.error(`❌ Plugins file not found: ${PLUGINS_FILE}`);
|
||||
console.error(' Run "npx cap sync android" first to generate the file.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(PLUGINS_FILE, 'utf8');
|
||||
let plugins = JSON.parse(content);
|
||||
|
||||
if (!Array.isArray(plugins)) {
|
||||
console.error(`❌ Invalid plugins file format: expected array, got ${typeof plugins}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check which local plugins are missing
|
||||
const existingPackages = new Set(plugins.map(p => p.pkg));
|
||||
const missingPlugins = LOCAL_PLUGINS.filter(p => !existingPackages.has(p.pkg));
|
||||
|
||||
if (missingPlugins.length === 0) {
|
||||
console.log('✅ All local plugins are already present in capacitor.plugins.json');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add missing plugins
|
||||
plugins.push(...missingPlugins);
|
||||
|
||||
// Write back to file with proper formatting (matching existing style)
|
||||
const formatted = JSON.stringify(plugins, null, '\t');
|
||||
fs.writeFileSync(PLUGINS_FILE, formatted + '\n', 'utf8');
|
||||
|
||||
console.log('✅ Restored local plugins to capacitor.plugins.json:');
|
||||
missingPlugins.forEach(p => {
|
||||
console.log(` - ${p.pkg} (${p.classpath})`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error restoring local plugins:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
restoreLocalPlugins();
|
||||
@@ -93,7 +93,7 @@ echo "=========================="
|
||||
|
||||
# Critical files from our assessment
|
||||
files=(
|
||||
src/components/MembersList.vue"
|
||||
src/components/MeetingMembersList.vue"
|
||||
"src/views/ContactsView.vue"
|
||||
src/views/OnboardMeetingSetupView.vue"
|
||||
src/db/databaseUtil.ts"
|
||||
|
||||
104
scripts/test-notification-receiver.sh
Executable file
104
scripts/test-notification-receiver.sh
Executable file
@@ -0,0 +1,104 @@
|
||||
#!/bin/bash
|
||||
# test-notification-receiver.sh
|
||||
# Author: Matthew Raymer
|
||||
# Description: Test script to manually trigger the DailyNotificationReceiver
|
||||
# to verify it's working correctly
|
||||
|
||||
# Function to find adb command
|
||||
find_adb() {
|
||||
# Check if adb is in PATH
|
||||
if command -v adb >/dev/null 2>&1; then
|
||||
echo "adb"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for ANDROID_HOME
|
||||
if [ -n "$ANDROID_HOME" ] && [ -x "$ANDROID_HOME/platform-tools/adb" ]; then
|
||||
echo "$ANDROID_HOME/platform-tools/adb"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for local.properties
|
||||
local local_props="android/local.properties"
|
||||
if [ -f "$local_props" ]; then
|
||||
local sdk_dir=$(grep "^sdk.dir=" "$local_props" 2>/dev/null | cut -d'=' -f2 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed "s|^file://||")
|
||||
if [ -n "$sdk_dir" ] && [ -x "$sdk_dir/platform-tools/adb" ]; then
|
||||
echo "$sdk_dir/platform-tools/adb"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try common macOS locations
|
||||
local common_locations=(
|
||||
"$HOME/Library/Android/sdk"
|
||||
"$HOME/Android/Sdk"
|
||||
"$HOME/.android/sdk"
|
||||
)
|
||||
|
||||
for sdk_path in "${common_locations[@]}"; do
|
||||
if [ -x "$sdk_path/platform-tools/adb" ]; then
|
||||
echo "$sdk_path/platform-tools/adb"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Not found
|
||||
return 1
|
||||
}
|
||||
|
||||
# Find adb
|
||||
ADB_CMD=$(find_adb)
|
||||
if [ $? -ne 0 ] || [ -z "$ADB_CMD" ]; then
|
||||
echo "Error: adb command not found!"
|
||||
echo ""
|
||||
echo "Please ensure one of the following:"
|
||||
echo " 1. adb is in your PATH"
|
||||
echo " 2. ANDROID_HOME is set and points to Android SDK"
|
||||
echo " 3. Android SDK is installed at:"
|
||||
echo " - $HOME/Library/Android/sdk (macOS default)"
|
||||
echo " - $HOME/Android/Sdk"
|
||||
echo ""
|
||||
echo "You can find your SDK location in Android Studio:"
|
||||
echo " Preferences > Appearance & Behavior > System Settings > Android SDK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Testing DailyNotificationReceiver..."
|
||||
echo "Using adb: $ADB_CMD"
|
||||
echo ""
|
||||
|
||||
# Get the package name
|
||||
PACKAGE_NAME="app.timesafari.app"
|
||||
INTENT_ACTION="org.timesafari.daily.NOTIFICATION"
|
||||
|
||||
echo "Package: $PACKAGE_NAME"
|
||||
echo "Intent Action: $INTENT_ACTION"
|
||||
echo ""
|
||||
|
||||
# Check if device is connected
|
||||
if ! "$ADB_CMD" devices | grep -q $'\tdevice'; then
|
||||
echo "Error: No Android device/emulator connected!"
|
||||
echo ""
|
||||
echo "Please:"
|
||||
echo " 1. Start an Android emulator in Android Studio, or"
|
||||
echo " 2. Connect a physical device via USB"
|
||||
echo ""
|
||||
echo "Then run: $ADB_CMD devices"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 1: Send broadcast intent to trigger receiver (without ID - simulates current bug)
|
||||
echo "Test 1: Sending broadcast intent to DailyNotificationReceiver (without ID)..."
|
||||
"$ADB_CMD" shell am broadcast -a "$INTENT_ACTION" -n "$PACKAGE_NAME/org.timesafari.dailynotification.DailyNotificationReceiver"
|
||||
|
||||
echo ""
|
||||
echo "Test 2: Sending broadcast intent WITH ID (to test if receiver works with ID)..."
|
||||
"$ADB_CMD" shell am broadcast -a "$INTENT_ACTION" -n "$PACKAGE_NAME/org.timesafari.dailynotification.DailyNotificationReceiver" --es "id" "timesafari_daily_reminder"
|
||||
|
||||
echo ""
|
||||
echo "Check logcat for 'DN|RECEIVE_START' to see if receiver was triggered"
|
||||
echo "Test 1 should show 'missing_id' error"
|
||||
echo "Test 2 should work correctly (if plugin supports it)"
|
||||
echo ""
|
||||
echo "To monitor logs, run:"
|
||||
echo " $ADB_CMD logcat | grep -E 'DN|RECEIVE_START|DailyNotification'"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user