Compare commits

..

2 Commits

Author SHA1 Message Date
Jose Olarte III
ce54b0b435 mockup: change wording 2025-12-03 09:34:37 +08:00
Jose Olarte III
54351dd67c mockup: HomeView gifting buttons + GiftedDialog photo button 2025-12-02 23:32:28 +08:00
179 changed files with 8884 additions and 18624 deletions

View File

@@ -181,26 +181,26 @@ Brief description of the document's purpose and scope.
### Check Single File
```bash
npx markdownlint doc/filename.md
npx markdownlint docs/filename.md
```
### Check All Documentation
```bash
npx markdownlint doc/
npx markdownlint docs/
```
### Auto-fix Common Issues
```bash
# Remove trailing spaces
sed -i 's/[[:space:]]*$//' doc/filename.md
sed -i 's/[[:space:]]*$//' docs/filename.md
# Remove multiple blank lines
sed -i '/^$/N;/^\n$/D' doc/filename.md
sed -i '/^$/N;/^\n$/D' docs/filename.md
# Add newline at end if missing
echo "" >> doc/filename.md
echo "" >> docs/filename.md
```
## Common Patterns

View File

@@ -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 `doc/meta_rule_usage_guide.md` for comprehensive usage instructions
- **Getting Started**: See `docs/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

View File

@@ -122,11 +122,11 @@ npm run lint-fix
## Resources
- **Testing**: `doc/migration-testing/`
- **Testing**: `docs/migration-testing/`
- **Architecture**: `doc/architecture-decisions.md`
- **Architecture**: `docs/architecture-decisions.md`
- **Build Context**: `doc/build-modernization-context.md`
- **Build Context**: `docs/build-modernization-context.md`
---

View File

@@ -122,9 +122,9 @@ Copy/paste and fill:
- `src/...`
- ADR: `doc/adr/xxxx-yy-zz-something.md`
- ADR: `docs/adr/xxxx-yy-zz-something.md`
- Design: `doc/...`
- Design: `docs/...`
## Competence Hooks
@@ -230,7 +230,7 @@ Before proposing solutions, trace the actual execution path:
attach during service/feature investigations
- `doc/adr/**` — attach when editing ADRs
- `docs/adr/**` — attach when editing ADRs
## Referenced Files

View File

@@ -6,13 +6,10 @@ 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
View File

@@ -16,9 +16,6 @@ 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 Normal file → Executable file
View File

@@ -1,9 +1,40 @@
echo "husky - DEPRECATED
Please remove the following two lines from $0:
#!/usr/bin/env sh
. \"\$(dirname -- \"\$0\")/_/husky.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
}
They WILL FAIL in v10.0.0
"
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

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

View File

@@ -175,6 +175,27 @@ 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
@@ -196,7 +217,7 @@ cp .env.example .env.development
- Node.js 18+ and npm
- Git
- 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 mobile builds: Xcode (macOS) or Android Studio
- For desktop builds: Additional build tools based on your OS
## Forks
@@ -364,19 +385,20 @@ 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, you can do the same with `build:web:prod` instead.
- For prod, get on the server and run the correct build:
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`?
... and log onto the server:
- `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/`
Be sure to record the new hash in the changelog. Edit package.json to increment version &
- 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
@@ -1047,7 +1069,7 @@ npx cap sync electron
- Package integrity verification
- Rollback capabilities
For detailed documentation, see [doc/electron-build-patterns.md](doc/electron-build-patterns.md).
For detailed documentation, see [docs/electron-build-patterns.md](docs/electron-build-patterns.md).
## Mobile Builds (Capacitor)
@@ -1120,13 +1142,12 @@ 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 zsh`
- `pkgx +rubygems.org sh`
- ... and you may have to fix these, especially with pkgx:
@@ -1137,10 +1158,10 @@ export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
```bash
cd ios/App && xcrun agvtool new-version 65 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.3.8;/g" App.xcodeproj/project.pbxproj && cd -
cd ios/App && xcrun agvtool new-version 48 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.3;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
@@ -1181,128 +1202,7 @@ npm run build:ios:prod
### Android Build
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)
Prerequisites: Android Studio with Java SDK installed
#### Android Build Commands
@@ -1419,8 +1319,8 @@ The recommended way to build for Android is using the automated build script:
##### 1. Bump the version in package.json, then update these versions & run:
```bash
perl -p -i -e 's/versionCode .*/versionCode 65/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.3.8"/g' android/app/build.gradle
perl -p -i -e 's/versionCode .*/versionCode 48/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.3"/g' android/app/build.gradle
```
##### 2. Build
@@ -1809,13 +1709,11 @@ npm run build:android:assets
## Additional Resources
- [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)
- [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)
---
@@ -2438,7 +2336,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'),
@@ -2447,7 +2345,7 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
},
optimizeDeps: {
include: [
'nostr-tools',
'@nostr/tools',
'@jlongster/sql.js',
'absurd-sql',
// ... additional dependencies
@@ -2472,7 +2370,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
@@ -2612,7 +2510,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"]
}
}

View File

@@ -6,60 +6,6 @@ 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

View File

@@ -27,7 +27,7 @@ Large Components (>500 lines): 5 components (12.5%)
├── GiftedDialog.vue (670 lines) ⚠️ HIGH PRIORITY
├── PhotoDialog.vue (669 lines) ⚠️ HIGH PRIORITY
├── PushNotificationPermission.vue (660 lines) ⚠️ HIGH PRIORITY
└── MeetingMembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
└── MembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
Medium Components (200-500 lines): 12 components (30%)
├── GiftDetailsStep.vue (450 lines)

View File

@@ -15,7 +15,7 @@ Quick start:
```bash
npm install
npm run build:web:dev
npm run build:web:serve -- --test
```
To be able to take action on the platform: go to [the test page](http://localhost:8080/test) and click "Become User 0".
@@ -279,11 +279,13 @@ 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.
@@ -303,9 +305,21 @@ timesafari/
└── 📄 doc/README-BUILD-GUARD.md # Guard system documentation
```
## Known Issues
### Critical Vue Reactivity Bug
A critical Vue reactivity bug was discovered during ActiveDid migration testing where component properties fail to trigger template updates correctly.
**Impact**: The `newDirectOffersActivityNumber` element in HomeView.vue requires a watcher workaround to render correctly.
**Status**: Workaround implemented, investigation ongoing.
**Documentation**: See [Vue Reactivity Bug Report](doc/vue-reactivity-bug-report.md) for details.
## 🤝 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

View File

@@ -27,18 +27,12 @@ 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 65
versionName "1.3.8"
versionCode 48
versionName "1.1.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -107,20 +101,6 @@ 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"

View File

@@ -18,7 +18,6 @@ dependencies {
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
implementation project(':capawesome-capacitor-file-picker')
implementation project(':timesafari-daily-notification-plugin')
}

View File

@@ -1,12 +1,10 @@
<?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
@@ -29,59 +27,8 @@
<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"
@@ -98,14 +45,4 @@
<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>

View File

@@ -42,31 +42,6 @@
"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": {

View File

@@ -34,17 +34,5 @@
{
"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"
}
]

View File

@@ -1,10 +1,6 @@
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;
@@ -15,21 +11,9 @@ 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);
@@ -64,160 +48,9 @@ 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);
}
}
}

View File

@@ -1,27 +0,0 @@
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");
}
}

View File

@@ -1,73 +0,0 @@
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();
}
});
}
}

View File

@@ -1,84 +0,0 @@
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);
}
}

View File

@@ -1,12 +0,0 @@
<?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>

View File

@@ -22,9 +22,6 @@ 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) {

View File

@@ -28,6 +28,3 @@ 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')

View File

@@ -9,8 +9,7 @@
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# 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
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit

View File

@@ -1,5 +1,5 @@
ext {
minSdkVersion = 23
minSdkVersion = 22
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.8.0'

View File

@@ -44,31 +44,6 @@ 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: {

View File

@@ -1,122 +0,0 @@
# 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 289305). 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 13031305). On Android it only calls `scheduleDailyNotification()`.
So the app is not double-cancelling on Android and is passing the expected options.
### Plugin in apps 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` → plugins `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 youre 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 apps `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, youll 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 23 minutes from now.
- Edit and save **without changing the time**.
- Wait for the time; the notification should fire.
- In logcat, filter by the plugins 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 apps `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 whats 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.

View File

@@ -1,169 +0,0 @@
# 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 Whats 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 apps 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 apps 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 apps `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 apps 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 fetchers 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 apps 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_&lt;timestamp&gt;** (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 theres 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 dont 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 apps 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 apps 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 23 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!") | FetchWorkers `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`).

View File

@@ -1,82 +0,0 @@
# 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 doesnt 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 its 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 youre 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 dont see the notification, open **TimeSafari** once. (You dont 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 dont need to change anything)
## 4) If it still doesnt work
- Ensure youre 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 (youll 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**.

View File

@@ -1,259 +0,0 @@
# 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).

View File

@@ -1,85 +0,0 @@
# 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 **510 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. 50100 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 &lt; 12 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 12 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.

View File

@@ -1,515 +0,0 @@
# 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.

View File

@@ -1,106 +0,0 @@
# 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 apps 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 plugins `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 plugins 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 apps `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 days 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.

View File

@@ -1,251 +0,0 @@
# 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.

View File

@@ -1,312 +0,0 @@
# 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.

View File

@@ -1,348 +0,0 @@
# 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) 🚀

View File

@@ -1,193 +0,0 @@
# 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) 🚀

View File

@@ -1,237 +0,0 @@
# 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

View File

@@ -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: `doc/`, `*.md`, `*.txt`
- Documentation: `docs/`, `*.md`, `*.txt`
- Config files: `*.json`, `*.yml`, `*.yaml`
- IDE files: `.cursor/` directory

View File

@@ -1,139 +0,0 @@
# 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.

View File

@@ -1,283 +0,0 @@
# 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

View File

@@ -1,140 +0,0 @@
# 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)

View File

@@ -1,93 +0,0 @@
# 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

View File

@@ -1,507 +0,0 @@
# 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)

View File

@@ -1,412 +0,0 @@
# 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

View File

@@ -1,238 +0,0 @@
# 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

View File

@@ -1,378 +0,0 @@
# 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

View File

@@ -1,27 +0,0 @@
# Plugin: Android — Alarm set after edit doesnt 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 hasnt fired yet, so we dont cancel when the previous alarm already fired (e.g. user edited after first fire).
**Verification:**
- In the consuming app: set reminder 23 min from now, let it fire, then edit to 23 min from then and save. Capture logcat through the second scheduled time.
- If the receiver never logs at the second time, the OS didnt 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`.

View File

@@ -1,71 +0,0 @@
# 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-1compatible 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 apps dependency (e.g. `npm update @timesafari/daily-notification-plugin` or point at the fixed commit), then `npx cap sync android` and rebuild.

View File

@@ -1,114 +0,0 @@
# 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:** Users 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:
- NotifyReceivers alarm: `notification_id` = `daily_timesafari_reminder`, `is_static_reminder` = true → correct user message.
- DailyNotificationSchedulers 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 doesnt 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 doesnt 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. Dialogs `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 parents callback runs; it calls `cancelDailyNotification()` (on iOS) then `scheduleDailyNotification(...)` **once**. No double schedule from the app.
So the duplicate is entirely due to the plugins 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. 23 minutes ahead). Wait until the scheduled time. **Only one** notification should appear, with the users 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.

View File

@@ -1,74 +0,0 @@
# 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:** ~10571109 (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 plugins own guard in `scheduleDailyNotification()` is what opens Settings.
---
## Consumer app context
- **Permission flow:** The app requests `POST_NOTIFICATIONS` via the plugins `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 dont 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 ~10571109). 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 dont 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 apps dependency (e.g. `npm update @timesafari/daily-notification-plugin`), then `npx cap sync android` and rebuild.

View File

@@ -1,151 +0,0 @@
# 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 users 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 apps 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 users 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 doesnt 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 users 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 users 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 plugins boot recovery and alarm delivery are entirely on the plugin side.
- The apps `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 users 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 12 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).

View File

@@ -1,169 +0,0 @@
# 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**: Androids 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 wont 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 todays 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 plugins `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 plugins 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 plugins 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.

View File

@@ -1,133 +0,0 @@
# 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 users).
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 users 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 doesnt 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 days 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 apps 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 days 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 runs notification_id** (UUID or `notify_*`), not by the apps 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 doesnt 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 runs id is a rollover or time-based id, also look up the **canonical** reminder id (e.g. `daily_timesafari_reminder`) and prefer that entitys title/body if present, so rollover displays user text.
- **Rollover scheduling:** When scheduling the next days alarm (ROLLOVER_ON_FIRE), pass title/body (or a stable reminder id) so the next fires 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 12 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 apps 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 users 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 days 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).

View File

@@ -1,104 +0,0 @@
# 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** alarms 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. |

View File

@@ -1,108 +0,0 @@
# 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.

View File

@@ -1,148 +0,0 @@
# 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 apps 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 plugins 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 apps 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 apps 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 plugins 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.

View File

@@ -1,528 +0,0 @@
# 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`

View File

@@ -1,329 +0,0 @@
# 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)

View File

@@ -1,76 +0,0 @@
# 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.

View File

@@ -56,7 +56,6 @@
"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"
},
@@ -130,7 +129,6 @@
"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"
}
@@ -1071,7 +1069,6 @@
"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",
@@ -2877,6 +2874,16 @@
"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",

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 70;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -15,35 +15,8 @@
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>"; };
@@ -55,39 +28,10 @@
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;
@@ -97,13 +41,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D22ED456DE00824752 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -119,7 +56,6 @@
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
504EC3051FED79650016851F /* Products */,
BA325FFCDCE8D334E5C7AEBE /* Pods */,
4B546315E668C7A13939F417 /* Frameworks */,
@@ -130,7 +66,6 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -138,9 +73,6 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */,
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */,
C86585E52ED4577F00824752 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
@@ -176,40 +108,16 @@
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 */
@@ -217,7 +125,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 2610;
LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 1630;
TargetAttributes = {
504EC3031FED79650016851F = {
@@ -225,9 +133,6 @@
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
C86585D42ED456DE00824752 = {
CreatedOnToolsVersion = 26.1.1;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
@@ -244,7 +149,6 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
C86585D42ED456DE00824752 /* TimeSafariShareExtension */,
);
};
/* End PBXProject section */
@@ -263,13 +167,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D32ED456DE00824752 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -356,29 +253,12 @@
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;
@@ -522,9 +402,8 @@
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 = 65;
CURRENT_PROJECT_VERSION = 48;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -534,7 +413,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.1.3;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -550,9 +429,8 @@
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 = 65;
CURRENT_PROJECT_VERSION = 48;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -562,7 +440,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.1.3;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -572,80 +450,6 @@
};
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 */
@@ -667,15 +471,6 @@
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 */;

View File

@@ -1,77 +0,0 @@
<?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>

View File

@@ -1,10 +0,0 @@
<?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>

View File

@@ -1,64 +1,20 @@
import UIKit
import Capacitor
import CapacitorCommunitySqlite
import UserNotifications
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
class AppDelegate: UIResponder, UIApplicationDelegate {
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.
@@ -76,54 +32,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
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) {
@@ -133,8 +41,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
// Note: Share Extension opens app with timesafari:// (empty path), which is handled by JavaScript
// via the appUrlOpen listener in main.capacitor.ts
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
@@ -144,6 +50,5 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}

View File

@@ -58,19 +58,5 @@
</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>

View File

@@ -1,66 +0,0 @@
//
// 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
])
}
}

View File

@@ -1,107 +0,0 @@
//
// 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()
}
}

View File

@@ -20,7 +20,6 @@ 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

View File

@@ -86,8 +86,6 @@ PODS:
- SQLCipher/common (4.9.0)
- SQLCipher/standard (4.9.0):
- SQLCipher/common
- TimesafariDailyNotificationPlugin (2.0.0):
- Capacitor
- ZIPFoundation (0.9.19)
DEPENDENCIES:
@@ -102,7 +100,6 @@ 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:
@@ -144,8 +141,6 @@ 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
@@ -172,9 +167,8 @@ SPEC CHECKSUMS:
nanopb: 438bc412db1928dac798aa6fd75726007be04262
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
TimesafariDailyNotificationPlugin: 3c12e8c39fc27f689f56cf4e57230a8c28611fcc
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
PODFILE CHECKSUM: 6d92bfa46c6c2d31d19b8c0c38f56a8ae9fd222f
PODFILE CHECKSUM: 5fa870b031c7c4e0733e2f96deaf81866c75ff7d
COCOAPODS: 1.16.2

View File

@@ -1,21 +0,0 @@
<?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>

View File

@@ -1,207 +0,0 @@
//
// 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)
}
}

View File

@@ -1,10 +0,0 @@
<?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>

7844
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
{
"name": "timesafari",
"version": "1.3.8-beta",
"description": "Gift Economies Application",
"version": "1.1.4-beta",
"description": "Time Safari Application",
"author": {
"name": "Gift Economies Team"
"name": "Time Safari 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 && node scripts/restore-local-plugins.js",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && npx capacitor-assets generate",
"build:capacitor:sync": "npm run build:capacitor && npx cap sync",
"build:native": "vite build && npx cap sync && 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",
@@ -156,17 +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-brands-svg-icons": "^7.1.0",
"@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",
@@ -189,7 +189,6 @@
"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",
@@ -203,10 +202,9 @@
"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",
"pinia": "^2.1.7",
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.2.1",
"qr-code-generator-vue3": "^1.4.21",
"qrcode": "^1.5.4",
@@ -235,7 +233,6 @@
"@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",

View File

@@ -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: 3,
workers: 4,
/* 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)\.spec\.ts).+\.spec\.ts$/,
testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.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)\.spec\.ts).+\.spec\.ts$/,
testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
use: { ...devices['Desktop Firefox'] },
},

View File

@@ -1,60 +0,0 @@
# 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)

View File

@@ -75,146 +75,6 @@ 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..."
@@ -466,18 +326,6 @@ 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."
@@ -537,7 +385,6 @@ 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
@@ -625,9 +472,6 @@ fi
# Step 8: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
# 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

View File

@@ -215,9 +215,9 @@ clean_electron_artifacts() {
safe_execute "Cleaning Electron app directory" "rm -rf electron/app"
fi
# Clean TypeScript compilation artifacts (exclude hand-maintained electron-plugins.js)
# Clean TypeScript compilation artifacts
if [[ -d "electron/src" ]]; then
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' -delete 2>/dev/null || true"
safe_execute "Cleaning TypeScript artifacts" "find electron/src -name '*.js.map' -delete 2>/dev/null || true"
fi

View File

@@ -349,56 +349,10 @@ 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 (with Xcode 26 workaround if needed)"
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
log_info "Sync-only mode: syncing with Capacitor"
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
log_success "Sync completed successfully!"
exit 0
fi
@@ -450,109 +404,8 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi
# 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.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 6: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# Step 7: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7

View File

@@ -1,70 +0,0 @@
#!/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"

View File

@@ -116,7 +116,7 @@ echo "=============================="
# Analyze critical files identified in the assessment
critical_files=(
src/components/MeetingMembersList.vue"
src/components/MembersList.vue"
"src/views/ContactsView.vue"
src/views/OnboardMeetingSetupView.vue"
src/db/databaseUtil.ts"

View File

@@ -1,35 +0,0 @@
#!/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

View File

@@ -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: `doc/`, `*.md`, `*.txt`
- Documentation: `docs/`, `*.md`, `*.txt`
- Config files: `*.json`, `*.yml`, `*.yaml`
- IDE files: `.cursor/` directory

View File

@@ -52,7 +52,7 @@ SKIP_PATTERNS=(
"^test-.*/" # Test directories (must end with /)
"^\.git/" # Git directory
"^node_modules/" # Dependencies
"^doc/" # Documentation
"^docs/" # Documentation
"^\.cursor/" # Cursor IDE files
"\.md$" # Markdown files
"\.txt$" # Text files

View File

@@ -1,78 +0,0 @@
#!/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();

View File

@@ -93,7 +93,7 @@ echo "=========================="
# Critical files from our assessment
files=(
src/components/MeetingMembersList.vue"
src/components/MembersList.vue"
"src/views/ContactsView.vue"
src/views/OnboardMeetingSetupView.vue"
src/db/databaseUtil.ts"

View File

@@ -1,104 +0,0 @@
#!/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'"

View File

@@ -95,7 +95,7 @@ print_status "All type safety checks passed! 🎉"
print_status "Your code is ready for commit"
echo ""
echo "📚 Remember to follow the Type Safety Guidelines:"
echo " - doc/typescript-type-safety-guidelines.md"
echo " - docs/typescript-type-safety-guidelines.md"
echo " - Use proper error handling patterns"
echo " - Leverage existing type definitions"
echo " - Run 'npm run lint-fix' for automatic fixes"

View File

@@ -93,7 +93,7 @@ echo "=========================="
# Critical files from our assessment
files=(
src/components/MeetingMembersList.vue"
src/components/MembersList.vue"
"src/views/ContactsView.vue"
src/views/OnboardMeetingSetupView.vue"
src/db/databaseUtil.ts"

View File

@@ -321,7 +321,7 @@ suggest_next_steps() {
echo "3. Update documentation to reflect new patterns"
else
echo "1. Start with high-priority files (databaseUtil and logging)"
echo "2. Use the migration template: doc/migration-templates/component-migration.md"
echo "2. Use the migration template: docs/migration-templates/component-migration.md"
echo "3. Test each component after migration"
echo "4. Set up ESLint rules to prevent new legacy usage"
echo "5. Re-run this script to track progress"

View File

@@ -113,7 +113,7 @@ if [[ $total_issues -gt 0 ]]; then
echo ""
echo "🚨 ACTION REQUIRED:"
echo " $total_issues components need notification migration completion"
echo " Follow: doc/migration-templates/COMPLETE_MIGRATION_CHECKLIST.md"
echo " Follow: docs/migration-templates/COMPLETE_MIGRATION_CHECKLIST.md"
exit 1
else
echo ""

View File

@@ -21,7 +21,7 @@
</button>
<font-awesome
icon="circle-info"
class="text-2xl text-blue-500 ml-4"
class="text-2xl text-blue-500 ml-2"
@click="emitShowCopyInfo"
/>
</div>

View File

@@ -105,9 +105,11 @@ import { Component, Prop, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import * as R from "ramda";
import { NotificationIface } from "../constants/app";
import { AppString, NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import { logger } from "../utils/logger";
import { contactsToExportJson } from "../libs/util";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
@@ -220,15 +222,26 @@ export default class DataExportSection extends Vue {
return "list-disc list-outside ml-4";
}
/**
* Computed property for the export file name
* Includes today's date for easy identification of backup files
*/
private get fileName(): string {
const today = new Date();
const dateString = today.toISOString().split("T")[0]; // YYYY-MM-DD format
return `${AppString.APP_NAME_NO_SPACES}-backup-contacts-${dateString}.json`;
}
/**
* Exports the database to a JSON file
* Exports contacts and contact labels tables
* Uses the platform service to handle platform-specific export logic
* Shows success/error notifications to user
*
* @throws {Error} If export fails
*/
public async exportDatabase(): Promise<void> {
// Note that similar code is in ContactsView.vue exportContactData()
if (this.isExporting) {
return; // Prevent multiple simultaneous exports
}
@@ -236,11 +249,49 @@ export default class DataExportSection extends Vue {
try {
this.isExporting = true;
// Prepare export data using shared utility function
await this.$saveContactExport();
// Fetch contacts from database using mixin's cached method
const allContacts = await this.$contacts();
// Convert contacts to export format
const processedContacts: Contact[] = allContacts.map((contact) => {
// first remove the contactMethods field, mostly to cast to a clear type (that will end up with JSON objects)
const exContact: Contact = R.omit(["contactMethods"], contact);
// now add contactMethods as a true array of ContactMethod objects
// $contacts() returns normalized contacts where contactMethods is already an array,
// but we handle both array and string cases for robustness
if (contact.contactMethods) {
if (Array.isArray(contact.contactMethods)) {
// Already an array, use it directly
exContact.contactMethods = contact.contactMethods;
} else {
// Check if it's a string that needs parsing (shouldn't happen with normalized contacts, but handle for robustness)
const contactMethodsValue = contact.contactMethods as unknown;
if (
typeof contactMethodsValue === "string" &&
contactMethodsValue.trim() !== ""
) {
// String that needs parsing
exContact.contactMethods = JSON.parse(contactMethodsValue);
} else {
// Invalid data, use empty array
exContact.contactMethods = [];
}
}
} else {
// No contactMethods, use empty array
exContact.contactMethods = [];
}
return exContact;
});
const exportData = contactsToExportJson(processedContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
// Use platform service to handle export (no platform-specific logic here!)
await this.platformService.writeAndShareFile(this.fileName, jsonStr);
this.notify.success(
"Contact export completed successfully. Check downloads or the share dialog.",
"Contact export completed successfully. Check your downloads or share dialog.",
);
} catch (error) {
logger.error("Export Error:", error);

View File

@@ -57,8 +57,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
:selectable="youSelectable"
:conflicted="youConflicted"
:entity-data="youEntityData"
:conflict-context="conflictContext"
:notify="notify"
:conflict-context="conflictContext"
@entity-selected="handleEntitySelected"
/>
@@ -69,8 +69,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
:label="unnamedEntityName"
icon="circle-question"
:entity-data="unnamedEntityData"
:conflict-context="conflictContext"
:notify="notify"
:conflict-context="conflictContext"
@entity-selected="handleEntitySelected"
/>
</template>
@@ -97,8 +97,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:conflict-context="conflictContext"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
@@ -116,8 +116,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:conflict-context="conflictContext"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
@@ -131,73 +131,25 @@ projects, and special entities with selection. * * @author Matthew Raymer */
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:conflict-context="conflictContext"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<template v-else-if="entityType === 'projects'">
<!-- When showing projects without search: split into recently starred and rest -->
<template v-if="!searchTerm.trim()">
<!-- Recently Starred Section -->
<template v-if="recentStarredProjectsToShow.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Recently Starred
</li>
<ProjectCard
v-for="project in recentStarredProjectsToShow"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:conflict-context="conflictContext"
:notify="notify"
@project-selected="handleProjectSelected"
/>
</template>
<!-- Rest of Projects Section -->
<li
v-if="remainingProjects.length > 0"
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
All Projects
</li>
<ProjectCard
v-for="project in remainingProjects"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:conflict-context="conflictContext"
:notify="notify"
@project-selected="handleProjectSelected"
/>
</template>
<!-- When searching: show filtered results normally -->
<template v-else>
<ProjectCard
v-for="project in displayedEntities as PlanData[]"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:conflict-context="conflictContext"
:notify="notify"
@project-selected="handleProjectSelected"
/>
</template>
<ProjectCard
v-for="project in displayedEntities as PlanData[]"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
</template>
</ul>
</template>
@@ -223,7 +175,6 @@ import { TIMEOUTS } from "@/utils/notify";
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
const RECENT_STARRED_PROJECTS_COUNT = 10;
/**
* EntityGrid - Unified grid layout for displaying people or projects
@@ -251,6 +202,27 @@ export default class EntityGrid extends Vue {
@Prop({ required: true })
entityType!: "people" | "projects";
// Search state
searchTerm = "";
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
searchBeforeId: string | undefined = undefined;
isLoadingSearchMore = false;
// API server for project searches
apiServer = "";
// Internal project state (when entities prop not provided for projects)
allProjects: PlanData[] = [];
loadBeforeId: string | undefined = undefined;
isLoadingProjects = false;
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
/**
* Array of entities to display
*
@@ -302,30 +274,32 @@ export default class EntityGrid extends Vue {
@Prop({ default: "other party" })
conflictContext!: string;
// Search state
searchTerm = "";
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
searchBeforeId: string | undefined = undefined;
isLoadingSearchMore = false;
// API server for project searches
apiServer = "";
// Internal project state (when entities prop not provided for projects)
allProjects: PlanData[] = [];
loadBeforeId: string | undefined = undefined;
isLoadingProjects = false;
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
// Starred projects state (for showing recently starred projects)
starredPlanHandleIds: string[] = [];
recentStarredProjects: PlanData[] = [];
/**
* Function to determine which entities to display (allows parent control)
*
* This function prop allows parent components to customize which entities
* are displayed in the grid, enabling advanced filtering and sorting.
* Note: Infinite scroll is disabled when this prop is provided.
*
* @param entities - The full array of entities (Contact[] or PlanData[])
* @param entityType - The type of entities being displayed ("people" or "projects")
* @returns Filtered/sorted array of entities to display
*
* @example
* // Custom filtering: only show contacts with profile images
* :display-entities-function="(entities, type) =>
* entities.filter(e => e.profileImageUrl)"
*
* @example
* // Custom sorting: sort projects by name
* :display-entities-function="(entities, type) =>
* entities.sort((a, b) => a.name.localeCompare(b.name))"
*/
@Prop({ default: null })
displayEntitiesFunction?: (
entities: Contact[] | PlanData[],
entityType: "people" | "projects",
) => Contact[] | PlanData[];
/**
* CSS classes for the empty state message
@@ -371,6 +345,11 @@ export default class EntityGrid extends Vue {
return this.filteredEntities.slice(0, this.displayedCount);
}
// If custom function provided, use it (disables infinite scroll)
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entitiesToUse, this.entityType);
}
// Default: projects use infinite scroll
if (this.entityType === "projects") {
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
@@ -399,8 +378,7 @@ export default class EntityGrid extends Vue {
}
/**
* Get all contacts sorted alphabetically (when showing contacts and not searching)
* Includes contacts shown in "Recently Added" section as well
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
* Uses infinite scroll to control how many are displayed
*/
get alphabeticalContacts(): Contact[] {
@@ -411,48 +389,18 @@ export default class EntityGrid extends Vue {
) {
return [];
}
// Sort all contacts alphabetically (including recent ones)
// Skip the first few (recent contacts) and sort the rest alphabetically
// Create a copy to avoid mutating the original array
const sorted = [...(this.entities as Contact[])].sort(
(a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
},
);
// Apply infinite scroll: show based on displayedCount
return sorted.slice(0, this.displayedCount);
}
/**
* Get the 3 most recently starred projects (when showing projects and not searching)
* Returns the cached member field
*/
get recentStarredProjectsToShow(): PlanData[] {
if (this.entityType !== "projects" || this.searchTerm.trim()) {
return [];
}
return this.recentStarredProjects;
}
/**
* Get all projects (when showing projects and not searching)
* Includes projects shown in "Recently Starred" section as well
* Uses infinite scroll to control how many are displayed
*/
get remainingProjects(): PlanData[] {
if (this.entityType !== "projects" || this.searchTerm.trim()) {
return [];
}
const projects = this.entitiesToUse as PlanData[];
if (projects.length === 0) {
return [];
}
// Apply infinite scroll: show based on displayedCount
return projects.slice(0, this.displayedCount);
const remaining = this.entities as Contact[];
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Apply infinite scroll: show based on displayedCount (minus the recent contacts)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
}
/**
@@ -500,115 +448,6 @@ export default class EntityGrid extends Vue {
return UNNAMED_ENTITY_NAME;
}
/**
* Initialize infinite scroll on mount
*/
async mounted(): Promise<void> {
if (this.entityType === "projects") {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Load starred project IDs for showing recently starred projects
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
// Load projects on mount if entities prop not provided
this.isLoadingProjects = true;
if (!this.entities) {
await this.loadProjects();
}
await this.loadRecentStarredProjects();
this.isLoadingProjects = false;
}
// Validate entities prop for people
if (this.entityType === "people") {
if (!this.entities) {
logger.error(
"EntityGrid: entities prop or allContacts prop is required when entityType is 'people'",
);
}
}
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
async () => {
// Search mode: handle search pagination
if (this.searchTerm.trim()) {
if (this.entityType === "projects") {
// Projects: load more search results if available
if (
this.displayedCount >= this.filteredEntities.length &&
this.searchBeforeId &&
!this.isLoadingSearchMore
) {
this.isLoadingSearchMore = true;
try {
const searchLower = this.searchTerm.toLowerCase().trim();
await this.loadProjects(this.searchBeforeId, searchLower);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more search results:", error);
// Error already handled in loadProjects
} finally {
this.isLoadingSearchMore = false;
}
} else {
// Show more from already-loaded search results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Contacts: show more from already-filtered results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Non-search mode
if (this.entityType === "projects") {
const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// If using internal state and need to load more from server
if (
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
beforeId &&
!this.isLoadingProjects
) {
this.isLoadingProjects = true;
try {
await this.loadProjects(beforeId);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more projects:", error);
// Error already handled in loadProjects
} finally {
this.isLoadingProjects = false;
}
} else {
// Normal case: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
} else {
// People: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
}
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
/**
* Check if a person DID is conflicted
*/
@@ -616,13 +455,6 @@ export default class EntityGrid extends Vue {
return this.conflictChecker(did);
}
/**
* Check if a project handleId is conflicted
*/
isProjectConflicted(handleId: string): boolean {
return this.conflictChecker(handleId);
}
/**
* Handle person selection from PersonCard
*/
@@ -693,7 +525,7 @@ export default class EntityGrid extends Vue {
if (this.entityType === "projects") {
// Server-side search for projects (initial load, no beforeId)
const searchLower = this.searchTerm.toLowerCase().trim();
await this.loadProjects(undefined, searchLower);
await this.fetchProjects(undefined, searchLower);
} else {
// Client-side filtering for contacts (complete list)
await this.performContactSearch();
@@ -716,7 +548,10 @@ export default class EntityGrid extends Vue {
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
*/
async loadProjects(beforeId?: string, claimContents?: string): Promise<void> {
async fetchProjects(
beforeId?: string,
claimContents?: string,
): Promise<void> {
if (!this.apiServer) {
if (claimContents) {
this.filteredEntities = [];
@@ -860,57 +695,6 @@ export default class EntityGrid extends Vue {
}
}
/**
* Load the most recently starred projects
* The starredPlanHandleIds array order represents starred order (newest at the end)
*/
async loadRecentStarredProjects(): Promise<void> {
if (
this.entityType !== "projects" ||
this.searchTerm.trim() ||
this.starredPlanHandleIds.length === 0
) {
this.recentStarredProjects = [];
return;
}
// Get the last 3 starred IDs (most recently starred)
const recentStarredIds = this.starredPlanHandleIds.slice(
-RECENT_STARRED_PROJECTS_COUNT,
);
// Find projects matching those IDs, sorting with newest first
const projects = this.entitiesToUse as PlanData[];
const recentProjects = recentStarredIds
.map((id) => projects.find((p) => p.handleId === id))
.filter((p): p is PlanData => p !== undefined)
.reverse();
// If any projects are not found, fetch them from the API server
if (recentProjects.length < recentStarredIds.length) {
const missingIds = recentStarredIds.filter(
(id) => !recentProjects.some((p) => p.handleId === id),
);
const missingProjects = await this.fetchProjectsByIds(missingIds);
recentProjects.push(...missingProjects);
}
this.recentStarredProjects = recentProjects;
}
async fetchProjectsByIds(ids: string[]): Promise<PlanData[]> {
const idsString = encodeURIComponent(JSON.stringify(ids));
const url = `${this.apiServer}/api/v2/report/plans?planHandleIds=${idsString}`;
const response = await fetch(url, {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error("Failed to fetch projects");
}
const results = await response.json();
return results.data;
}
/**
* Client-side contact search
* Assumes entities prop contains complete contact list from local database
@@ -965,6 +749,11 @@ export default class EntityGrid extends Vue {
* Determine if more entities can be loaded
*/
canLoadMore(): boolean {
if (this.displayEntitiesFunction) {
// Custom function disables infinite scroll
return false;
}
if (this.searchTerm.trim()) {
// Search mode: check if more results available
if (this.entityType === "projects") {
@@ -1004,11 +793,132 @@ export default class EntityGrid extends Vue {
}
// People: check if more alphabetical contacts available
// All contacts are shown alphabetically (recent ones appear in both sections)
// Total available = recent + all alphabetical
if (!this.entities) {
return false;
}
return this.displayedCount < this.entities.length;
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
return this.displayedCount < totalAvailable;
}
/**
* Initialize infinite scroll on mount
*/
async mounted(): Promise<void> {
// Load apiServer for project searches/loads
if (this.entityType === "projects") {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Load projects on mount if entities prop not provided
if (!this.entities && this.apiServer) {
this.isLoadingProjects = true;
try {
await this.fetchProjects();
} catch (error) {
logger.error("Error loading projects on mount:", error);
} finally {
this.isLoadingProjects = false;
}
}
}
// Validate entities prop for people
if (this.entityType === "people" && !this.entities) {
logger.error(
"EntityGrid: entities prop is required when entityType is 'people'",
);
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Contacts data is required but not provided.",
},
TIMEOUTS.SHORT,
);
}
}
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
async () => {
// Search mode: handle search pagination
if (this.searchTerm.trim()) {
if (this.entityType === "projects") {
// Projects: load more search results if available
if (
this.displayedCount >= this.filteredEntities.length &&
this.searchBeforeId &&
!this.isLoadingSearchMore
) {
this.isLoadingSearchMore = true;
try {
const searchLower = this.searchTerm.toLowerCase().trim();
await this.fetchProjects(this.searchBeforeId, searchLower);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more search results:", error);
// Error already handled in fetchProjects
} finally {
this.isLoadingSearchMore = false;
}
} else {
// Show more from already-loaded search results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Contacts: show more from already-filtered results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Non-search mode
if (this.entityType === "projects") {
const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// If using internal state and need to load more from server
if (
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
beforeId &&
!this.isLoadingProjects
) {
this.isLoadingProjects = true;
try {
await this.fetchProjects(beforeId);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more projects:", error);
// Error already handled in fetchProjects
} finally {
this.isLoadingProjects = false;
}
} else {
// Normal case: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
} else {
// People: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
}
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
// Emit methods using @Emit decorator
@@ -1024,39 +934,6 @@ export default class EntityGrid extends Vue {
return data;
}
/**
* Watch for changes in entityType to load projects when switching to projects
*/
@Watch("entityType")
async onEntityTypeChange(newType: "people" | "projects"): Promise<void> {
// Reset displayed count and clear search when switching types
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchTerm = "";
this.filteredEntities = [];
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
// When switching to projects, load them if not provided via entities prop
if (newType === "projects" && !this.entities) {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
if (this.allProjects.length === 0) {
this.isLoadingProjects = true;
await this.loadProjects();
await this.loadRecentStarredProjects();
this.isLoadingProjects = false;
}
}
// Clear project state when switching away from projects
if (newType === "people") {
this.allProjects = [];
this.loadBeforeId = undefined;
}
}
/**
* Watch for changes in search term to reset displayed count and pagination
*/
@@ -1082,7 +959,7 @@ export default class EntityGrid extends Vue {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
// For projects: clear internal state if entities prop is provided
// For projects: if entities prop is provided, clear internal state
if (this.entityType === "projects" && this.entities) {
this.allProjects = [];
this.loadBeforeId = undefined;

View File

@@ -8,30 +8,21 @@ notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-1">
<label class="block font-bold mb-4">
{{ stepLabel }}
</label>
<!-- Toggle link for entity type selection -->
<div class="text-right mb-4">
<button
type="button"
class="text-sm text-blue-600 hover:text-blue-800 underline font-medium"
@click="handleToggleEntityType"
>
{{ toggleLinkText }}
</button>
</div>
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? undefined : allContacts"
:entities="shouldShowProjects ? projects || undefined : allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="conflictChecker"
:conflict-context="conflictContext"
:notify="notify"
:show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable"
:notify="notify"
:conflict-context="conflictContext"
@entity-selected="handleEntitySelected"
/>
@@ -45,7 +36,16 @@ import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
import { GiverReceiverInputInfo } from "@/libs/util";
/**
* Entity data interface for giver/receiver
*/
interface EntityData {
did?: string;
handleId?: string;
name?: string;
image?: string;
}
/**
* Entity selection event data structure
@@ -78,6 +78,26 @@ export default class EntitySelectionStep extends Vue {
@Prop({ required: true })
stepType!: "giver" | "recipient";
/** Type of giver entity: 'person' or 'project' */
@Prop({ required: true })
giverEntityType!: "person" | "project";
/** Type of recipient entity: 'person' or 'project' */
@Prop({ required: true })
recipientEntityType!: "person" | "project";
/** Whether to show projects instead of people */
@Prop({ default: false })
showProjects!: boolean;
/** Whether this is from a project view */
@Prop({ default: false })
isFromProjectView!: boolean;
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
@Prop({ required: false })
projects?: PlanData[];
/** Array of available contacts */
@Prop({ required: true })
allContacts!: Contact[];
@@ -94,22 +114,40 @@ export default class EntitySelectionStep extends Vue {
@Prop({ required: true })
conflictChecker!: (did: string) => boolean;
/** Project ID for context (giver) */
@Prop({ default: "" })
fromProjectId!: string;
/** Project ID for context (recipient) */
@Prop({ default: "" })
toProjectId!: string;
/** Current giver entity for context */
@Prop()
giver?: GiverReceiverInputInfo | null;
giver?: EntityData | null;
/** Current receiver entity for context */
@Prop()
receiver?: GiverReceiverInputInfo | null;
receiver?: EntityData | null;
/** Form field values to preserve when navigating to "Show All" */
@Prop({ default: "" })
description!: string;
@Prop({ default: "0" })
amountInput!: string;
@Prop({ default: "HUR" })
unitCode!: string;
/** Offer ID for context when fulfilling an offer */
@Prop({ default: "" })
offerId!: string;
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
// initializing based on a Prop doesn't work here; see "mounted()"
newGiverEntityType: "person" | "project" = "person";
newRecipientEntityType: "person" | "project" = "person";
/**
* CSS classes for the cancel button
*/
@@ -122,19 +160,15 @@ export default class EntitySelectionStep extends Vue {
*/
get stepLabel(): string {
if (this.stepType === "recipient") {
return "Choose who received the gift:";
} else if (this.stepType === "giver") {
if (this.shouldShowProjects) {
return "Choose recipient project";
return "Choose a project benefitted from:";
} else {
return "Choose recipient person";
}
} else {
// this.stepType === "giver"
if (this.shouldShowProjects) {
return "Choose giving project";
} else {
return "Choose giving person";
return "Choose a person received from:";
}
}
return "Choose entity:";
}
/**
@@ -152,14 +186,25 @@ export default class EntitySelectionStep extends Vue {
* Whether to show projects in the grid
*/
get shouldShowProjects(): boolean {
// When editing an entity, show the appropriate entity type for that entity
if (this.stepType === "giver") {
return this.newGiverEntityType === "project";
return this.giverEntityType === "project";
} else if (this.stepType === "recipient") {
return this.newRecipientEntityType === "project";
return this.recipientEntityType === "project";
}
return false;
}
/**
* Whether to show the "You" entity
*/
get shouldShowYouEntity(): boolean {
return (
this.stepType === "recipient" ||
(this.stepType === "giver" && this.isFromProjectView)
);
}
/**
* Whether the "You" entity is selectable
*/
@@ -167,24 +212,6 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid);
}
/**
* Text for the toggle link
*/
get toggleLinkText(): string {
if (this.shouldShowProjects) {
return "... or choose a person instead →";
} else {
return "... or choose a project instead →";
}
}
async mounted(): Promise<void> {
this.newGiverEntityType = this.giver?.handleId ? "project" : "person";
this.newRecipientEntityType = this.receiver?.handleId
? "project"
: "person";
}
/**
* Handle entity selection from EntityGrid
*/
@@ -195,19 +222,6 @@ export default class EntitySelectionStep extends Vue {
});
}
/**
* Handle toggle entity type button click
*/
handleToggleEntityType(): void {
if (this.stepType === "giver") {
this.newGiverEntityType =
this.newGiverEntityType === "person" ? "project" : "person";
} else if (this.stepType === "recipient") {
this.newRecipientEntityType =
this.newRecipientEntityType === "person" ? "project" : "person";
}
}
/**
* Handle cancel button click
*/

View File

@@ -1,6 +1,16 @@
/* EntitySummaryButton.vue - Displays selected entity with edit capability */
/** * EntitySummaryButton.vue - Displays selected entity with edit capability *
* Extracted from GiftedDialog.vue to handle entity summary display in the gift *
details step with edit functionality. * * Features: * - Shows entity avatar
(person or project) * - Displays entity name and role label * - Handles editable
vs locked states * - Function props for parent control over edit behavior * -
Supports both person and project entity types * - Template streamlined with
computed CSS properties * * @author Matthew Raymer */
<template>
<button :class="containerClasses" @click="handleClick">
<component
:is="editable ? 'button' : 'div'"
:class="containerClasses"
@click="handleClick"
>
<!-- Entity Icon/Avatar -->
<div>
<template v-if="entityType === 'project'">
@@ -37,11 +47,14 @@
</h3>
</div>
<!-- Edit Icon -->
<p class="ms-auto text-sm pe-1 text-blue-500">
<font-awesome icon="pen" title="Change" />
<!-- Edit/Lock Icon -->
<p class="ms-auto text-sm pe-1" :class="iconClasses">
<font-awesome
:icon="editable ? 'pen' : 'lock'"
:title="editable ? 'Change' : 'Can\'t be changed'"
/>
</p>
</button>
</component>
</template>
<script lang="ts">
@@ -50,15 +63,24 @@ import EntityIcon from "./EntityIcon.vue";
import ProjectIcon from "./ProjectIcon.vue";
import { Contact } from "../db/tables/contacts";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import { GiverReceiverInputInfo } from "@/libs/util";
/**
* EntitySummaryButton - Displays selected entity with edit capability
* Entity interface for both person and project entities
*/
interface EntityData {
did?: string;
handleId?: string;
name?: string;
image?: string;
}
/**
* EntitySummaryButton - Displays selected entity with optional edit capability
*
* Features:
* - Shows entity avatar (person or project)
* - Displays entity name and role label
* - Always editable - click to change entity
* - Handles editable vs locked states
* - Function props for parent control over edit behavior
* - Supports both person and project entity types
* - Template streamlined with computed CSS properties
@@ -72,26 +94,30 @@ import { GiverReceiverInputInfo } from "@/libs/util";
export default class EntitySummaryButton extends Vue {
/** Entity data to display */
@Prop({ required: true })
entity!: GiverReceiverInputInfo | Contact | null;
entity!: EntityData | Contact | null;
/** Type of entity: 'person' or 'project' */
@Prop({ required: true })
entityType!: "person" | "project";
/** Display label for the entity role */
@Prop({ required: true })
label!: string;
/** Whether the entity can be edited */
@Prop({ default: true })
editable!: boolean;
/**
* Function prop for handling edit requests
* Called when the button is clicked, allowing parent to control edit behavior
* Called when the button is clicked and editable, allowing parent to control edit behavior
*/
@Prop({ type: Function, default: () => {} })
onEditRequested!: (data: {
entityType: string;
entity: GiverReceiverInputInfo | Contact | null;
entity: EntityData | Contact | null;
}) => void | Promise<void>;
get entityType(): string {
return this.entity && "handleId" in this.entity ? "project" : "person";
}
/**
* CSS classes for the main container
*/
@@ -106,6 +132,13 @@ export default class EntitySummaryButton extends Vue {
return this.entity !== null && "profileImageUrl" in this.entity;
}
/**
* Computed CSS classes for the edit/lock icon
*/
get iconClasses(): string {
return this.editable ? "text-blue-500" : "text-slate-400";
}
/**
* Computed CSS classes for the entity name
*/
@@ -139,13 +172,16 @@ export default class EntitySummaryButton extends Vue {
}
/**
* Handle click event - call function prop to allow parent to control edit behavior
* Handle click event - only call function prop if editable
* Allows parent to control edit behavior and validation
*/
handleClick(): void {
this.onEditRequested({
entityType: this.entityType,
entity: this.entity,
});
if (this.editable) {
this.onEditRequested({
entityType: this.entityType,
entity: this.entity,
});
}
}
}
</script>
@@ -159,4 +195,8 @@ button {
button:hover {
background-color: #f1f5f9; /* hover:bg-slate-100 */
}
div {
cursor: default;
}
</style>

View File

@@ -14,14 +14,18 @@ control over updates and validation * * @author Matthew Raymer */
<!-- Giver Button -->
<EntitySummaryButton
:entity="giver"
:entity-type="giverEntityType"
:label="giverLabel"
:editable="canEditGiver"
:on-edit-requested="handleEditGiver"
/>
<!-- Recipient Button -->
<EntitySummaryButton
:entity="receiver"
:entity-type="recipientEntityType"
:label="recipientLabel"
:editable="canEditRecipient"
:on-edit-requested="handleEditRecipient"
/>
</div>
@@ -57,10 +61,21 @@ control over updates and validation * * @author Matthew Raymer */
</select>
</div>
<!-- Photo & More Options Link -->
<router-link :to="photoOptionsRoute" :class="photoOptionsClasses">
Photo &amp; more options&hellip;
</router-link>
<div class="grid grid-cols-2 gap-2 mb-4">
<button
class="text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg"
>
<font-awesome icon="camera" />
Add Photo
</button>
<!-- More Options Link -->
<router-link
:to="optionsRoute"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg"
>
More Options
</router-link>
</div>
<!-- Sign & Send Info -->
<p class="text-center text-sm mb-4">
@@ -102,7 +117,17 @@ import { Component, Prop, Vue, Watch, Emit } from "vue-facing-decorator";
import EntitySummaryButton from "./EntitySummaryButton.vue";
import AmountInput from "./AmountInput.vue";
import { RouteLocationRaw } from "vue-router";
import { GiverReceiverInputInfo } from "@/libs/util";
import { logger } from "@/utils/logger";
/**
* Entity data interface for giver/receiver
*/
interface EntityData {
did?: string;
handleId?: string;
name?: string;
image?: string;
}
/**
* GiftDetailsStep - Complete step 2 gift details form interface
@@ -128,11 +153,19 @@ import { GiverReceiverInputInfo } from "@/libs/util";
export default class GiftDetailsStep extends Vue {
/** Giver entity data */
@Prop({ required: true })
giver!: GiverReceiverInputInfo | null;
giver!: EntityData | null;
/** Receiver entity data */
@Prop({ required: true })
receiver!: GiverReceiverInputInfo | null;
receiver!: EntityData | null;
/** Type of giver entity: 'person' or 'project' */
@Prop({ required: true })
giverEntityType!: "person" | "project";
/** Type of recipient entity: 'person' or 'project' */
@Prop({ required: true })
recipientEntityType!: "person" | "project";
/** Gift description */
@Prop({ default: "" })
@@ -150,6 +183,10 @@ export default class GiftDetailsStep extends Vue {
@Prop({ default: "" })
prompt!: string;
/** Whether this is from a project view */
@Prop({ default: false })
isFromProjectView!: boolean;
/** Whether there's a conflict between giver and receiver */
@Prop({ default: false })
hasConflict!: boolean;
@@ -192,21 +229,6 @@ export default class GiftDetailsStep extends Vue {
private localAmount: number = 0;
private localUnitCode: string = "HUR";
get giverEntityType(): string {
return this.giver?.handleId ? "project" : "person";
}
get recipientEntityType(): string {
return this.receiver?.handleId ? "project" : "person";
}
/**
* CSS classes for the photo & more options link
*/
get photoOptionsClasses(): string {
return "block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-lg mb-4";
}
/**
* CSS classes for the cancel button
*/
@@ -259,6 +281,20 @@ export default class GiftDetailsStep extends Vue {
: "Given to:";
}
/**
* Whether the giver can be edited
*/
get canEditGiver(): boolean {
return !(this.isFromProjectView && this.giverEntityType === "project");
}
/**
* Whether the recipient can be edited
*/
get canEditRecipient(): boolean {
return this.recipientEntityType === "person";
}
/**
* Computed CSS classes for submit button
*/
@@ -272,18 +308,26 @@ export default class GiftDetailsStep extends Vue {
/**
* Computed route for photo & more options
*/
get photoOptionsRoute(): RouteLocationRaw {
get optionsRoute(): RouteLocationRaw {
return {
name: "gifted-details",
query: {
amountInput: this.localAmount.toString(),
description: this.localDescription,
giverDid: this.giver?.did,
giverDid:
this.giverEntityType === "person" ? this.giver?.did : undefined,
giverName: this.giver?.name,
offerId: this.offerId,
fulfillsProjectId: this.receiver?.handleId,
providerProjectId: this.giver?.handleId,
recipientDid: this.receiver?.did,
fulfillsProjectId:
this.recipientEntityType === "project" ? this.toProjectId : undefined,
providerProjectId:
this.giverEntityType === "project"
? this.giver?.handleId
: this.fromProjectId,
recipientDid:
this.recipientEntityType === "person"
? this.receiver?.did
: undefined,
recipientName: this.receiver?.name,
unitCode: this.localUnitCode,
},
@@ -303,6 +347,10 @@ export default class GiftDetailsStep extends Vue {
* Calls the onUpdateAmount function prop for parent control
*/
handleAmountChange(newAmount: number): void {
logger.debug("[GiftDetailsStep] handleAmountChange() called", {
oldAmount: this.localAmount,
newAmount,
});
this.localAmount = newAmount;
this.onUpdateAmount(newAmount);
}
@@ -321,7 +369,7 @@ export default class GiftDetailsStep extends Vue {
*/
handleEditGiver(_data: {
entityType: string;
entity: GiverReceiverInputInfo | null;
entity: EntityData | null;
}): void {
this.emitEditEntity({
entityType: "giver",
@@ -335,7 +383,7 @@ export default class GiftDetailsStep extends Vue {
*/
handleEditRecipient(_data: {
entityType: string;
entity: GiverReceiverInputInfo | null;
entity: EntityData | null;
}): void {
this.emitEditEntity({
entityType: "recipient",
@@ -375,8 +423,8 @@ export default class GiftDetailsStep extends Vue {
@Emit("edit-entity")
emitEditEntity(data: {
entityType: string;
currentEntity: GiverReceiverInputInfo | null;
}): { entityType: string; currentEntity: GiverReceiverInputInfo | null } {
currentEntity: EntityData | null;
}): { entityType: string; currentEntity: EntityData | null } {
return data;
}

View File

@@ -9,12 +9,24 @@
<EntitySelectionStep
v-show="firstStep"
:step-type="stepType"
:giver-entity-type="giverEntityType"
:recipient-entity-type="recipientEntityType"
:show-projects="
giverEntityType === 'project' || recipientEntityType === 'project'
"
:is-from-project-view="isFromProjectView"
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:conflict-checker="wouldCreateConflict"
:from-project-id="fromProjectId"
:to-project-id="toProjectId"
:giver="giver"
:receiver="receiver"
:description="description"
:amount-input="amountInput"
:unit-code="unitCode"
:offer-id="offerId"
:notify="$notify"
@entity-selected="handleEntitySelected"
@cancel="cancel"
@@ -31,6 +43,7 @@
:amount="parseFloat(amountInput) || 0"
:unit-code="unitCode"
:prompt="prompt"
:is-from-project-view="isFromProjectView"
:has-conflict="hasPersonConflict"
:offer-id="offerId"
:from-project-id="fromProjectId"
@@ -100,10 +113,11 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
@Prop({ default: "person" }) initialGiverEntityType = "person" as
@Prop() isFromProjectView = false;
@Prop({ default: "person" }) giverEntityType = "person" as
| "person"
| "project";
@Prop({ default: "person" }) initialRecipientEntityType = "person" as
@Prop({ default: "person" }) recipientEntityType = "person" as
| "person"
| "project";
@@ -128,12 +142,12 @@ export default class GiftedDialog extends Vue {
didInfo = didInfo;
get giverEntityType(): string {
return this.giver && "handleId" in this.giver ? "project" : "person";
}
get recipientEntityType(): string {
return this.receiver && "handleId" in this.receiver ? "project" : "person";
// Computed property to help debug template logic
get shouldShowProjects() {
const result =
(this.stepType === "giver" && this.giverEntityType === "project") ||
(this.stepType === "recipient" && this.recipientEntityType === "project");
return result;
}
// Computed property to check if current selection would create a conflict
@@ -158,23 +172,22 @@ export default class GiftedDialog extends Vue {
return false;
}
// Computed property to check if current selection would create a project conflict
get hasProjectConflict() {
// Only check for conflicts when both entities are projects
// Computed property to check if a contact would create a conflict when selected
wouldCreateConflict(contactDid: string) {
// Only check for conflicts when both entities are persons
if (
this.giverEntityType !== "project" ||
this.recipientEntityType !== "project"
this.giverEntityType !== "person" ||
this.recipientEntityType !== "person"
) {
return false;
}
// Check if giver and recipient are the same project
if (
this.giver?.handleId &&
this.receiver?.handleId &&
this.giver.handleId === this.receiver.handleId
) {
return true;
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
return this.receiver?.did === contactDid;
} else if (this.stepType === "recipient") {
// If selecting as recipient, check if it conflicts with current giver
return this.giver?.did === contactDid;
}
return false;
@@ -198,6 +211,8 @@ export default class GiftedDialog extends Vue {
this.amountInput = amountInput || "0";
this.unitCode = unitCode || "HUR";
this.callbackOnSuccess = callbackOnSuccess;
this.firstStep = !giver;
this.stepType = "giver";
try {
const settings = await this.$accountSettings();
@@ -208,14 +223,6 @@ export default class GiftedDialog extends Vue {
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
// Skip Step 1 if both giver and receiver are provided
const hasGiver = giver && (!!giver.did || !!giver.handleId);
const hasReceiver = receiver && (!!receiver.did || !!receiver.handleId);
this.firstStep = !hasGiver || !hasReceiver;
if (this.firstStep) {
this.stepType = giver ? "receiver" : "giver";
}
logger.debug("[GiftedDialog] Settings received:", {
activeDid: this.activeDid,
apiServer: this.apiServer,
@@ -264,43 +271,9 @@ export default class GiftedDialog extends Vue {
this.eraseValues();
}
// Computed property to check if a contact or project would create a conflict when selected
wouldCreateConflict(identifier: string) {
// Check for person conflicts when both entities are persons
if (
this.giverEntityType === "person" &&
this.recipientEntityType === "person"
) {
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
return this.receiver?.did === identifier;
} else if (this.stepType === "recipient") {
// If selecting as recipient, check if it conflicts with current giver
return this.giver?.did === identifier;
}
}
// Check for project conflicts when both entities are projects
if (
this.giverEntityType === "project" &&
this.recipientEntityType === "project"
) {
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
return this.receiver?.handleId === identifier;
} else if (this.stepType === "recipient") {
// If selecting as recipient, check if it conflicts with current giver
return this.giver?.handleId === identifier;
}
}
return false;
}
eraseValues() {
this.description = "";
this.giver = undefined;
this.receiver = undefined;
this.amountInput = "0";
this.prompt = "";
this.unitCode = "HUR";
@@ -342,15 +315,6 @@ export default class GiftedDialog extends Vue {
return;
}
// Check for project conflict
if (this.hasProjectConflict) {
this.safeNotify.error(
"You cannot select the same project as both giver and recipient.",
TIMEOUTS.STANDARD,
);
return;
}
this.close();
this.safeNotify.toast(
NOTIFY_GIFTED_DETAILS_RECORDING_GIVE.message,
@@ -396,34 +360,25 @@ export default class GiftedDialog extends Vue {
this.recipientEntityType === "person"
) {
// Project-to-person gift
fromDid = undefined;
toDid = recipientDid as string;
fulfillsProjectHandleId = undefined;
providerPlanHandleId = this.giver?.handleId;
fromDid = undefined; // No person giver
toDid = recipientDid as string; // Person recipient
fulfillsProjectHandleId = undefined; // No project recipient
providerPlanHandleId = this.giver?.handleId; // Project giver
} else if (
this.giverEntityType === "person" &&
this.recipientEntityType === "project"
) {
// Person-to-project gift
fromDid = giverDid as string;
toDid = undefined;
fulfillsProjectHandleId = this.receiver?.handleId;
providerPlanHandleId = undefined;
} else if (
this.giverEntityType === "person" &&
this.recipientEntityType === "person"
) {
fromDid = giverDid as string; // Person giver
toDid = undefined; // No person recipient
fulfillsProjectHandleId = this.toProjectId; // Project recipient
providerPlanHandleId = undefined; // No project giver
} else {
// Person-to-person gift
fromDid = giverDid as string;
toDid = recipientDid as string;
fulfillsProjectHandleId = undefined;
providerPlanHandleId = undefined;
} else {
// Project-to-project gift
fromDid = undefined;
toDid = undefined;
fulfillsProjectHandleId = this.receiver?.handleId;
providerPlanHandleId = this.giver?.handleId;
}
const result = await createAndSubmitGive(
@@ -494,16 +449,7 @@ export default class GiftedDialog extends Vue {
this.safeNotify.info(libsUtil.PRIVACY_MESSAGE, TIMEOUTS.MODAL);
}
goBackToStep1(step: string) {
this.stepType = step;
this.firstStep = true;
}
moveToStep2() {
this.firstStep = false;
}
selectGiverPerson(contact?: Contact) {
selectGiver(contact?: Contact) {
if (contact) {
this.giver = {
did: contact.did,
@@ -521,16 +467,33 @@ export default class GiftedDialog extends Vue {
this.firstStep = false;
}
selectGiverProject(project: PlanData) {
goBackToStep1(step: string) {
this.stepType = step;
this.firstStep = true;
}
moveToStep2() {
this.firstStep = false;
}
selectProject(project: PlanData) {
this.giver = {
did: project.handleId,
name: project.name,
image: project.image,
handleId: project.handleId,
};
// Only set receiver to "You" if no receiver has been selected yet
if (!this.receiver || !this.receiver.did) {
this.receiver = {
did: this.activeDid,
name: "You",
};
}
this.firstStep = false;
}
selectRecipientPerson(contact?: Contact) {
selectRecipient(contact?: Contact) {
if (contact) {
this.receiver = {
did: contact.did,
@@ -550,6 +513,7 @@ export default class GiftedDialog extends Vue {
selectRecipientProject(project: PlanData) {
this.receiver = {
did: project.handleId,
name: project.name,
image: project.image,
handleId: project.handleId,
@@ -557,6 +521,27 @@ export default class GiftedDialog extends Vue {
this.firstStep = false;
}
// Computed property for the query parameters
get giftedDetailsQuery() {
return {
amountInput: this.amountInput,
description: this.description,
giverDid: this.giverEntityType === "person" ? this.giver?.did : undefined,
giverName: this.giver?.name,
offerId: this.offerId,
fulfillsProjectId:
this.recipientEntityType === "project" ? this.toProjectId : undefined,
providerProjectId:
this.giverEntityType === "project"
? this.giver?.handleId
: this.fromProjectId,
recipientDid:
this.recipientEntityType === "person" ? this.receiver?.did : undefined,
recipientName: this.receiver?.name,
unitCode: this.unitCode,
};
}
// New event handlers for component integration
/**
@@ -573,14 +558,14 @@ export default class GiftedDialog extends Vue {
// Apply DID-based logic for person entities
const processedContact = this.processPersonEntity(contact);
if (entity.stepType === "giver") {
this.selectGiverPerson(processedContact);
this.selectGiver(processedContact);
} else {
this.selectRecipientPerson(processedContact);
this.selectRecipient(processedContact);
}
} else if (entity.type === "project") {
const project = entity.data as PlanData;
if (entity.stepType === "giver") {
this.selectGiverProject(project);
this.selectProject(project);
} else {
this.selectRecipientProject(project);
}
@@ -611,7 +596,6 @@ export default class GiftedDialog extends Vue {
entityType: string;
currentEntity: { did: string; name: string };
}) {
// Always allow editing - go back to Step 1 to select a new entity
this.goBackToStep1(data.entityType);
}

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