Compare commits
211 Commits
v1.0.11-p2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a5395082f6 | |||
|
|
b6f663121d | ||
|
|
5756178c23 | ||
|
|
fbb5a94071 | ||
|
|
9121b1e0f7 | ||
|
|
469167a55f | ||
|
|
a5c5a7e74e | ||
|
|
fc1cebd720 | ||
|
|
5f12b69d2a | ||
|
|
4dd1aea002 | ||
|
|
33010ad7cf | ||
|
|
ba1186c057 | ||
|
|
757263c073 | ||
|
|
539b011fa8 | ||
|
|
d3ade1f27a | ||
|
|
21ab05d63b | ||
|
|
87d24ca506 | ||
|
|
7b41ca9e0b | ||
|
|
7a1e58a4b6 | ||
| 4a1d476528 | |||
| 11561991bd | |||
|
|
ca6a75ded8 | ||
|
|
d8a0eaf413 | ||
|
|
b8d9b6247d | ||
|
|
6df1d4a7c6 | ||
|
|
daaf7aa62a | ||
| 1dc0052b39 | |||
|
|
6ad7ff5fe1 | ||
|
|
f58eeda8a7 | ||
|
|
36356e0aca | ||
|
|
6f4d946662 | ||
|
|
c38f235647 | ||
|
|
2714480070 | ||
|
|
e873a46bbd | ||
|
|
aa0eaa5389 | ||
|
|
c36781e440 | ||
|
|
cff7b659dc | ||
|
|
d3df4d9115 | ||
|
|
bc3bf484cc | ||
|
|
25f83cf1fa | ||
|
|
7188d32ae6 | ||
|
|
1157a0f1ef | ||
|
|
c2b1a60804 | ||
|
|
fa8028a698 | ||
|
|
9feaf60c84 | ||
|
|
aaeb71d31d | ||
|
|
531ce9f709 | ||
|
|
0b61d33f21 | ||
|
|
02a44a3e7b | ||
|
|
cb3cb5a78e | ||
|
|
a62f54b8a8 | ||
|
|
7702bd3b81 | ||
|
|
602eafc892 | ||
|
|
a77f08052f | ||
|
|
442b826401 | ||
|
|
0bc75372b5 | ||
|
|
57c7ddb7eb | ||
|
|
a3afefeda9 | ||
|
|
bf90f158ac | ||
|
|
5dbe0d1455 | ||
|
|
7f79c5990b | ||
|
|
bef88ad844 | ||
|
|
d0155f0b22 | ||
|
|
dd55c6b4e1 | ||
|
|
2915fe7438 | ||
|
|
5247ebeecb | ||
|
|
20b33f6e31 | ||
|
|
630fd3de81 | ||
|
|
aaac23111c | ||
|
|
d2a1041cc4 | ||
|
|
243cbd08f1 | ||
|
|
7e93cbd771 | ||
|
|
6d64f71988 | ||
|
|
65379aedd6 | ||
|
|
66c7eca33d | ||
|
|
d88978259d | ||
|
|
66cbe763fc | ||
|
|
766d56c661 | ||
|
|
f446362984 | ||
|
|
20f15ebcea | ||
|
|
b230a8e7b5 | ||
|
|
f97b3bec5b | ||
|
|
911aabf671 | ||
|
|
5ae63e6f6d | ||
|
|
edc4082f72 | ||
|
|
c8919480d9 | ||
|
|
2d353c877c | ||
|
|
2f0d733b10 | ||
|
|
a7d33e2d37 | ||
|
|
83ec604a4b | ||
|
|
8b116db095 | ||
|
|
76c05e3690 | ||
|
|
f19ff4c127 | ||
|
|
839e167c98 | ||
|
|
f40562b68a | ||
|
|
f1830e5f6f | ||
|
|
f38b06abed | ||
|
|
ea4bc88808 | ||
|
|
63e5b4535e | ||
|
|
d913f03e23 | ||
|
|
4c1281754e | ||
|
|
9655fa10f8 | ||
|
|
6ac7b35566 | ||
|
|
62559cd546 | ||
|
|
7b1f1200bc | ||
|
|
39eed856f5 | ||
|
|
9565191101 | ||
|
|
f83e799254 | ||
|
|
36e15633be | ||
|
|
dced4b49e1 | ||
|
|
a85f8b2f52 | ||
|
|
f6df9e13fb | ||
|
|
b53042d679 | ||
|
|
78cd72529d | ||
|
|
95bf0f03c9 | ||
|
|
ac39255672 | ||
|
|
973af9b688 | ||
|
|
11b86f1f2e | ||
|
|
7060c20508 | ||
|
|
154ffd1638 | ||
|
|
96d4ee26b6 | ||
|
|
481c8b0301 | ||
|
|
25ba0ef0f0 | ||
|
|
012829456a | ||
|
|
29fb30e4ec | ||
|
|
3584cddad6 | ||
|
|
e47bd430a1 | ||
|
|
f06ddf3765 | ||
|
|
6aceb567ba | ||
|
|
5c75592740 | ||
|
|
2d70c03cf4 | ||
|
|
cdbe51f46a | ||
|
|
b51a1e4f75 | ||
|
|
2f861522a7 | ||
|
|
7443abf05b | ||
|
|
f8dd1290fa | ||
|
|
0551948b7a | ||
|
|
0b3a68c95a | ||
|
|
d84b3aece2 | ||
|
|
db3442a560 | ||
|
|
38fa249d95 | ||
|
|
a42d0535ac | ||
|
|
36f2c095db | ||
|
|
a070ec9f0b | ||
|
|
c40bc8dab3 | ||
|
|
dafedadf6d | ||
|
|
cc3daaec23 | ||
|
|
1dca99ad17 | ||
|
|
4586e64245 | ||
|
|
4118afa30e | ||
|
|
ddcafe2a00 | ||
|
|
e604b7f46c | ||
|
|
d8b29954a2 | ||
|
|
9b73e873d9 | ||
|
|
ac7550c77d | ||
|
|
735de3b09f | ||
|
|
694c7ea59f | ||
|
|
87f12a0029 | ||
|
|
f97f5702d5 | ||
|
|
442c48c233 | ||
|
|
13eafc11d1 | ||
|
|
dfb99259d9 | ||
|
|
56a89e65b3 | ||
|
|
31214c816d | ||
|
|
1f512f3add | ||
|
|
65966b7cc7 | ||
|
|
74bb35048d | ||
|
|
67c077e0d0 | ||
|
|
ae958b7ff8 | ||
|
|
dbb2f64f62 | ||
|
|
484e427991 | ||
|
|
bad6452d81 | ||
|
|
b72d2e27e3 | ||
|
|
d3c692bb72 | ||
|
|
8509c65d68 | ||
|
|
58bf0fec3a | ||
|
|
db573476a2 | ||
|
|
371f9a7c6d | ||
|
|
daf1809165 | ||
|
|
65f4c77b49 | ||
|
|
26294bfefd | ||
|
|
1dcd96a67a | ||
|
|
4a457fa788 | ||
|
|
15726ceb8f | ||
|
|
c29957bf64 | ||
|
|
d596346ba2 | ||
|
|
bdd2a5d7ac | ||
|
|
3a0b9b5692 | ||
|
|
1a1a94c995 | ||
|
|
0b01032b5b | ||
|
|
e845876b40 | ||
|
|
ee8e51b05c | ||
|
|
3f03a8263c | ||
|
|
086ba90723 | ||
|
|
21dcc71eae | ||
|
|
b62b2eddcc | ||
|
|
bae7438f76 | ||
|
|
04cf801b09 | ||
|
|
6297281d2d | ||
|
|
aea2a7f39d | ||
|
|
1591d7ab89 | ||
|
|
9767f7a5da | ||
|
|
ff840ae44d | ||
|
|
692f66ffd0 | ||
|
|
2499454c97 | ||
|
|
f5f776e4d7 | ||
|
|
6f71180fd4 | ||
|
|
38188d590e | ||
|
|
6b5b886951 | ||
|
|
7725f19387 | ||
| 76b3fa8199 |
138
.github/workflows/ci.yml
vendored
Normal file
138
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, ios-2]
|
||||
pull_request:
|
||||
branches: [main, develop, ios-2]
|
||||
|
||||
jobs:
|
||||
# Node.js / TypeScript checks
|
||||
node-ts:
|
||||
name: Node.js / TypeScript
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint || true
|
||||
|
||||
- name: Type check
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run local CI
|
||||
run: ./ci/run.sh
|
||||
|
||||
- name: Package check
|
||||
run: npm pack --dry-run
|
||||
|
||||
# Android checks
|
||||
android:
|
||||
name: Android
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
android/.gradle
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Make gradlew executable
|
||||
run: chmod +x android/gradlew || true
|
||||
|
||||
- name: Run Android tests
|
||||
working-directory: android
|
||||
run: |
|
||||
if [ -f "./gradlew" ]; then
|
||||
chmod +x ./gradlew
|
||||
./gradlew test --no-daemon || echo "Android tests skipped (expected in standalone plugin context)"
|
||||
else
|
||||
echo "gradlew not found, skipping Android tests"
|
||||
fi
|
||||
|
||||
- name: Run Android lint
|
||||
working-directory: android
|
||||
run: |
|
||||
if [ -f "./gradlew" ]; then
|
||||
./gradlew lint --no-daemon || echo "Android lint skipped (expected in standalone plugin context)"
|
||||
else
|
||||
echo "gradlew not found, skipping Android lint"
|
||||
fi
|
||||
|
||||
# iOS checks (macOS only)
|
||||
ios:
|
||||
name: iOS
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
||||
- name: Install CocoaPods dependencies
|
||||
working-directory: ios
|
||||
run: |
|
||||
sudo gem install cocoapods
|
||||
pod install || echo "Pod install skipped (expected in standalone plugin context)"
|
||||
|
||||
- name: Build iOS
|
||||
working-directory: ios
|
||||
run: |
|
||||
if [ -d "DailyNotificationPlugin.xcworkspace" ] || [ -d "*.xcworkspace" ]; then
|
||||
xcodebuild -workspace DailyNotificationPlugin.xcworkspace \
|
||||
-scheme DailyNotificationPlugin \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 15' \
|
||||
clean build \
|
||||
|| echo "iOS build skipped (expected in standalone plugin context)"
|
||||
else
|
||||
echo "iOS workspace not found, skipping build"
|
||||
fi
|
||||
|
||||
- name: Run iOS tests
|
||||
working-directory: ios
|
||||
run: |
|
||||
if [ -d "DailyNotificationPlugin.xcworkspace" ] || [ -d "*.xcworkspace" ]; then
|
||||
xcodebuild test \
|
||||
-workspace DailyNotificationPlugin.xcworkspace \
|
||||
-scheme DailyNotificationPlugin \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 15' \
|
||||
|| echo "iOS tests skipped (expected in standalone plugin context)"
|
||||
else
|
||||
echo "iOS workspace not found, skipping tests"
|
||||
fi
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -9,6 +9,10 @@ dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Workspace package build outputs
|
||||
packages/*/dist/
|
||||
packages/*/build/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -68,3 +72,11 @@ workflow/
|
||||
screenshots/
|
||||
*.zip
|
||||
*.gz
|
||||
*.tar.gz
|
||||
docs.tar.gz
|
||||
|
||||
# Build reports and caches
|
||||
build/reports/
|
||||
.gradle/nb-cache/
|
||||
android/.gradle/
|
||||
runs/
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
||||
DB3AE51713EFB84E05BC35EBACB3258E9428C8277A536E2102ACFF8EAB42145B
|
||||
@@ -1239,10 +1239,10 @@ dependencies {
|
||||
-keep @androidx.room.Dao class *
|
||||
|
||||
# Plugin classes
|
||||
-keep class com.timesafari.dailynotification.** { *; }
|
||||
-keep class org.timesafari.dailynotification.** { *; }
|
||||
|
||||
# Capacitor plugin
|
||||
-keep class com.timesafari.dailynotification.DailyNotificationPlugin { *; }
|
||||
-keep class org.timesafari.dailynotification.DailyNotificationPlugin { *; }
|
||||
|
||||
# Encryption
|
||||
-keep class javax.crypto.** { *; }
|
||||
|
||||
268
BUILDING.md
268
BUILDING.md
@@ -44,9 +44,11 @@ npx cap run android
|
||||
## Prerequisites
|
||||
|
||||
### Required Software
|
||||
- **Android Studio** (latest stable version)
|
||||
- **Android Studio** (latest stable version) - for Android development
|
||||
- **Java 11+** (for Kotlin compilation)
|
||||
- **Android SDK** with API level 21+
|
||||
- **Xcode** (latest stable version) - for iOS development (macOS only)
|
||||
- **Xcode Command Line Tools** - required for iOS builds (includes `xcodebuild`, `sqlite3`, etc.)
|
||||
- **Node.js** 16+ (for TypeScript compilation)
|
||||
- **npm** or **yarn** (for dependency management)
|
||||
|
||||
@@ -54,11 +56,35 @@ npx cap run android
|
||||
- **Gradle Wrapper** (included in project)
|
||||
- **Kotlin** (configured in build.gradle)
|
||||
- **TypeScript** (for plugin interface)
|
||||
- **CocoaPods** - for iOS dependency management
|
||||
|
||||
### iOS-Specific Prerequisites
|
||||
|
||||
**Xcode Command Line Tools** are required for iOS builds. The build script will verify these are installed:
|
||||
|
||||
```bash
|
||||
# Install Xcode Command Line Tools (if not already installed)
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
# Check if Command Line Tools are configured
|
||||
xcode-select -p
|
||||
|
||||
# Verify xcodebuild is available
|
||||
xcodebuild -version
|
||||
|
||||
# Verify sqlite3 is available (part of Command Line Tools)
|
||||
sqlite3 --version
|
||||
```
|
||||
|
||||
**Note:** The build script automatically checks for Command Line Tools and will fail with clear error messages if they're missing.
|
||||
|
||||
### System Requirements
|
||||
- **RAM**: 4GB minimum, 8GB recommended
|
||||
- **Storage**: 2GB free space
|
||||
- **OS**: Windows 10+, macOS 10.14+, or Linux
|
||||
- **OS**: Windows 10+, macOS 10.14+, or Linux (iOS development requires macOS)
|
||||
|
||||
## Build Methods
|
||||
|
||||
@@ -297,6 +323,8 @@ android/build/reports/tests/test/index.html
|
||||
|
||||
### iOS Native Build Process
|
||||
|
||||
**Prerequisites:** Ensure Xcode Command Line Tools are installed (see [Prerequisites](#prerequisites) section). The build script will verify this automatically.
|
||||
|
||||
#### 1. Navigate to iOS Directory
|
||||
```bash
|
||||
cd ios
|
||||
@@ -307,6 +335,12 @@ cd ios
|
||||
pod install
|
||||
```
|
||||
|
||||
**Note:** If you encounter issues with `pod install`, ensure Xcode Command Line Tools are properly configured:
|
||||
```bash
|
||||
xcode-select --install # Install if missing
|
||||
xcode-select -p # Verify installation path
|
||||
```
|
||||
|
||||
#### 3. Build Commands
|
||||
```bash
|
||||
# Build using Xcode command line
|
||||
@@ -361,12 +395,16 @@ npm install
|
||||
# Build Vue 3 app
|
||||
npm run build
|
||||
|
||||
# Add Capacitor
|
||||
npm install @capacitor/android
|
||||
# Add Capacitor platforms
|
||||
npm install @capacitor/android @capacitor/ios
|
||||
|
||||
# Sync with Capacitor
|
||||
npx cap sync android
|
||||
|
||||
# For iOS: Use the npm script (handles Podfile fixes automatically)
|
||||
npm run cap:sync:ios
|
||||
# This runs: cap copy ios + fix Podfile + pod install
|
||||
|
||||
# Run on Android device/emulator
|
||||
npx cap run android
|
||||
|
||||
@@ -374,6 +412,149 @@ npx cap run android
|
||||
npx cap run ios
|
||||
```
|
||||
|
||||
**iOS Setup (Vue 3 Test App)**
|
||||
|
||||
The iOS setup requires additional steps to configure the plugin correctly:
|
||||
|
||||
**1. Install Dependencies**
|
||||
```bash
|
||||
cd test-apps/daily-notification-test
|
||||
npm install
|
||||
```
|
||||
|
||||
**2. Build Vue App**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
**3. Add iOS Platform (if not already added)**
|
||||
```bash
|
||||
npx cap add ios
|
||||
```
|
||||
|
||||
**4. Fix Podfile Configuration**
|
||||
|
||||
**Critical**: Capacitor's `npx cap sync ios` regenerates the Podfile with incorrect plugin references (`TimesafariDailyNotificationPlugin` instead of `DailyNotificationPlugin`).
|
||||
|
||||
**Solution**: Use the npm script `npm run cap:sync:ios` which:
|
||||
1. Copies assets without running pod install (`npx cap copy ios`)
|
||||
2. Automatically fixes the Podfile
|
||||
3. Then runs `pod install` with the corrected Podfile
|
||||
|
||||
```bash
|
||||
# Use the npm script (recommended)
|
||||
npm run cap:sync:ios
|
||||
|
||||
# Or manually fix after copy
|
||||
npx cap copy ios
|
||||
node scripts/fix-capacitor-plugins.js
|
||||
cd ios/App && pod install && cd ../..
|
||||
```
|
||||
|
||||
The fix script will:
|
||||
- Change `TimesafariDailyNotificationPlugin` → `DailyNotificationPlugin`
|
||||
- Fix the path from `'../../../..'` → `'../../node_modules/@timesafari/daily-notification-plugin/ios'`
|
||||
|
||||
**5. Install CocoaPods Dependencies**
|
||||
|
||||
After the Podfile is fixed, install the iOS dependencies:
|
||||
|
||||
```bash
|
||||
cd ios/App
|
||||
pod install
|
||||
cd ../..
|
||||
```
|
||||
|
||||
**Expected Podfile Configuration:**
|
||||
|
||||
The Podfile should reference the plugin like this:
|
||||
|
||||
```ruby
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'DailyNotificationPlugin', :path => '../../node_modules/@timesafari/daily-notification-plugin/ios'
|
||||
end
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- The pod name must be `DailyNotificationPlugin` (not `TimesafariDailyNotificationPlugin`)
|
||||
- The path must point to `../../node_modules/@timesafari/daily-notification-plugin/ios`
|
||||
- The plugin must be installed in `node_modules` via `npm install` (it's installed as a local file dependency)
|
||||
|
||||
**6. Sync and Build**
|
||||
|
||||
**Important**: `npx cap sync ios` tries to run `pod install` automatically, but it will fail because the Podfile has incorrect plugin references. Use the npm script instead:
|
||||
|
||||
```bash
|
||||
# Option 1: Use the npm script (recommended - handles everything)
|
||||
npm run cap:sync:ios
|
||||
|
||||
# This script:
|
||||
# 1. Copies web assets (npx cap copy ios)
|
||||
# 2. Fixes the Podfile (node scripts/fix-capacitor-plugins.js)
|
||||
# 3. Installs pods (cd ios/App && pod install)
|
||||
|
||||
# Option 2: Manual steps (if you need more control)
|
||||
npx cap copy ios # Copy assets without pod install
|
||||
node scripts/fix-capacitor-plugins.js # Fix Podfile
|
||||
cd ios/App && pod install && cd ../.. # Install pods
|
||||
|
||||
# Open in Xcode
|
||||
npx cap open ios
|
||||
```
|
||||
|
||||
**Why this approach?**
|
||||
- `npx cap sync ios` regenerates the Podfile with wrong references, then tries to run `pod install` which fails
|
||||
- `npx cap copy ios` only copies files, allowing us to fix the Podfile before `pod install`
|
||||
- The npm script automates the entire workflow correctly
|
||||
|
||||
**Troubleshooting iOS Setup:**
|
||||
|
||||
**Error: `[!] No podspec found for 'TimesafariDailyNotificationPlugin'`**
|
||||
|
||||
This means the Podfile has the wrong pod name or path. Solutions:
|
||||
|
||||
1. **Run the fix script:**
|
||||
```bash
|
||||
node scripts/fix-capacitor-plugins.js
|
||||
```
|
||||
|
||||
2. **Manually fix the Podfile:**
|
||||
- Open `ios/App/Podfile`
|
||||
- Change `TimesafariDailyNotificationPlugin` to `DailyNotificationPlugin`
|
||||
- Change path from `'../../../..'` to `'../../node_modules/@timesafari/daily-notification-plugin/ios'`
|
||||
|
||||
3. **Verify plugin is installed:**
|
||||
```bash
|
||||
ls -la node_modules/@timesafari/daily-notification-plugin/ios/DailyNotificationPlugin.podspec
|
||||
```
|
||||
|
||||
4. **Reinstall dependencies if needed:**
|
||||
```bash
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
**Error: `pod install` fails**
|
||||
|
||||
1. **Update CocoaPods:**
|
||||
```bash
|
||||
sudo gem install cocoapods
|
||||
```
|
||||
|
||||
2. **Clean CocoaPods cache:**
|
||||
```bash
|
||||
cd ios/App
|
||||
rm -rf Pods Podfile.lock
|
||||
pod install --repo-update
|
||||
```
|
||||
|
||||
3. **Verify Xcode Command Line Tools:**
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
**Test App Features:**
|
||||
|
||||
- Interactive plugin testing interface
|
||||
@@ -390,8 +571,13 @@ test-apps/daily-notification-test/
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ └── stores/ # Pinia state management
|
||||
├── android/ # Android Capacitor app
|
||||
├── ios/ # iOS Capacitor app
|
||||
│ └── App/
|
||||
│ ├── Podfile # CocoaPods dependencies
|
||||
│ └── App.xcworkspace # Xcode workspace
|
||||
├── docs/ # Test app documentation
|
||||
└── scripts/ # Test app build scripts
|
||||
│ └── fix-capacitor-plugins.js # Auto-fixes Podfile
|
||||
```
|
||||
|
||||
#### Android Test Apps
|
||||
@@ -467,7 +653,7 @@ public class MainActivity extends BridgeActivity {
|
||||
{
|
||||
"plugins": {
|
||||
"DailyNotification": {
|
||||
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -542,7 +728,7 @@ The Vue 3 test app uses a **project reference approach** for plugin integration:
|
||||
{
|
||||
"id": "DailyNotification",
|
||||
"name": "DailyNotification",
|
||||
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -630,6 +816,13 @@ The project includes several automated build scripts in the `scripts/` directory
|
||||
./scripts/build-native.sh --platform ios
|
||||
./scripts/build-native.sh --verbose
|
||||
|
||||
# Clean build (removes all build artifacts and caches)
|
||||
./scripts/clean-build.sh
|
||||
./scripts/clean-build.sh --all # Also cleans caches and reinstalls dependencies
|
||||
./scripts/clean-build.sh --clean-gradle-cache # Clean Gradle cache
|
||||
./scripts/clean-build.sh --clean-derived-data # Clean Xcode DerivedData
|
||||
./scripts/clean-build.sh --reinstall-node # Reinstall node_modules
|
||||
|
||||
# TimeSafari-specific builds
|
||||
node scripts/build-timesafari.js
|
||||
|
||||
@@ -706,7 +899,7 @@ npm install @timesafari/daily-notification-plugin
|
||||
npm install /path/to/daily-notification-plugin
|
||||
|
||||
# Install from git repository
|
||||
npm install git+https://github.com/timesafari/daily-notification-plugin.git
|
||||
npm install git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git
|
||||
```
|
||||
|
||||
#### 3. Integration in Host Applications
|
||||
@@ -796,6 +989,28 @@ adb logcat | grep DailyNotification
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Clean Build (First Step for Many Issues)
|
||||
|
||||
If you encounter persistent build issues, try a clean build first:
|
||||
|
||||
```bash
|
||||
# Clean all build artifacts (recommended first step)
|
||||
./scripts/clean-build.sh
|
||||
|
||||
# Clean everything including caches (for stubborn issues)
|
||||
./scripts/clean-build.sh --all
|
||||
|
||||
# Then rebuild
|
||||
./scripts/build-native.sh --platform all
|
||||
```
|
||||
|
||||
**When to use clean-build:**
|
||||
- Build errors that don't make sense
|
||||
- Dependency conflicts
|
||||
- Stale build artifacts
|
||||
- After switching branches
|
||||
- After updating dependencies
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Gradle Sync Failures
|
||||
@@ -867,6 +1082,39 @@ File → Project Structure → SDK Location
|
||||
# Solution: Check Kotlin version in build.gradle
|
||||
```
|
||||
|
||||
#### iOS Build Issues
|
||||
```bash
|
||||
# Problem: "Xcode Command Line Tools not configured"
|
||||
# Error: xcode-select -p fails or xcodebuild not found
|
||||
# Solution: Install Command Line Tools
|
||||
xcode-select --install
|
||||
|
||||
# Verify installation
|
||||
xcode-select -p
|
||||
xcodebuild -version
|
||||
sqlite3 --version
|
||||
|
||||
# Problem: "sqlite3 not found" or linker errors with SQLite
|
||||
# Solution: Ensure Command Line Tools are properly installed
|
||||
# The build script checks for this automatically, but if you see linker errors:
|
||||
xcode-select --install
|
||||
|
||||
# Problem: pkgx SQLite conflicts with iOS builds
|
||||
# Error: Linker errors about libsqlite3.dylib
|
||||
# Solution: The build script automatically handles this by unsetting problematic
|
||||
# environment variables. If issues persist:
|
||||
unset PKGX_DIR DYLD_LIBRARY_PATH LD_LIBRARY_PATH
|
||||
./scripts/build-native.sh --platform ios
|
||||
|
||||
# Problem: "pod install" fails
|
||||
# Solution: Ensure Command Line Tools are installed
|
||||
xcode-select --install
|
||||
# Then reinstall CocoaPods dependencies
|
||||
cd ios
|
||||
pod deintegrate
|
||||
pod install
|
||||
```
|
||||
|
||||
#### Capacitor Integration Issues
|
||||
```bash
|
||||
# Problem: Plugin not found in Capacitor app
|
||||
@@ -880,7 +1128,7 @@ npx cap sync android
|
||||
#### AAR Duplicate Class Issues
|
||||
```bash
|
||||
# Problem: Duplicate class errors when integrating plugin AAR
|
||||
# Error: "Duplicate class com.timesafari.dailynotification.BootReceiver found in modules"
|
||||
# Error: "Duplicate class org.timesafari.dailynotification.BootReceiver found in modules"
|
||||
# Root Cause: Plugin being included both as project reference and as AAR file
|
||||
|
||||
# Solution 1: Use Project Reference Approach (Recommended)
|
||||
@@ -967,7 +1215,7 @@ daily-notification-plugin/
|
||||
├── scripts/ # Build scripts and automation
|
||||
├── test-apps/ # Test applications
|
||||
│ └── daily-notification-test/ # Vue 3 test app
|
||||
├── docs/ # Documentation
|
||||
├── doc/ # Documentation
|
||||
├── examples/ # Usage examples
|
||||
├── tests/ # Test files
|
||||
├── package.json # Node.js dependencies
|
||||
@@ -1062,7 +1310,7 @@ scripts/
|
||||
|
||||
### Getting Help
|
||||
- Check the [troubleshooting section](#troubleshooting)
|
||||
- Review [GitHub issues](https://github.com/timesafari/daily-notification-plugin/issues)
|
||||
- Review [GitHub issues](https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin/issues)
|
||||
- Consult [Capacitor documentation](https://capacitorjs.com/docs)
|
||||
- Ask in [Capacitor community](https://github.com/ionic-team/capacitor/discussions)
|
||||
|
||||
|
||||
102
CHANGELOG.md
102
CHANGELOG.md
@@ -5,6 +5,108 @@ All notable changes to the Daily Notification Plugin will be documented in this
|
||||
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).
|
||||
|
||||
## [3.0.1] - 2026-04-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: Dual native prefetch with an empty `NotificationContent` list no longer maps to placeholder title/body or arms the chained notify alarm for that cycle. The cache stores `skipNotification`, `DualScheduleHelper` skips display for fresh payloads (and for stale cache when `relationship.fallbackBehavior` is `skip`), and `DualScheduleFetchRecovery` still schedules the next prefetch.
|
||||
|
||||
### Added
|
||||
|
||||
- **Android**: Unit tests (`DualScheduleHelperTest`) for dual empty-cache resolution and skip payload detection.
|
||||
|
||||
## [3.0.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **iOS**: `NativeNotificationContentFetcher` SPI, `FetchContext`, `NativeNotificationFetcherRegistry`, and `DailyNotificationPlugin.registerNativeFetcher(_:)` for host-provided fetch (parity with Android `setNativeFetcher`).
|
||||
- **iOS**: `updateStarredPlans` / `getStarredPlans` plugin methods; starred IDs stored under UserDefaults key `daily_notification_timesafari.starredPlanIds` (JSON array string).
|
||||
- **Android**: `DualScheduleNotifyScheduler` and `DUAL_NOTIFY_SCHEDULE_ID_KEY` to arm the dual user notification **after** prefetch completes.
|
||||
- **Docs**: `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` for consuming apps.
|
||||
|
||||
### Changed
|
||||
|
||||
- **iOS**: `configureNativeFetcher` requires a registered native fetcher (matches Android); calls `configure` on the fetcher; background fetch prefers registered fetcher with timeout, then legacy in-plugin HTTP when no fetcher + config exists.
|
||||
- **iOS**: Dual (`scheduleDualNotification`) uses **chained** scheduling: prefetch BG task only, then one-shot user notification after fetch (`armChainedDualNotificationAfterPrefetch`), with max slip before fallback copy.
|
||||
- **iOS**: `NotificationContent` is `public` for host fetcher implementations.
|
||||
- **Android**: Dual notify exact alarm is no longer scheduled in `ScheduleHelper.scheduleDualNotification`; it is scheduled when `FetchWorker` completes (`max(nextNotifyAt, now)`), with recovery enqueue unchanged.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **iOS**: `configureNativeFetcher` rejects if `registerNativeFetcher` was not called first.
|
||||
|
||||
## [2.1.5] - 2026-03-25
|
||||
|
||||
### Changed
|
||||
|
||||
- **Android**: Dual (`scheduleDualNotification`) content prefetch uses **WorkManager** with **`initialDelay`** to the next `contentFetch.schedule` occurrence (not an immediate fetch at setup). After each successful dual fetch, the next prefetch is re-enqueued from persisted dual config.
|
||||
- **Android**: Dual prefetch with no `contentFetch.url` invokes the registered **`NativeNotificationContentFetcher`** when present (same SPI as `DailyNotificationFetchWorker`); otherwise mock JSON is used for development.
|
||||
- **Android**: `content_cache` rows include **`cacheScope`** (`dual` | `daily` | `legacy`). Dual notify resolution reads only **`dual`**; daily reminder fetches write **`daily`**, avoiding cross-feature overwrites. Database version **4** with migration from v3.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Android**: `doc/platform/android/ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md` (implementation plan; see repo for details).
|
||||
|
||||
## [2.1.4] - 2026-03-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: `scheduleDualNotification` / `parseContentFetchConfig` no longer requires `timeout`, `retryAttempts`, and `retryDelay` in `contentFetch` (optional fields per TypeScript). Omitted values defer to `FetchWorker` defaults.
|
||||
- **Android**: `parseUserNotificationConfig` no longer uses strict `getBoolean` / `getString` for optional `userNotification` fields (`title`, `body`, `sound`, `vibration`, `priority`). Omitted keys no longer throw `JSONException`; native scheduling applies existing defaults (`NotifyReceiver` / `DualScheduleHelper`).
|
||||
|
||||
### Documentation
|
||||
|
||||
- **README**: Notes for omitted `contentFetch` and optional `userNotification` fields on Android.
|
||||
|
||||
## [1.1.6] - 2026-02-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: Alarm set after edit/reschedule now fires. Removed `existingPendingIntent.cancel()` in the "cancel existing alarm before rescheduling" path so the PendingIntent passed to `setAlarmClock` is not cancelled (only `alarmManager.cancel()` is used), fixing no-fire on some devices.
|
||||
|
||||
## [1.1.5] - 2026-02-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: Rollover work using a `daily_rollover_*` schedule id no longer overwrites the app's schedule row in the DB. `NotifyReceiver` post-schedule update skips the "first enabled notify" fallback when `stableScheduleId` starts with `daily_rollover_`, so the app's reminder (e.g. `daily_timesafari_reminder`) keeps the correct `nextRunAt` after a notification fires.
|
||||
|
||||
### Added
|
||||
|
||||
- **Docs**: `doc/platform/android/CONSUMING_APP_ANDROID_NOTES.md` — notes for consuming apps on debouncing double `scheduleDailyNotification` calls and debugging alarms that are scheduled but do not fire (logcat with `DailyNotificationReceiver`).
|
||||
|
||||
## [1.1.4] - 2026-02-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: Re-setting a daily notification (edit/save same time) no longer cancels the alarm and then skips re-scheduling. DB idempotence in `NotifyReceiver.scheduleExactNotification()` now runs only when `!skipPendingIntentIdempotence`, so the app reset flow can re-register the alarm.
|
||||
- **Android**: Static reminder title/body no longer revert to fallback after the first fire. `DailyNotificationWorker.scheduleNextNotification()` now preserves `is_static_reminder` and stable `scheduleId` on rollover so the next occurrence keeps custom text.
|
||||
|
||||
### Added
|
||||
|
||||
- **Android**: `cancelDailyReminder(call)` in `DailyNotificationPlugin.kt` for parity with iOS. Accepts `reminderId` (or `id`, `reminder_id`, `scheduleId`), cancels the AlarmManager alarm for that id, and performs best-effort DB cleanup (`setEnabled` false, `updateRunTimes` null).
|
||||
|
||||
## [1.1.3] - 2026-02-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android (Java)**: Java call sites for `NotifyReceiver.scheduleExactNotification()` now pass the 8th parameter `skipPendingIntentIdempotence`, fixing "actual and formal argument lists differ in length" when building consuming apps. Updated `DailyNotificationReceiver.java` and `DailyNotificationWorker.java`.
|
||||
|
||||
## [1.1.2] - 2026-02-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: Second daily notification not firing after reschedule. After cancel-then-schedule, the idempotence check could still see the cancelled PendingIntent in Android's cache and skip the new schedule. The cancel-then-schedule path now skips PendingIntent-based idempotence so the new alarm is always registered.
|
||||
|
||||
## [1.1.1] - 2026-02-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: Target alarm broadcast to app package so receiver is triggered correctly
|
||||
|
||||
### Documentation
|
||||
|
||||
- EMULATOR_GUIDE: prerequisites, API 35, Apple Silicon; build.sh Android-only sync
|
||||
|
||||
## [2.1.0] - 2025-01-02
|
||||
|
||||
### Added
|
||||
|
||||
179
README.md
179
README.md
@@ -1,13 +1,35 @@
|
||||
# Daily Notification Plugin
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Version**: 2.2.0
|
||||
**Created**: 2025-09-22 09:22:32 UTC
|
||||
**Last Updated**: 2025-10-08 06:02:45 UTC
|
||||
|
||||
## Overview
|
||||
|
||||
The Daily Notification Plugin is a comprehensive Capacitor plugin that provides enterprise-grade daily notification functionality across Android, iOS, and Electron platforms. It features dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability.
|
||||
The Daily Notification Plugin is a Capacitor plugin that provides daily notification functionality following local-first principles across Android, iOS, and Electron platforms.
|
||||
|
||||
This is to support apps that allow users to own their data. This approach is in contrast to standard server-managed notifications; they have the advantage of trustworthy delivery, but they have the following downsides:
|
||||
|
||||
* Users must store their search terms and notification preferences on the server.
|
||||
|
||||
* Users are not able to move their notifications elsewhere, and cannot take control of their notifications with their own apps.
|
||||
|
||||
* Peer-to-peer network scenarios are not supported.
|
||||
|
||||
There are two types of notifications supported:
|
||||
|
||||
* Periodic static reminder messages
|
||||
|
||||
* Periodic API requests, then notifying the user if there is new content
|
||||
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
**New to the plugin?** Start here:
|
||||
|
||||
1. **[Installation & Setup](./doc/GETTING_STARTED.md)** — Installation, platform setup, and basic usage
|
||||
2. **[Quick Start Guide](./doc/examples/QUICK_START.md)** — Minimal working example
|
||||
3. **[Common Patterns](./doc/examples/COMMON_PATTERNS.md)** — Common integration patterns
|
||||
4. **[Troubleshooting](./doc/TROUBLESHOOTING.md)** — Common issues and solutions
|
||||
|
||||
For complete documentation, see the [Documentation Index](./doc/00-INDEX.md).
|
||||
|
||||
### 🎯 **Native-First Architecture**
|
||||
|
||||
@@ -16,7 +38,7 @@ The plugin has been optimized for **native-first deployment** with the following
|
||||
**Platform Support:**
|
||||
- ✅ **Android**: WorkManager + AlarmManager + SQLite
|
||||
- ✅ **iOS**: BGTaskScheduler + UNUserNotificationCenter + Core Data
|
||||
- ✅ **Electron**: Desktop notifications + SQLite/LocalStorage
|
||||
- ⏳ **Electron**: Desktop notifications + SQLite/LocalStorage (someday)
|
||||
- ❌ **Web (PWA)**: Removed for native-first focus
|
||||
|
||||
**Key Benefits:**
|
||||
@@ -27,6 +49,11 @@ The plugin has been optimized for **native-first deployment** with the following
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### **Overview**
|
||||
|
||||
Stand-alone tests are found in the test-apps directory.
|
||||
- The daily-notification-test (that includes Vue) has worked but is not tested extensively.
|
||||
|
||||
### ✅ **Phase 2 Complete - Production Ready**
|
||||
|
||||
| Component | Status | Implementation |
|
||||
@@ -40,7 +67,26 @@ The plugin has been optimized for **native-first deployment** with the following
|
||||
|
||||
**All platforms are fully implemented with complete feature parity and enterprise-grade functionality.**
|
||||
|
||||
### 🧪 **Testing & Quality**
|
||||
## Behavioral Contracts
|
||||
|
||||
### Guaranteed Behaviors
|
||||
|
||||
The plugin guarantees the following behaviors:
|
||||
|
||||
- **Idempotency**: Operations with the same idempotency key are safe to retry
|
||||
- **TTL Semantics**: Content with expired TTL is not delivered
|
||||
- **Schedule Persistence**: Schedules persist across app restarts
|
||||
- **Recovery**: Missed notifications are recovered on app launch (best-effort)
|
||||
|
||||
### Best-Effort Behaviors
|
||||
|
||||
The following behaviors are best-effort and may vary by platform:
|
||||
|
||||
- **Delivery in Doze Mode**: Android Doze mode may delay notifications
|
||||
- **Background Fetch Timing**: Exact timing depends on OS scheduling
|
||||
- **Battery Optimization**: May be affected by device battery optimization settings
|
||||
|
||||
### **Testing & Quality**
|
||||
|
||||
- **Test Coverage**: 58 tests across 4 test suites ✅
|
||||
- **Build Status**: TypeScript compilation and Rollup bundling ✅
|
||||
@@ -49,7 +95,7 @@ The plugin has been optimized for **native-first deployment** with the following
|
||||
|
||||
## Features
|
||||
|
||||
### 🚀 **Core Features**
|
||||
### **Core Features**
|
||||
|
||||
- **Dual Scheduling**: Separate content fetch and user notification scheduling
|
||||
- **TTL-at-Fire Logic**: Content validity checking at notification time
|
||||
@@ -58,25 +104,19 @@ The plugin has been optimized for **native-first deployment** with the following
|
||||
- **Static Daily Reminders**: Simple daily notifications without network content
|
||||
- **Cross-Platform**: Android, iOS, and Electron implementations
|
||||
|
||||
### 📱 **Platform Support**
|
||||
|
||||
- **Android**: WorkManager + AlarmManager + SQLite (Room)
|
||||
- **iOS**: BGTaskScheduler + UNUserNotificationCenter + Core Data
|
||||
- **Web**: ❌ Removed (native-first architecture)
|
||||
|
||||
### 🔧 **Enterprise Features**
|
||||
### **Enterprise Features**
|
||||
|
||||
- **Observability**: Structured logging with event codes
|
||||
- **Health Monitoring**: Comprehensive status and performance metrics
|
||||
- **Error Handling**: Exponential backoff and retry logic
|
||||
- **Security**: Encrypted storage and secure callback handling
|
||||
- **Database Access**: Full TypeScript interfaces for plugin database access
|
||||
- See [`docs/DATABASE_INTERFACES.md`](docs/DATABASE_INTERFACES.md) for complete API reference
|
||||
- See [docs/00-INDEX.md](docs/00-INDEX.md) for complete documentation index
|
||||
- See [`doc/architecture/DATABASE_INTERFACES.md`](doc/architecture/DATABASE_INTERFACES.md) for complete API reference
|
||||
- See [doc/00-INDEX.md](doc/00-INDEX.md) for complete documentation index
|
||||
- Plugin owns its SQLite database - access via Capacitor interfaces
|
||||
- Supports schedules, content cache, callbacks, history, and configuration
|
||||
|
||||
### ⏰ **Static Daily Reminders**
|
||||
### **Static Daily Reminders**
|
||||
|
||||
- **No Network Required**: Completely offline reminder notifications
|
||||
- **Simple Scheduling**: Easy daily reminder setup with HH:mm time format
|
||||
@@ -94,18 +134,14 @@ npm install @timesafari/daily-notification-plugin
|
||||
Or install from Git repository:
|
||||
|
||||
```bash
|
||||
npm install git+https://github.com/timesafari/daily-notification-plugin.git
|
||||
npm install git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git
|
||||
```
|
||||
|
||||
The plugin follows the standard Capacitor Android structure - no additional path configuration needed!
|
||||
|
||||
## Documentation
|
||||
|
||||
**📚 Complete Documentation Index**: See [docs/00-INDEX.md](./docs/00-INDEX.md) for organized access to all documentation.
|
||||
|
||||
## Quick Integration
|
||||
|
||||
**New to the plugin?** Start with the [Quick Integration Guide](./docs/integration/QUICK_START.md) for step-by-step setup instructions.
|
||||
**New to the plugin?** Start with the [Quick Integration Guide](./doc/integration/QUICK_START.md) for step-by-step setup instructions.
|
||||
|
||||
The quick guide covers:
|
||||
- Installation and setup
|
||||
@@ -114,7 +150,7 @@ The quick guide covers:
|
||||
- Basic usage examples
|
||||
- Troubleshooting common issues
|
||||
|
||||
**For AI Agents**: See [AI Integration Guide](./docs/ai/AI_INTEGRATION_GUIDE.md) for explicit, machine-readable instructions with verification steps, error handling, and decision trees.
|
||||
**For AI Agents**: See [AI Integration Guide](./doc/ai/AI_INTEGRATION_GUIDE.md) for explicit, machine-readable instructions with verification steps, error handling, and decision trees.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -280,6 +316,12 @@ await DailyNotification.scheduleDualNotification({
|
||||
});
|
||||
```
|
||||
|
||||
If `contentFetch` omits `timeout`, `retryAttempts`, or `retryDelay`, Android applies defaults when scheduling fetch work (currently 30000 ms, 3 attempts, 1000 ms between attempts; see `FetchWorker`).
|
||||
|
||||
If `userNotification` omits optional fields (`title`, `body`, `sound`, `vibration`, `priority`), Android parses them as omitted; scheduling uses the same defaults as `NotifyReceiver` / `DualScheduleHelper` (e.g. sound and vibration default to on, priority to `normal` where applicable).
|
||||
|
||||
**Android (dual prefetch timing & cache):** Prefetch work is scheduled with a delay to the next `contentFetch.schedule` instant (best-effort under Doze/OEM). Fetched content is stored in a **scoped** cache row (`dual`) so it is not overwritten by the daily reminder fetch (`daily`). With no `contentFetch.url`, the host app’s **`NativeNotificationContentFetcher`** is used when registered.
|
||||
|
||||
### Callback Methods
|
||||
|
||||
#### `registerCallback(name, config)`
|
||||
@@ -366,7 +408,21 @@ console.log(`Test alarm scheduled for ${result.secondsFromNow} seconds`);
|
||||
console.log(`Will fire at: ${new Date(result.triggerAtMillis).toLocaleString()}`);
|
||||
```
|
||||
|
||||
## Capacitor Compatibility Matrix
|
||||
### Quick Smoke Test
|
||||
|
||||
For immediate validation of plugin functionality:
|
||||
|
||||
- **Android**: [Manual Smoke Test - Android](./doc/testing/MANUAL_SMOKE_TEST.md#android-platform-testing)
|
||||
- **iOS**: [Manual Smoke Test - iOS](./doc/testing/MANUAL_SMOKE_TEST.md#ios-platform-testing)
|
||||
- **Electron**: [Manual Smoke Test - Electron](./doc/testing/MANUAL_SMOKE_TEST.md#electron-platform-testing)
|
||||
|
||||
### Manual Smoke Test Documentation
|
||||
|
||||
Complete testing procedures: [doc/testing/MANUAL_SMOKE_TEST.md](./doc/testing/MANUAL_SMOKE_TEST.md)
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
### Capacitor Versions
|
||||
|
||||
| Plugin Version | Capacitor Version | Status | Notes |
|
||||
|----------------|-------------------|--------|-------|
|
||||
@@ -374,25 +430,14 @@ console.log(`Will fire at: ${new Date(result.triggerAtMillis).toLocaleString()}`
|
||||
| 1.0.0+ | 6.0.0 - 6.2.0 | ✅ **Supported** | Full feature support |
|
||||
| 1.0.0+ | 5.7.8 | ⚠️ **Legacy** | Deprecated, upgrade recommended |
|
||||
|
||||
### Quick Smoke Test
|
||||
### Platform Requirements
|
||||
|
||||
For immediate validation of plugin functionality:
|
||||
### Android Requirements
|
||||
|
||||
- **Android**: [Manual Smoke Test - Android](./docs/testing/MANUAL_SMOKE_TEST.md#android-platform-testing)
|
||||
- **iOS**: [Manual Smoke Test - iOS](./docs/testing/MANUAL_SMOKE_TEST.md#ios-platform-testing)
|
||||
- **Electron**: [Manual Smoke Test - Electron](./docs/testing/MANUAL_SMOKE_TEST.md#electron-platform-testing)
|
||||
|
||||
### Manual Smoke Test Documentation
|
||||
|
||||
Complete testing procedures: [docs/testing/MANUAL_SMOKE_TEST.md](./docs/testing/MANUAL_SMOKE_TEST.md)
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
### Android
|
||||
|
||||
- **Minimum SDK**: API 21 (Android 5.0)
|
||||
- **Target SDK**: API 34 (Android 14)
|
||||
- **Permissions**: `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, `USE_EXACT_ALARM`
|
||||
- **Minimum SDK**: 23 (Android 6.0)
|
||||
- **Target SDK**: 35 (Android 15)
|
||||
- **Exact Alarm Permission**: Required for Android 12+ (SCHEDULE_EXACT_ALARM)
|
||||
- **Notification Permission**: Required for Android 13+ (POST_NOTIFICATIONS)
|
||||
- **Dependencies**: Room 2.6.1+, WorkManager 2.9.0+
|
||||
|
||||
### iOS
|
||||
@@ -404,6 +449,8 @@ Complete testing procedures: [docs/testing/MANUAL_SMOKE_TEST.md](./docs/testing/
|
||||
|
||||
### Electron
|
||||
|
||||
### Electron Requirements
|
||||
|
||||
- **Minimum Version**: Electron 20+
|
||||
- **Desktop Notifications**: Native desktop notification APIs
|
||||
- **Storage**: SQLite or LocalStorage fallback
|
||||
@@ -514,18 +561,17 @@ await DailyNotification.updateDailyReminder('morning_checkin', {
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- NotifyReceiver for AlarmManager-based notifications -->
|
||||
<!-- REQUIRED: Without this, alarms fire but notifications won't display -->
|
||||
<receiver android:name="com.timesafari.dailynotification.NotifyReceiver"
|
||||
<receiver android:name="org.timesafari.dailynotification.NotifyReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
</receiver>
|
||||
|
||||
<receiver android:name="com.timesafari.dailynotification.BootReceiver"
|
||||
<receiver android:name="org.timesafari.dailynotification.BootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
@@ -560,8 +606,8 @@ dependencies {
|
||||
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.timesafari.dailynotification.content-fetch</string>
|
||||
<string>com.timesafari.dailynotification.notification-delivery</string>
|
||||
<string>org.timesafari.dailynotification.content-fetch</string>
|
||||
<string>org.timesafari.dailynotification.notification-delivery</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
@@ -750,7 +796,7 @@ console.log('Callbacks:', callbacks);
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/timesafari/daily-notification-plugin.git
|
||||
git clone https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git
|
||||
cd daily-notification-plugin
|
||||
npm install
|
||||
npm run build
|
||||
@@ -773,29 +819,25 @@ npm test
|
||||
5. Ensure all tests pass
|
||||
6. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Support
|
||||
|
||||
### Documentation
|
||||
|
||||
**📚 [Complete Documentation Index](./docs/00-INDEX.md)** - Central hub for all project documentation
|
||||
**[Complete Documentation Index](./doc/00-INDEX.md)** - Central hub for all project documentation
|
||||
|
||||
**Key Documentation:**
|
||||
- **Integration**: [Integration Guide](./docs/integration/INTEGRATION_GUIDE.md) - Complete integration instructions
|
||||
- **Integration**: [Integration Guide](./doc/integration/INTEGRATION_GUIDE.md) - Complete integration instructions
|
||||
- **Platform Guides**:
|
||||
- [iOS Platform Docs](./docs/platform/ios/) - iOS implementation, migration, and troubleshooting
|
||||
- [Android Platform Docs](./docs/platform/android/) - Android implementation and directives
|
||||
- **Testing**: [Testing Documentation](./docs/testing/) - Comprehensive testing guides and procedures
|
||||
- **Alarms**: [Alarm System Docs](./docs/alarms/) - Alarm system documentation
|
||||
- **Database Interfaces**: [`docs/DATABASE_INTERFACES.md`](docs/DATABASE_INTERFACES.md) - Complete guide to accessing plugin database from TypeScript/webview
|
||||
- **Database Implementation**: [`docs/DATABASE_INTERFACES_IMPLEMENTATION.md`](docs/DATABASE_INTERFACES_IMPLEMENTATION.md) - Implementation summary and status
|
||||
- **Database Consolidation Plan**: [`docs/platform/android/DATABASE_CONSOLIDATION_PLAN.md`](docs/platform/android/DATABASE_CONSOLIDATION_PLAN.md) - Database schema consolidation roadmap
|
||||
- [iOS Platform Docs](./doc/platform/ios/) - iOS implementation, migration, and troubleshooting
|
||||
- [Android Platform Docs](./doc/platform/android/) - Android implementation and directives
|
||||
- **Testing**: [Testing Documentation](./doc/testing/) - Comprehensive testing guides and procedures
|
||||
- **Alarms**: [Alarm System Docs](./doc/alarms/) - Alarm system documentation
|
||||
- **Database Interfaces**: [`doc/architecture/DATABASE_INTERFACES.md`](doc/architecture/DATABASE_INTERFACES.md) - Complete guide to accessing plugin database from TypeScript/webview
|
||||
- **Database Implementation**: [`doc/DATABASE_INTERFACES_IMPLEMENTATION.md`](doc/DATABASE_INTERFACES_IMPLEMENTATION.md) - Implementation summary and status
|
||||
- **Database Consolidation Plan**: [`doc/platform/android/DATABASE_CONSOLIDATION_PLAN.md`](doc/platform/android/DATABASE_CONSOLIDATION_PLAN.md) - Database schema consolidation roadmap
|
||||
- **Building Guide**: [BUILDING.md](BUILDING.md) - Comprehensive build instructions and troubleshooting
|
||||
- **Design & Research**: [Design Documentation](./docs/design/) - Design research and implementation guides
|
||||
- **Archive**: [Legacy Documentation](./docs/archive/2025-legacy-doc/) - Historical documentation preserved for reference
|
||||
- **Design & Research**: [Design Documentation](./doc/design/) - Design research and implementation guides
|
||||
- **Archive**: [Legacy Documentation](./doc/archive/2025-legacy-doc/) - Historical documentation preserved for reference
|
||||
|
||||
### Community
|
||||
|
||||
@@ -808,10 +850,3 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
||||
- **Custom Implementations**: Tailored solutions for enterprise needs
|
||||
- **Integration Support**: Help with complex integrations
|
||||
- **Performance Optimization**: Custom performance tuning
|
||||
|
||||
---
|
||||
|
||||
**Version**: 2.0.0
|
||||
**Last Updated**: 2025-09-22 09:22:32 UTC
|
||||
**Status**: Phase 2 Complete - Production Ready
|
||||
**Author**: Matthew Raymer
|
||||
|
||||
19
android/app/capacitor.build.gradle
Normal file
19
android/app/capacitor.build.gradle
Normal file
@@ -0,0 +1,19 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
16
android/app/src/main/assets/capacitor.config.json
Normal file
16
android/app/src/main/assets/capacitor.config.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"appId": "org.timesafari.dailynotification",
|
||||
"appName": "DailyNotification Test App",
|
||||
"webDir": "www",
|
||||
"server": {
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"plugins": {
|
||||
"DailyNotification": {
|
||||
"fetchUrl": "https://api.example.com/daily-content",
|
||||
"scheduleTime": "09:00",
|
||||
"enableNotifications": true,
|
||||
"debugMode": true
|
||||
}
|
||||
}
|
||||
}
|
||||
1
android/app/src/main/assets/capacitor.plugins.json
Normal file
1
android/app/src/main/assets/capacitor.plugins.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
475
android/app/src/main/assets/public/index.html
Normal file
475
android/app/src/main/assets/public/index.html
Normal file
@@ -0,0 +1,475 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>DailyNotification Plugin Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: white;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
.button {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 15px 30px;
|
||||
margin: 10px;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.status {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="statusCard" class="status" style="margin-bottom: 20px; font-size: 14px;">
|
||||
<strong>Plugin Status</strong><br>
|
||||
<div style="margin-top: 10px;">
|
||||
⚙️ Plugin Settings: <span id="configStatus">Not configured</span><br>
|
||||
🔌 Native Fetcher: <span id="fetcherStatus">Not configured</span><br>
|
||||
🔔 Notifications: <span id="notificationPermStatus">Checking...</span><br>
|
||||
⏰ Exact Alarms: <span id="exactAlarmPermStatus">Checking...</span><br>
|
||||
📢 Channel: <span id="channelStatus">Checking...</span><br>
|
||||
<div id="pluginStatusContent" style="margin-top: 8px;">
|
||||
Loading plugin status...
|
||||
</div>
|
||||
<div id="notificationReceivedIndicator" style="margin-top: 8px; padding: 8px; background: rgba(0, 255, 0, 0.2); border-radius: 5px; display: none;">
|
||||
<strong>🔔 Notification Received!</strong><br>
|
||||
<span id="notificationReceivedTime"></span><br>
|
||||
<small>Check the top of your screen for the notification banner</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="button" onclick="configurePlugin()">Configure Plugin</button>
|
||||
<button class="button" onclick="requestPermissions()">Request Permissions</button>
|
||||
<button class="button" onclick="testNotification()">Test Notification</button>
|
||||
<button class="button" onclick="checkComprehensiveStatus()">Full System Status</button>
|
||||
|
||||
<div id="status" class="status">
|
||||
Ready to test...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
console.log('Script loading...');
|
||||
console.log('JavaScript is working!');
|
||||
|
||||
// Use real DailyNotification plugin
|
||||
console.log('Using real DailyNotification plugin...');
|
||||
window.DailyNotification = window.Capacitor.Plugins.DailyNotification;
|
||||
|
||||
// Define functions immediately and attach to window
|
||||
|
||||
function configurePlugin() {
|
||||
console.log('configurePlugin called');
|
||||
const status = document.getElementById('status');
|
||||
const configStatus = document.getElementById('configStatus');
|
||||
const fetcherStatus = document.getElementById('fetcherStatus');
|
||||
|
||||
status.innerHTML = 'Configuring plugin...';
|
||||
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
|
||||
|
||||
// Update top status to show configuring
|
||||
configStatus.innerHTML = '⏳ Configuring...';
|
||||
fetcherStatus.innerHTML = '⏳ Waiting...';
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
status.innerHTML = 'DailyNotification plugin not available';
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
configStatus.innerHTML = '❌ Plugin unavailable';
|
||||
fetcherStatus.innerHTML = '❌ Plugin unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure plugin settings
|
||||
window.DailyNotification.configure({
|
||||
storage: 'tiered',
|
||||
ttlSeconds: 86400,
|
||||
prefetchLeadMinutes: 60,
|
||||
maxNotificationsPerDay: 3,
|
||||
retentionDays: 7
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Plugin settings configured, now configuring native fetcher...');
|
||||
// Update top status
|
||||
configStatus.innerHTML = '✅ Configured';
|
||||
|
||||
// Configure native fetcher with demo credentials
|
||||
// Note: DemoNativeFetcher uses hardcoded mock data, so this is optional
|
||||
// but demonstrates the API. In production, this would be real credentials.
|
||||
return window.DailyNotification.configureNativeFetcher({
|
||||
apiBaseUrl: 'http://10.0.2.2:3000', // Android emulator → host localhost
|
||||
activeDid: 'did:ethr:0xDEMO1234567890', // Demo DID
|
||||
jwtSecret: 'demo-jwt-secret-for-development-testing'
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
// Update top status
|
||||
fetcherStatus.innerHTML = '✅ Configured';
|
||||
|
||||
// Update bottom status for user feedback
|
||||
status.innerHTML = 'Plugin configured successfully!';
|
||||
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
|
||||
})
|
||||
.catch(error => {
|
||||
// Update top status with error
|
||||
if (configStatus.innerHTML.includes('Configuring')) {
|
||||
configStatus.innerHTML = '❌ Failed';
|
||||
}
|
||||
if (fetcherStatus.innerHTML.includes('Waiting') || fetcherStatus.innerHTML.includes('Configuring')) {
|
||||
fetcherStatus.innerHTML = '❌ Failed';
|
||||
}
|
||||
|
||||
status.innerHTML = `Configuration failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
});
|
||||
} catch (error) {
|
||||
configStatus.innerHTML = '❌ Error';
|
||||
fetcherStatus.innerHTML = '❌ Error';
|
||||
status.innerHTML = `Configuration failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
}
|
||||
|
||||
function loadPluginStatus() {
|
||||
console.log('loadPluginStatus called');
|
||||
const pluginStatusContent = document.getElementById('pluginStatusContent');
|
||||
const statusCard = document.getElementById('statusCard');
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
pluginStatusContent.innerHTML = '❌ DailyNotification plugin not available';
|
||||
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
return;
|
||||
}
|
||||
window.DailyNotification.getNotificationStatus()
|
||||
.then(result => {
|
||||
const nextTime = result.nextNotificationTime ? new Date(result.nextNotificationTime).toLocaleString() : 'None scheduled';
|
||||
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
|
||||
const statusIcon = hasSchedules ? '✅' : '⏸️';
|
||||
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
|
||||
📅 Next Notification: ${nextTime}<br>
|
||||
⏳ Pending: ${result.pending || 0}`;
|
||||
statusCard.style.background = hasSchedules ?
|
||||
'rgba(0, 255, 0, 0.15)' : 'rgba(255, 255, 255, 0.1)'; // Green if active, light gray if none
|
||||
})
|
||||
.catch(error => {
|
||||
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
|
||||
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
});
|
||||
} catch (error) {
|
||||
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
|
||||
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
}
|
||||
|
||||
// Notification test functions
|
||||
function testNotification() {
|
||||
console.log('testNotification called');
|
||||
|
||||
// Quick sanity check - test plugin availability
|
||||
if (window.Capacitor && window.Capacitor.isPluginAvailable) {
|
||||
const isAvailable = window.Capacitor.isPluginAvailable('DailyNotification');
|
||||
console.log('is plugin available?', isAvailable);
|
||||
}
|
||||
|
||||
const status = document.getElementById('status');
|
||||
status.innerHTML = 'Testing plugin connection...';
|
||||
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
status.innerHTML = 'DailyNotification plugin not available';
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
return;
|
||||
}
|
||||
|
||||
// Test the notification method directly
|
||||
console.log('Testing notification scheduling...');
|
||||
const now = new Date();
|
||||
const notificationTime = new Date(now.getTime() + 240000); // 4 minutes from now
|
||||
const prefetchTime = new Date(now.getTime() + 120000); // 2 minutes from now (2 min before notification)
|
||||
const notificationTimeString = notificationTime.getHours().toString().padStart(2, '0') + ':' +
|
||||
notificationTime.getMinutes().toString().padStart(2, '0');
|
||||
const prefetchTimeString = prefetchTime.getHours().toString().padStart(2, '0') + ':' +
|
||||
prefetchTime.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
window.DailyNotification.scheduleDailyNotification({
|
||||
time: notificationTimeString,
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test notification from the DailyNotification plugin!',
|
||||
sound: true,
|
||||
priority: 'high'
|
||||
})
|
||||
.then(() => {
|
||||
const prefetchTimeReadable = prefetchTime.toLocaleTimeString();
|
||||
const notificationTimeReadable = notificationTime.toLocaleTimeString();
|
||||
status.innerHTML = '✅ Notification scheduled!<br>' +
|
||||
'📥 Prefetch: ' + prefetchTimeReadable + ' (' + prefetchTimeString + ')<br>' +
|
||||
'🔔 Notification: ' + notificationTimeReadable + ' (' + notificationTimeString + ')<br><br>' +
|
||||
'<small>💡 When the notification fires, look for a banner at the <strong>top of your screen</strong>.</small>';
|
||||
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
|
||||
// Refresh plugin status display
|
||||
setTimeout(() => loadPluginStatus(), 500);
|
||||
})
|
||||
.catch(error => {
|
||||
// Check if this is an exact alarm permission error
|
||||
if (error.code === 'EXACT_ALARM_PERMISSION_REQUIRED' ||
|
||||
error.message.includes('Exact alarm permission') ||
|
||||
error.message.includes('Alarms & reminders')) {
|
||||
status.innerHTML = '⚠️ Exact Alarm Permission Required<br><br>' +
|
||||
'Settings opened automatically.<br>' +
|
||||
'Please enable "Allow exact alarms" and return to try again.';
|
||||
status.style.background = 'rgba(255, 165, 0, 0.3)'; // Orange background
|
||||
} else {
|
||||
status.innerHTML = `❌ Notification failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
status.innerHTML = `Notification test failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Permission management functions
|
||||
function requestPermissions() {
|
||||
console.log('requestPermissions called');
|
||||
const status = document.getElementById('status');
|
||||
status.innerHTML = 'Requesting permissions...';
|
||||
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
status.innerHTML = 'DailyNotification plugin not available';
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
return;
|
||||
}
|
||||
|
||||
window.DailyNotification.requestNotificationPermissions()
|
||||
.then(() => {
|
||||
status.innerHTML = 'Permission request completed! Check your device settings if needed.';
|
||||
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
|
||||
|
||||
// Refresh permission and channel status display after request
|
||||
setTimeout(() => {
|
||||
loadPermissionStatus();
|
||||
loadChannelStatus();
|
||||
}, 1000);
|
||||
})
|
||||
.catch(error => {
|
||||
status.innerHTML = `Permission request failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
});
|
||||
} catch (error) {
|
||||
status.innerHTML = `Permission request failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
}
|
||||
|
||||
function loadChannelStatus() {
|
||||
const channelStatus = document.getElementById('channelStatus');
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
channelStatus.innerHTML = '❌ Plugin unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
window.DailyNotification.isChannelEnabled()
|
||||
.then(result => {
|
||||
const importanceText = getImportanceText(result.importance);
|
||||
if (result.enabled) {
|
||||
channelStatus.innerHTML = `✅ Enabled (${importanceText})`;
|
||||
} else {
|
||||
channelStatus.innerHTML = `❌ Disabled (${importanceText})`;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
channelStatus.innerHTML = '⚠️ Error';
|
||||
});
|
||||
} catch (error) {
|
||||
channelStatus.innerHTML = '⚠️ Error';
|
||||
}
|
||||
}
|
||||
|
||||
function checkComprehensiveStatus() {
|
||||
const status = document.getElementById('status');
|
||||
status.innerHTML = 'Checking comprehensive status...';
|
||||
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
status.innerHTML = 'DailyNotification plugin not available';
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
return;
|
||||
}
|
||||
|
||||
window.DailyNotification.checkStatus()
|
||||
.then(result => {
|
||||
const canSchedule = result.canScheduleNow;
|
||||
const issues = [];
|
||||
|
||||
if (!result.postNotificationsGranted) {
|
||||
issues.push('POST_NOTIFICATIONS permission');
|
||||
}
|
||||
if (!result.channelEnabled) {
|
||||
issues.push('notification channel disabled');
|
||||
}
|
||||
if (!result.exactAlarmsGranted) {
|
||||
issues.push('exact alarm permission');
|
||||
}
|
||||
|
||||
let statusText = `Status: ${canSchedule ? 'Ready to schedule' : 'Issues found'}`;
|
||||
if (issues.length > 0) {
|
||||
statusText += `\nIssues: ${issues.join(', ')}`;
|
||||
}
|
||||
|
||||
statusText += `\nChannel: ${getImportanceText(result.channelImportance)}`;
|
||||
statusText += `\nChannel ID: ${result.channelId}`;
|
||||
|
||||
status.innerHTML = statusText;
|
||||
status.style.background = canSchedule ? 'rgba(0, 255, 0, 0.3)' : 'rgba(255, 0, 0, 0.3)';
|
||||
})
|
||||
.catch(error => {
|
||||
status.innerHTML = `Status check failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
});
|
||||
} catch (error) {
|
||||
status.innerHTML = `Status check failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
}
|
||||
|
||||
function getImportanceText(importance) {
|
||||
switch (importance) {
|
||||
case 0: return 'None (blocked)';
|
||||
case 1: return 'Min';
|
||||
case 2: return 'Low';
|
||||
case 3: return 'Default';
|
||||
case 4: return 'High';
|
||||
case 5: return 'Max';
|
||||
default: return `Unknown (${importance})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach to window object
|
||||
window.configurePlugin = configurePlugin;
|
||||
window.testNotification = testNotification;
|
||||
window.requestPermissions = requestPermissions;
|
||||
window.checkComprehensiveStatus = checkComprehensiveStatus;
|
||||
|
||||
function loadPermissionStatus() {
|
||||
const notificationPermStatus = document.getElementById('notificationPermStatus');
|
||||
const exactAlarmPermStatus = document.getElementById('exactAlarmPermStatus');
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
notificationPermStatus.innerHTML = '❌ Plugin unavailable';
|
||||
exactAlarmPermStatus.innerHTML = '❌ Plugin unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
window.DailyNotification.checkPermissionStatus()
|
||||
.then(result => {
|
||||
notificationPermStatus.innerHTML = result.notificationsEnabled ? '✅ Granted' : '❌ Not granted';
|
||||
exactAlarmPermStatus.innerHTML = result.exactAlarmEnabled ? '✅ Granted' : '❌ Not granted';
|
||||
})
|
||||
.catch(error => {
|
||||
notificationPermStatus.innerHTML = '⚠️ Error';
|
||||
exactAlarmPermStatus.innerHTML = '⚠️ Error';
|
||||
});
|
||||
} catch (error) {
|
||||
notificationPermStatus.innerHTML = '⚠️ Error';
|
||||
exactAlarmPermStatus.innerHTML = '⚠️ Error';
|
||||
}
|
||||
}
|
||||
|
||||
// Check for notification delivery periodically
|
||||
function checkNotificationDelivery() {
|
||||
if (!window.DailyNotification) return;
|
||||
|
||||
window.DailyNotification.getNotificationStatus()
|
||||
.then(result => {
|
||||
if (result.lastNotificationTime) {
|
||||
const lastTime = new Date(result.lastNotificationTime);
|
||||
const now = new Date();
|
||||
const timeDiff = now - lastTime;
|
||||
|
||||
// If notification was received in the last 2 minutes, show indicator
|
||||
if (timeDiff > 0 && timeDiff < 120000) {
|
||||
const indicator = document.getElementById('notificationReceivedIndicator');
|
||||
const timeSpan = document.getElementById('notificationReceivedTime');
|
||||
|
||||
if (indicator && timeSpan) {
|
||||
indicator.style.display = 'block';
|
||||
timeSpan.textContent = `Received at ${lastTime.toLocaleTimeString()}`;
|
||||
|
||||
// Hide after 30 seconds
|
||||
setTimeout(() => {
|
||||
indicator.style.display = 'none';
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Silently fail - this is just for visual feedback
|
||||
});
|
||||
}
|
||||
|
||||
// Load plugin status automatically on page load
|
||||
window.addEventListener('load', () => {
|
||||
console.log('Page loaded, loading plugin status...');
|
||||
// Small delay to ensure Capacitor is ready
|
||||
setTimeout(() => {
|
||||
loadPluginStatus();
|
||||
loadPermissionStatus();
|
||||
loadChannelStatus();
|
||||
|
||||
// Check for notification delivery every 5 seconds
|
||||
setInterval(checkNotificationDelivery, 5000);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
|
||||
|
||||
console.log('Functions attached to window:', {
|
||||
configurePlugin: typeof window.configurePlugin,
|
||||
testNotification: typeof window.testNotification
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -14,7 +14,7 @@ apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
|
||||
android {
|
||||
namespace "com.timesafari.dailynotification.plugin"
|
||||
namespace "org.timesafari.dailynotification.plugin"
|
||||
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
|
||||
|
||||
defaultConfig {
|
||||
@@ -45,21 +45,13 @@ android {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
// Disable test compilation - tests reference deprecated/removed code
|
||||
// TODO: Rewrite tests to use modern AndroidX testing framework
|
||||
// Enable unit tests with modern AndroidX testing framework
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude test sources from compilation
|
||||
sourceSets {
|
||||
test {
|
||||
java {
|
||||
srcDirs = [] // Disable test source compilation
|
||||
}
|
||||
enabled = true
|
||||
}
|
||||
// Enable Android resources for Robolectric (only for test tasks, not all tasks)
|
||||
unitTests.includeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,5 +119,13 @@ dependencies {
|
||||
// Room annotation processor - use kapt for Kotlin, annotationProcessor for Java
|
||||
kapt "androidx.room:room-compiler:2.6.1"
|
||||
annotationProcessor "androidx.room:room-compiler:2.6.1"
|
||||
|
||||
// Test dependencies
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
testImplementation "androidx.test:core:1.5.0"
|
||||
testImplementation "androidx.test.ext:junit:1.1.5"
|
||||
testImplementation "org.robolectric:robolectric:4.11.1"
|
||||
testImplementation "androidx.room:room-testing:2.6.1"
|
||||
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
|
||||
}
|
||||
|
||||
|
||||
59
android/capacitor-cordova-android-plugins/build.gradle
Normal file
59
android/capacitor-cordova-android-plugins/build.gradle
Normal file
@@ -0,0 +1,59 @@
|
||||
ext {
|
||||
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
|
||||
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1'
|
||||
}
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.2.1'
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
android {
|
||||
namespace "capacitor.cordova.android.plugins"
|
||||
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
|
||||
defaultConfig {
|
||||
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
|
||||
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
flatDir{
|
||||
dirs 'src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'src/main/libs', include: ['*.jar'])
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "org.apache.cordova:framework:$cordovaAndroidVersion"
|
||||
// SUB-PROJECT DEPENDENCIES START
|
||||
|
||||
// SUB-PROJECT DEPENDENCIES END
|
||||
}
|
||||
|
||||
// PLUGIN GRADLE EXTENSIONS START
|
||||
apply from: "cordova.variables.gradle"
|
||||
// PLUGIN GRADLE EXTENSIONS END
|
||||
|
||||
for (def func : cdvPluginPostBuildExtras) {
|
||||
func()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
ext {
|
||||
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
|
||||
// Plugin gradle extensions can append to this to have code run at the end.
|
||||
cdvPluginPostBuildExtras = []
|
||||
cordovaConfig = [:]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:amazon="http://schemas.amazon.com/apk/res/android">
|
||||
<application >
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
3
android/capacitor.settings.gradle
Normal file
3
android/capacitor.settings.gradle
Normal file
@@ -0,0 +1,3 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
@@ -2,7 +2,7 @@
|
||||
# These rules are applied to consuming apps when they use this plugin
|
||||
|
||||
# Keep plugin classes
|
||||
-keep class com.timesafari.dailynotification.** { *; }
|
||||
-keep class org.timesafari.dailynotification.** { *; }
|
||||
|
||||
# Keep Capacitor plugin interface
|
||||
-keep class com.getcapacitor.Plugin { *; }
|
||||
|
||||
@@ -22,7 +22,32 @@ org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
|
||||
# Increase memory for Gradle daemon
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
# Java 17+ requires --add-opens flags for KAPT to access internal compiler classes
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m \
|
||||
--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
|
||||
|
||||
# Kotlin compiler daemon JVM arguments (required for KAPT with Java 17+)
|
||||
# The Kotlin daemon runs separately and needs the same --add-opens flags
|
||||
kotlin.daemon.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m \
|
||||
--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
|
||||
|
||||
# Enable configuration cache
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.timesafari.dailynotification.plugin">
|
||||
package="org.timesafari.dailynotification.plugin">
|
||||
|
||||
<!-- Plugin receivers are declared in consuming app's manifest -->
|
||||
<!-- This manifest is optional and mainly for library metadata -->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{
|
||||
"pkg": "@timesafari/daily-notification-plugin",
|
||||
"name": "DailyNotification",
|
||||
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
"classpath": "org.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* PermissionManager.java
|
||||
*
|
||||
* Specialized manager for permission handling and notification settings
|
||||
* Handles notification permissions, channel management, and exact alarm settings
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 2.0.0 - Modular Architecture
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.PluginCall;
|
||||
|
||||
/**
|
||||
* Manager class for permission and settings management
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Request notification permissions
|
||||
* - Check permission status
|
||||
* - Manage notification channels
|
||||
* - Handle exact alarm settings
|
||||
* - Provide comprehensive status checking
|
||||
*/
|
||||
public class PermissionManager {
|
||||
|
||||
private static final String TAG = "PermissionManager";
|
||||
|
||||
private final Context context;
|
||||
private final ChannelManager channelManager;
|
||||
|
||||
/**
|
||||
* Initialize the PermissionManager
|
||||
*
|
||||
* @param context Android context
|
||||
* @param channelManager Channel manager for notification channels
|
||||
*/
|
||||
public PermissionManager(Context context, ChannelManager channelManager) {
|
||||
this.context = context;
|
||||
this.channelManager = channelManager;
|
||||
|
||||
Log.d(TAG, "PermissionManager initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permissions from the user
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void requestNotificationPermissions(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Requesting notification permissions");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// For Android 13+, request POST_NOTIFICATIONS permission
|
||||
requestPermission(Manifest.permission.POST_NOTIFICATIONS, call);
|
||||
} else {
|
||||
// For older versions, permissions are granted at install time
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("granted", true);
|
||||
result.put("message", "Notifications enabled (pre-Android 13)");
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error requesting notification permissions", e);
|
||||
call.reject("Failed to request permissions: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the current status of notification permissions
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void checkPermissionStatus(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Checking permission status");
|
||||
|
||||
boolean postNotificationsGranted = false;
|
||||
boolean exactAlarmsGranted = false;
|
||||
|
||||
// Check POST_NOTIFICATIONS permission
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
postNotificationsGranted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
} else {
|
||||
postNotificationsGranted = NotificationManagerCompat.from(context).areNotificationsEnabled();
|
||||
}
|
||||
|
||||
// Check exact alarm permission
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
|
||||
context.getSystemService(Context.ALARM_SERVICE);
|
||||
exactAlarmsGranted = alarmManager.canScheduleExactAlarms();
|
||||
} else {
|
||||
exactAlarmsGranted = true; // Pre-Android 12, exact alarms are always allowed
|
||||
}
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("postNotificationsGranted", postNotificationsGranted);
|
||||
result.put("exactAlarmsGranted", exactAlarmsGranted);
|
||||
result.put("channelEnabled", channelManager.isChannelEnabled());
|
||||
result.put("channelImportance", channelManager.getChannelImportance());
|
||||
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking permission status", e);
|
||||
call.reject("Failed to check permissions: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open exact alarm settings for the user
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void openExactAlarmSettings(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Opening exact alarm settings");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM);
|
||||
intent.setData(android.net.Uri.parse("package:" + context.getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
try {
|
||||
context.startActivity(intent);
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("message", "Exact alarm settings opened");
|
||||
call.resolve(result);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to open exact alarm settings", e);
|
||||
call.reject("Failed to open exact alarm settings: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("message", "Exact alarms not supported on this Android version");
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error opening exact alarm settings", e);
|
||||
call.reject("Failed to open exact alarm settings: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the notification channel is enabled
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void isChannelEnabled(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Checking channel status");
|
||||
|
||||
boolean enabled = channelManager.isChannelEnabled();
|
||||
int importance = channelManager.getChannelImportance();
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("enabled", enabled);
|
||||
result.put("importance", importance);
|
||||
result.put("channelId", channelManager.getDefaultChannelId());
|
||||
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking channel status", e);
|
||||
call.reject("Failed to check channel status: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open notification channel settings for the user
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void openChannelSettings(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Opening channel settings");
|
||||
|
||||
boolean opened = channelManager.openChannelSettings();
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("opened", opened);
|
||||
result.put("message", opened ? "Channel settings opened" : "Failed to open channel settings");
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error opening channel settings", e);
|
||||
call.reject("Failed to open channel settings: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive status of the notification system
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void checkStatus(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Checking comprehensive status");
|
||||
|
||||
// Check permissions
|
||||
boolean postNotificationsGranted = false;
|
||||
boolean exactAlarmsGranted = false;
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
postNotificationsGranted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
} else {
|
||||
postNotificationsGranted = NotificationManagerCompat.from(context).areNotificationsEnabled();
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
|
||||
context.getSystemService(Context.ALARM_SERVICE);
|
||||
exactAlarmsGranted = alarmManager.canScheduleExactAlarms();
|
||||
} else {
|
||||
exactAlarmsGranted = true;
|
||||
}
|
||||
|
||||
// Check channel status
|
||||
boolean channelEnabled = channelManager.isChannelEnabled();
|
||||
int channelImportance = channelManager.getChannelImportance();
|
||||
|
||||
// Determine overall status
|
||||
boolean canScheduleNow = postNotificationsGranted && channelEnabled && exactAlarmsGranted;
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("canScheduleNow", canScheduleNow);
|
||||
result.put("postNotificationsGranted", postNotificationsGranted);
|
||||
result.put("exactAlarmsGranted", exactAlarmsGranted);
|
||||
result.put("channelEnabled", channelEnabled);
|
||||
result.put("channelImportance", channelImportance);
|
||||
result.put("channelId", channelManager.getDefaultChannelId());
|
||||
result.put("androidVersion", Build.VERSION.SDK_INT);
|
||||
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking comprehensive status", e);
|
||||
call.reject("Failed to check status: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a specific permission
|
||||
*
|
||||
* @param permission Permission to request
|
||||
* @param call Plugin call
|
||||
*/
|
||||
private void requestPermission(String permission, PluginCall call) {
|
||||
try {
|
||||
// This would typically be handled by the Capacitor framework
|
||||
// For now, we'll check if the permission is already granted
|
||||
boolean granted = context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED;
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("granted", granted);
|
||||
result.put("permission", permission);
|
||||
result.put("message", granted ? "Permission already granted" : "Permission not granted");
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error requesting permission: " + permission, e);
|
||||
call.reject("Failed to request permission: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -76,11 +76,13 @@ class BootReceiver : BroadcastReceiver() {
|
||||
// Reschedule AlarmManager notification
|
||||
val nextRunTime = calculateNextRunTime(schedule)
|
||||
if (nextRunTime > System.currentTimeMillis()) {
|
||||
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
|
||||
?: Pair("Daily Notification", "Your daily update is ready")
|
||||
val config = UserNotificationConfig(
|
||||
enabled = schedule.enabled,
|
||||
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
|
||||
title = "Daily Notification",
|
||||
body = "Your daily update is ready",
|
||||
title = title,
|
||||
body = body,
|
||||
sound = true,
|
||||
vibration = true,
|
||||
priority = "normal"
|
||||
@@ -90,7 +92,8 @@ class BootReceiver : BroadcastReceiver() {
|
||||
nextRunTime,
|
||||
config,
|
||||
scheduleId = schedule.id,
|
||||
source = ScheduleSource.BOOT_RECOVERY
|
||||
source = ScheduleSource.BOOT_RECOVERY,
|
||||
skipPendingIntentIdempotence = true
|
||||
)
|
||||
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
@@ -21,9 +21,8 @@ import android.util.Log;
|
||||
*/
|
||||
public class ChannelManager {
|
||||
private static final String TAG = "ChannelManager";
|
||||
private static final String DEFAULT_CHANNEL_ID = "timesafari.daily";
|
||||
private static final String DEFAULT_CHANNEL_NAME = "Daily Notifications";
|
||||
private static final String DEFAULT_CHANNEL_DESCRIPTION = "Daily notifications from TimeSafari";
|
||||
// Channel constants moved to DailyNotificationConstants
|
||||
// Use DailyNotificationConstants.DEFAULT_CHANNEL_ID, etc.
|
||||
|
||||
private final Context context;
|
||||
private final NotificationManager notificationManager;
|
||||
@@ -44,7 +43,7 @@ public class ChannelManager {
|
||||
Log.d(TAG, "Ensuring notification channel exists");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID);
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
|
||||
if (channel == null) {
|
||||
Log.d(TAG, "Creating notification channel");
|
||||
@@ -73,7 +72,7 @@ public class ChannelManager {
|
||||
public boolean isChannelEnabled() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID);
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
if (channel == null) {
|
||||
Log.w(TAG, "Channel does not exist");
|
||||
return false;
|
||||
@@ -100,7 +99,7 @@ public class ChannelManager {
|
||||
public int getChannelImportance() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID);
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
if (channel != null) {
|
||||
return channel.getImportance();
|
||||
}
|
||||
@@ -118,18 +117,53 @@ public class ChannelManager {
|
||||
* @return true if settings intent was launched, false otherwise
|
||||
*/
|
||||
public boolean openChannelSettings() {
|
||||
return openChannelSettings(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the notification channel settings for a specific channel.
|
||||
*
|
||||
* @param channelId Channel ID to open settings for (defaults to DEFAULT_CHANNEL_ID if null)
|
||||
* @return true if settings intent was launched, false otherwise
|
||||
*/
|
||||
public boolean openChannelSettings(String channelId) {
|
||||
try {
|
||||
Log.d(TAG, "Opening channel settings");
|
||||
Log.d(TAG, "Opening channel settings for channel: " + channelId);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// Ensure channel exists before trying to open settings
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
|
||||
if (channel == null) {
|
||||
Log.d(TAG, "Channel does not exist, creating it first");
|
||||
createDefaultChannel();
|
||||
}
|
||||
|
||||
// Try to open channel-specific settings
|
||||
try {
|
||||
Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName())
|
||||
.putExtra(Settings.EXTRA_CHANNEL_ID, DEFAULT_CHANNEL_ID)
|
||||
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId != null ? channelId : org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
context.startActivity(intent);
|
||||
Log.d(TAG, "Channel settings opened");
|
||||
Log.d(TAG, "Channel settings opened for channel: " + channelId);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
// Fallback to general app notification settings
|
||||
Log.w(TAG, "Failed to open channel-specific settings, trying app notification settings", e);
|
||||
try {
|
||||
Intent fallbackIntent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName())
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
context.startActivity(fallbackIntent);
|
||||
Log.d(TAG, "App notification settings opened (fallback)");
|
||||
return true;
|
||||
} catch (Exception e2) {
|
||||
Log.e(TAG, "Failed to open notification settings", e2);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Channel settings not available on pre-Oreo");
|
||||
return false;
|
||||
@@ -146,11 +180,11 @@ public class ChannelManager {
|
||||
private void createDefaultChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
DEFAULT_CHANNEL_ID,
|
||||
DEFAULT_CHANNEL_NAME,
|
||||
org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID,
|
||||
org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
);
|
||||
channel.setDescription(DEFAULT_CHANNEL_DESCRIPTION);
|
||||
channel.setDescription(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_DESCRIPTION);
|
||||
channel.enableLights(true);
|
||||
channel.enableVibration(true);
|
||||
channel.setShowBadge(true);
|
||||
@@ -166,7 +200,7 @@ public class ChannelManager {
|
||||
* @return the default channel ID
|
||||
*/
|
||||
public String getDefaultChannelId() {
|
||||
return DEFAULT_CHANNEL_ID;
|
||||
return org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,7 +209,7 @@ public class ChannelManager {
|
||||
public void logChannelStatus() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID);
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
if (channel != null) {
|
||||
Log.i(TAG, "Channel Status - ID: " + channel.getId() +
|
||||
", Importance: " + channel.getImportance() +
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
/**
|
||||
* [org.timesafari.dailynotification.ContentCache] row discriminator so dual-schedule
|
||||
* prefetch does not overwrite daily-reminder cache (and vice versa).
|
||||
*/
|
||||
object ContentCacheScope {
|
||||
const val DUAL = "dual"
|
||||
const val DAILY = "daily"
|
||||
const val LEGACY = "legacy"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* DailyNotificationConstants.kt
|
||||
*
|
||||
* Centralized constants for Daily Notification Plugin
|
||||
* Eliminates magic numbers and string duplication across the codebase
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
/**
|
||||
* Centralized constants for Daily Notification Plugin
|
||||
*
|
||||
* All request codes, channel IDs, action strings, and extra keys
|
||||
* should be defined here and imported where needed.
|
||||
*/
|
||||
object DailyNotificationConstants {
|
||||
|
||||
// ============================================================
|
||||
// Permission Request Codes
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Maximum number of distinct JWT strings allowed in [configureNativeFetcher] `jwtTokens` / pool.
|
||||
* Host apps (e.g. TimeSafari) use a pool for background prefetch; cap avoids oversized bridge payloads.
|
||||
*/
|
||||
const val JWT_TOKEN_POOL_MAX = 128
|
||||
|
||||
/**
|
||||
* Request code for notification permission requests
|
||||
* Used by ActivityCompat.requestPermissions()
|
||||
*/
|
||||
const val PERMISSION_REQUEST_CODE = 1001
|
||||
|
||||
// ============================================================
|
||||
// Notification Channel Constants
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Default notification channel ID
|
||||
* Must match across ChannelManager and NotifyReceiver
|
||||
*/
|
||||
const val DEFAULT_CHANNEL_ID = "timesafari.daily"
|
||||
|
||||
/**
|
||||
* Default notification channel name (user-visible)
|
||||
*/
|
||||
const val DEFAULT_CHANNEL_NAME = "Daily Notifications"
|
||||
|
||||
/**
|
||||
* Default notification channel description
|
||||
*/
|
||||
const val DEFAULT_CHANNEL_DESCRIPTION = "Daily notifications from TimeSafari"
|
||||
|
||||
// ============================================================
|
||||
// Intent Actions
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Action string for notification broadcast intents
|
||||
* Used by AlarmManager PendingIntents
|
||||
*/
|
||||
const val ACTION_NOTIFICATION = "org.timesafari.daily.NOTIFICATION"
|
||||
|
||||
// ============================================================
|
||||
// Intent Extras Keys
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Extra key for notification ID in broadcast intents
|
||||
*/
|
||||
const val EXTRA_NOTIFICATION_ID = "notification_id"
|
||||
|
||||
/**
|
||||
* Extra key for schedule ID in broadcast intents
|
||||
*/
|
||||
const val EXTRA_SCHEDULE_ID = "schedule_id"
|
||||
|
||||
// ============================================================
|
||||
// Notification IDs
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Default notification ID for daily notifications
|
||||
* Used by NotificationManager.notify()
|
||||
*/
|
||||
const val DEFAULT_NOTIFICATION_ID = 1001
|
||||
|
||||
// ============================================================
|
||||
// SharedPreferences Keys
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* SharedPreferences file name for plugin storage
|
||||
*/
|
||||
const val PREFS_NAME = "daily_notification_timesafari"
|
||||
|
||||
/**
|
||||
* SharedPreferences key for starred plan IDs
|
||||
* Used by updateStarredPlans() and TimeSafariIntegrationManager
|
||||
*/
|
||||
const val PREFS_KEY_STARRED_PLAN_IDS = "starredPlanIds"
|
||||
|
||||
// ============================================================
|
||||
// WorkManager Tags
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* WorkManager tag for prefetch jobs
|
||||
*/
|
||||
const val WORK_TAG_PREFETCH = "prefetch"
|
||||
|
||||
/**
|
||||
* WorkManager tag for fetch jobs
|
||||
*/
|
||||
const val WORK_TAG_FETCH = "daily_notification_fetch"
|
||||
|
||||
/**
|
||||
* WorkManager tag for maintenance jobs
|
||||
*/
|
||||
const val WORK_TAG_MAINTENANCE = "daily_notification_maintenance"
|
||||
|
||||
/**
|
||||
* WorkManager tag for soft refetch jobs
|
||||
*/
|
||||
const val WORK_TAG_SOFT_REFETCH = "soft_refetch"
|
||||
|
||||
/**
|
||||
* WorkManager tag for display jobs
|
||||
*/
|
||||
const val WORK_TAG_DISPLAY = "daily_notification_display"
|
||||
|
||||
/**
|
||||
* WorkManager tag for dismiss jobs
|
||||
*/
|
||||
const val WORK_TAG_DISMISS = "daily_notification_dismiss"
|
||||
|
||||
// ============================================================
|
||||
// Schedule IDs
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Default schedule ID for daily notifications
|
||||
* Used when user doesn't provide a custom ID
|
||||
*/
|
||||
const val DEFAULT_SCHEDULE_ID = "daily_notification"
|
||||
|
||||
/**
|
||||
* SharedPreferences name for dual (New Activity) schedule config.
|
||||
* Used by plugin to persist config and by Worker to resolve relationship (contentTimeout/fallbackBehavior).
|
||||
*/
|
||||
const val DUAL_SCHEDULE_PREFS = "daily_notification_dual"
|
||||
|
||||
/**
|
||||
* Key for persisted dual schedule config JSON (userNotification + relationship).
|
||||
*/
|
||||
const val DUAL_SCHEDULE_CONFIG_KEY = "dual_schedule_config"
|
||||
|
||||
/**
|
||||
* Stable dual notify [Schedule.id] persisted when [ScheduleHelper.scheduleDualNotification] runs.
|
||||
* The user-visible alarm is scheduled after prefetch completes ([DualScheduleNotifyScheduler]).
|
||||
*/
|
||||
const val DUAL_NOTIFY_SCHEDULE_ID_KEY = "dual_notify_schedule_id"
|
||||
|
||||
/**
|
||||
* Prefix for dual notify schedule IDs. Receiver uses this to apply relationship at fire time.
|
||||
*/
|
||||
const val DUAL_NOTIFY_SCHEDULE_ID_PREFIX = "dual_notify_"
|
||||
|
||||
// ============================================================
|
||||
// Request Code Versioning
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Version for request code derivation algorithm
|
||||
* Increment if request code generation logic changes
|
||||
*/
|
||||
const val REQUEST_CODE_VERSION = 1
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -26,6 +26,34 @@ import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* Metrics interface for fetch worker operations
|
||||
*/
|
||||
interface FetchWorkerMetrics {
|
||||
void incRun();
|
||||
void incSuccess();
|
||||
void incFailure();
|
||||
void incRetry();
|
||||
void observeDurationMs(long ms);
|
||||
void observeItemsEnqueued(int n);
|
||||
void observeItemsFetched(int n);
|
||||
void observeItemsSaved(int n);
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op metrics implementation
|
||||
*/
|
||||
final class NoopFetchWorkerMetrics implements FetchWorkerMetrics {
|
||||
public void incRun() {}
|
||||
public void incSuccess() {}
|
||||
public void incFailure() {}
|
||||
public void incRetry() {}
|
||||
public void observeDurationMs(long ms) {}
|
||||
public void observeItemsEnqueued(int n) {}
|
||||
public void observeItemsFetched(int n) {}
|
||||
public void observeItemsSaved(int n) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Background worker for fetching daily notification content
|
||||
*
|
||||
@@ -50,6 +78,7 @@ public class DailyNotificationFetchWorker extends Worker {
|
||||
private final Context context;
|
||||
private final DailyNotificationStorage storage;
|
||||
private final DailyNotificationFetcher fetcher; // Legacy fetcher (fallback only)
|
||||
private final FetchWorkerMetrics metrics;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
@@ -63,6 +92,7 @@ public class DailyNotificationFetchWorker extends Worker {
|
||||
this.context = context;
|
||||
this.storage = new DailyNotificationStorage(context);
|
||||
this.fetcher = new DailyNotificationFetcher(context, storage);
|
||||
this.metrics = new NoopFetchWorkerMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +103,9 @@ public class DailyNotificationFetchWorker extends Worker {
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
long started = System.currentTimeMillis();
|
||||
metrics.incRun();
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Starting background content fetch");
|
||||
|
||||
@@ -89,6 +122,8 @@ public class DailyNotificationFetchWorker extends Worker {
|
||||
// Check if we should proceed with fetch
|
||||
if (!shouldProceedWithFetch(scheduledTime, fetchTime)) {
|
||||
Log.d(TAG, "Skipping fetch - conditions not met");
|
||||
metrics.incSuccess();
|
||||
metrics.observeDurationMs(System.currentTimeMillis() - started);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@@ -98,19 +133,63 @@ public class DailyNotificationFetchWorker extends Worker {
|
||||
if (contents != null && !contents.isEmpty()) {
|
||||
// Success - save contents and schedule notifications
|
||||
handleSuccessfulFetch(contents);
|
||||
metrics.incSuccess();
|
||||
metrics.observeDurationMs(System.currentTimeMillis() - started);
|
||||
return Result.success();
|
||||
|
||||
} else {
|
||||
// Fetch failed - handle retry logic
|
||||
return handleFailedFetch(retryCount, scheduledTime);
|
||||
Result result = handleFailedFetch(retryCount, scheduledTime);
|
||||
metrics.observeDurationMs(System.currentTimeMillis() - started);
|
||||
return result;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Unexpected error during background fetch", e);
|
||||
boolean retryable = isRetryable(e);
|
||||
if (retryable) {
|
||||
metrics.incRetry();
|
||||
} else {
|
||||
metrics.incFailure();
|
||||
}
|
||||
metrics.observeDurationMs(System.currentTimeMillis() - started);
|
||||
return handleFailedFetch(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify whether an exception is retryable
|
||||
*
|
||||
* @param t Exception to classify
|
||||
* @return true if retryable, false otherwise
|
||||
*/
|
||||
private boolean isRetryable(Throwable t) {
|
||||
if (t == null) return true;
|
||||
|
||||
// Common network-ish failures
|
||||
String name = t.getClass().getName();
|
||||
if (name.contains("SocketTimeout") || name.contains("ConnectException") ||
|
||||
name.contains("UnknownHost") || name.contains("TimeoutException")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If you have HTTP status errors, classify them (adapt to your exception type)
|
||||
try {
|
||||
java.lang.reflect.Method m = t.getClass().getMethod("getStatusCode");
|
||||
Object codeObj = m.invoke(t);
|
||||
if (codeObj instanceof Integer) {
|
||||
int code = (Integer) codeObj;
|
||||
if (code == 429) return true; // Rate limit - retry with backoff
|
||||
if (code >= 500) return true; // Server error - retry
|
||||
if (code >= 400) return false; // Client error (except 429) - don't retry
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
// Not an HTTP exception; treat as retryable by default
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should proceed with the fetch
|
||||
*
|
||||
@@ -210,17 +289,22 @@ public class DailyNotificationFetchWorker extends Worker {
|
||||
if (contents != null && !contents.isEmpty()) {
|
||||
Log.i(TAG, "PR2: Content fetched successfully - " + contents.size() +
|
||||
" items in " + fetchDuration + "ms");
|
||||
// TODO PR2: Record metrics (items_fetched, fetch_duration_ms, fetch_success)
|
||||
metrics.observeItemsFetched(contents.size());
|
||||
return contents;
|
||||
} else {
|
||||
Log.w(TAG, "PR2: Native fetcher returned empty list after " + fetchDuration + "ms");
|
||||
// TODO PR2: Record metrics (fetch_success=false)
|
||||
metrics.incFailure();
|
||||
return null;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "PR2: Error during native fetcher call", e);
|
||||
// TODO PR2: Record metrics (fetch_fail_class=retryable)
|
||||
boolean retryable = isRetryable(e);
|
||||
if (retryable) {
|
||||
metrics.incRetry();
|
||||
} else {
|
||||
metrics.incFailure();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -236,8 +320,9 @@ public class DailyNotificationFetchWorker extends Worker {
|
||||
android.content.SharedPreferences prefs = context.getSharedPreferences(
|
||||
"daily_notification_spi", Context.MODE_PRIVATE);
|
||||
|
||||
// For now, return default policy
|
||||
// TODO: Deserialize from SharedPreferences in future enhancement
|
||||
// NOTE: We intentionally do not deserialize large payloads from SharedPreferences.
|
||||
// Storage of notification content is handled by DailyNotificationStorage/DB layer.
|
||||
// SchedulingPolicy is lightweight and can be stored in SharedPreferences if needed in future.
|
||||
return SchedulingPolicy.createDefault();
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -326,7 +411,11 @@ public class DailyNotificationFetchWorker extends Worker {
|
||||
Log.i(TAG, "PR2: Successful fetch handling completed - " + scheduledCount + "/" +
|
||||
contents.size() + " notifications scheduled" +
|
||||
(skippedCount > 0 ? ", " + skippedCount + " duplicates skipped" : ""));
|
||||
// TODO PR2: Record metrics (items_enqueued=scheduledCount)
|
||||
|
||||
// Record metrics
|
||||
metrics.observeItemsFetched(contents.size());
|
||||
metrics.observeItemsSaved(scheduledCount);
|
||||
metrics.observeItemsEnqueued(scheduledCount);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "PR2: Error handling successful fetch", e);
|
||||
@@ -348,17 +437,25 @@ public class DailyNotificationFetchWorker extends Worker {
|
||||
// PR2: Schedule retry with SchedulingPolicy backoff
|
||||
scheduleRetry(retryCount + 1, scheduledTime);
|
||||
Log.i(TAG, "PR2: Scheduled retry attempt " + (retryCount + 1));
|
||||
metrics.incRetry();
|
||||
return Result.retry();
|
||||
|
||||
} else {
|
||||
// Max retries reached - use fallback content
|
||||
Log.w(TAG, "PR2: Max retries reached, using fallback content");
|
||||
useFallbackContent(scheduledTime);
|
||||
metrics.incFailure();
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "PR2 metabolites Error handling failed fetch", e);
|
||||
Log.e(TAG, "PR2: Error handling failed fetch", e);
|
||||
boolean retryable = isRetryable(e);
|
||||
if (retryable) {
|
||||
metrics.incRetry();
|
||||
} else {
|
||||
metrics.incFailure();
|
||||
}
|
||||
return Result.failure();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -42,7 +42,7 @@ public class DailyNotificationFetcher {
|
||||
|
||||
private final Context context;
|
||||
private final DailyNotificationStorage storage; // Deprecated path (kept for transitional read paths)
|
||||
private final com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage; // Preferred path
|
||||
private final org.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage; // Preferred path
|
||||
private final WorkManager workManager;
|
||||
|
||||
// ETag manager for efficient fetching
|
||||
@@ -60,7 +60,7 @@ public class DailyNotificationFetcher {
|
||||
|
||||
public DailyNotificationFetcher(Context context,
|
||||
DailyNotificationStorage storage,
|
||||
com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage) {
|
||||
org.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage) {
|
||||
this.context = context;
|
||||
this.storage = storage;
|
||||
this.roomStorage = roomStorage;
|
||||
@@ -220,8 +220,8 @@ public class DailyNotificationFetcher {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
com.timesafari.dailynotification.entities.NotificationContentEntity entity =
|
||||
new com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
org.timesafari.dailynotification.entities.NotificationContentEntity entity =
|
||||
new org.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
|
||||
"1.0.0",
|
||||
null,
|
||||
@@ -9,7 +9,7 @@
|
||||
* @created 2025-10-03 06:53:30 UTC
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.util.Log;
|
||||
import android.content.Context;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Debug;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -59,7 +59,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("com.timesafari.daily.NOTIFICATION".equals(action)) {
|
||||
if ("org.timesafari.daily.NOTIFICATION".equals(action)) {
|
||||
// Parse intent and enqueue work - keep receiver ultra-light
|
||||
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
|
||||
if (notificationId == null) {
|
||||
@@ -72,7 +72,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
enqueueNotificationWork(context, notificationId, intent);
|
||||
Log.d(TAG, "DN|RECEIVE_OK enqueued=" + notificationId);
|
||||
|
||||
} else if ("com.timesafari.daily.DISMISS".equals(action)) {
|
||||
} else if ("org.timesafari.daily.DISMISS".equals(action)) {
|
||||
// Handle dismissal - also lightweight
|
||||
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
|
||||
if (notificationId != null) {
|
||||
@@ -109,7 +109,9 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
String workName = "display_" + notificationId;
|
||||
|
||||
// Extract static reminder extras from intent if present
|
||||
// Static reminders have title/body in Intent extras, not in storage
|
||||
// Static reminders have title/body in Intent extras, not in storage.
|
||||
// Do NOT access DB on main thread here (Room disallows it); Worker will resolve
|
||||
// missing title/body by schedule_id on a background thread (see plugin-feedback-android-rollover-double-fire).
|
||||
boolean isStaticReminder = intent.getBooleanExtra("is_static_reminder", false);
|
||||
String title = intent.getStringExtra("title");
|
||||
String body = intent.getStringExtra("body");
|
||||
@@ -119,13 +121,17 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
if (priority == null) {
|
||||
priority = "normal";
|
||||
}
|
||||
String scheduleId = intent.getStringExtra("schedule_id");
|
||||
|
||||
Data.Builder dataBuilder = new Data.Builder()
|
||||
.putString("notification_id", notificationId)
|
||||
.putString("action", "display")
|
||||
.putBoolean("is_static_reminder", isStaticReminder);
|
||||
if (scheduleId != null && !scheduleId.isEmpty()) {
|
||||
dataBuilder.putString("schedule_id", scheduleId);
|
||||
}
|
||||
|
||||
// Add static reminder data if present
|
||||
// Add static reminder data when present (from Intent; Worker resolves from DB by schedule_id if missing)
|
||||
if (isStaticReminder && title != null && body != null) {
|
||||
dataBuilder.putString("title", title)
|
||||
.putString("body", body)
|
||||
@@ -356,7 +362,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
|
||||
// Add dismiss action
|
||||
Intent dismissIntent = new Intent(context, DailyNotificationReceiver.class);
|
||||
dismissIntent.setAction("com.timesafari.daily.DISMISS");
|
||||
dismissIntent.setAction("org.timesafari.daily.DISMISS");
|
||||
dismissIntent.putExtra(EXTRA_NOTIFICATION_ID, content.getId());
|
||||
|
||||
PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
|
||||
@@ -426,8 +432,8 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
}
|
||||
|
||||
// Create config for next notification
|
||||
com.timesafari.dailynotification.UserNotificationConfig config =
|
||||
new com.timesafari.dailynotification.UserNotificationConfig(
|
||||
org.timesafari.dailynotification.UserNotificationConfig config =
|
||||
new org.timesafari.dailynotification.UserNotificationConfig(
|
||||
true, // enabled
|
||||
cronExpression,
|
||||
content.getTitle() != null ? content.getTitle() : "Daily Notification",
|
||||
@@ -438,14 +444,15 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
);
|
||||
|
||||
// Use centralized scheduling function with ROLLOVER_ON_FIRE source
|
||||
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
nextScheduledTime,
|
||||
config,
|
||||
false, // isStaticReminder
|
||||
null, // reminderId
|
||||
scheduleId,
|
||||
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
|
||||
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
|
||||
false // skipPendingIntentIdempotence – rollover path does not skip
|
||||
);
|
||||
|
||||
Log.i(TAG, "Next notification scheduled via centralized function: scheduleId=" + scheduleId);
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -271,9 +271,16 @@ public class DailyNotificationRollingWindow {
|
||||
*/
|
||||
private int countPendingNotifications() {
|
||||
try {
|
||||
// This would typically query the storage for pending notifications
|
||||
// For now, we'll use a placeholder implementation
|
||||
return 0; // TODO: Implement actual counting logic
|
||||
long now = System.currentTimeMillis();
|
||||
int count = 0;
|
||||
|
||||
List<NotificationContent> all = storage.getAllNotifications();
|
||||
for (NotificationContent n : all) {
|
||||
if (n.getScheduledTime() >= now) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error counting pending notifications", e);
|
||||
@@ -289,9 +296,19 @@ public class DailyNotificationRollingWindow {
|
||||
*/
|
||||
private int countNotificationsForDate(String date) {
|
||||
try {
|
||||
// This would typically query the storage for notifications on a specific date
|
||||
// For now, we'll use a placeholder implementation
|
||||
return 0; // TODO: Implement actual counting logic
|
||||
long[] bounds = dateBoundsMillis(date);
|
||||
long start = bounds[0];
|
||||
long end = bounds[1];
|
||||
|
||||
int count = 0;
|
||||
List<NotificationContent> all = storage.getAllNotifications();
|
||||
for (NotificationContent n : all) {
|
||||
long t = n.getScheduledTime();
|
||||
if (t >= start && t < end) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error counting notifications for date: " + date, e);
|
||||
@@ -307,9 +324,19 @@ public class DailyNotificationRollingWindow {
|
||||
*/
|
||||
private List<NotificationContent> getNotificationsForDate(String date) {
|
||||
try {
|
||||
// This would typically query the storage for notifications on a specific date
|
||||
// For now, we'll return an empty list
|
||||
return new ArrayList<>(); // TODO: Implement actual retrieval logic
|
||||
long[] bounds = dateBoundsMillis(date);
|
||||
long start = bounds[0];
|
||||
long end = bounds[1];
|
||||
|
||||
List<NotificationContent> results = new ArrayList<>();
|
||||
List<NotificationContent> all = storage.getAllNotifications();
|
||||
for (NotificationContent n : all) {
|
||||
long t = n.getScheduledTime();
|
||||
if (t >= start && t < end) {
|
||||
results.add(n);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error getting notifications for date: " + date, e);
|
||||
@@ -331,6 +358,34 @@ public class DailyNotificationRollingWindow {
|
||||
return String.format("%04d-%02d-%02d", year, month, day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date bounds in milliseconds for a given date string
|
||||
*
|
||||
* @param yyyyMmDd Date in YYYY-MM-DD format
|
||||
* @return Array with [startMillis, endMillis]
|
||||
*/
|
||||
private long[] dateBoundsMillis(String yyyyMmDd) {
|
||||
// yyyyMmDd: "YYYY-MM-DD"
|
||||
String[] parts = yyyyMmDd.split("-");
|
||||
int year = Integer.parseInt(parts[0]);
|
||||
int month = Integer.parseInt(parts[1]); // 1-12
|
||||
int day = Integer.parseInt(parts[2]);
|
||||
|
||||
Calendar start = Calendar.getInstance();
|
||||
start.set(Calendar.YEAR, year);
|
||||
start.set(Calendar.MONTH, month - 1); // Calendar months are 0-based
|
||||
start.set(Calendar.DAY_OF_MONTH, day);
|
||||
start.set(Calendar.HOUR_OF_DAY, 0);
|
||||
start.set(Calendar.MINUTE, 0);
|
||||
start.set(Calendar.SECOND, 0);
|
||||
start.set(Calendar.MILLISECOND, 0);
|
||||
|
||||
Calendar end = (Calendar) start.clone();
|
||||
end.add(Calendar.DAY_OF_MONTH, 1);
|
||||
|
||||
return new long[] { start.getTimeInMillis(), end.getTimeInMillis() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rolling window statistics
|
||||
*
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -29,8 +29,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
public class DailyNotificationScheduler {
|
||||
|
||||
private static final String TAG = "DailyNotificationScheduler";
|
||||
private static final String ACTION_NOTIFICATION = "com.timesafari.daily.NOTIFICATION";
|
||||
private static final String EXTRA_NOTIFICATION_ID = "notification_id";
|
||||
// Intent action and extras moved to DailyNotificationConstants
|
||||
|
||||
private final Context context;
|
||||
private final AlarmManager alarmManager;
|
||||
@@ -155,10 +154,11 @@ public class DailyNotificationScheduler {
|
||||
cancelNotification(duplicateId);
|
||||
}
|
||||
|
||||
// Create intent for the notification
|
||||
// Create intent for the notification; setPackage ensures AlarmManager delivery on all OEMs
|
||||
Intent intent = new Intent(context, DailyNotificationReceiver.class);
|
||||
intent.setAction(ACTION_NOTIFICATION);
|
||||
intent.putExtra(EXTRA_NOTIFICATION_ID, content.getId());
|
||||
intent.setPackage(context.getPackageName());
|
||||
intent.setAction(org.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
|
||||
intent.putExtra(org.timesafari.dailynotification.DailyNotificationConstants.EXTRA_NOTIFICATION_ID, content.getId());
|
||||
|
||||
// Check if this is a static reminder
|
||||
if (content.getId().startsWith("reminder_") || content.getId().contains("_reminder")) {
|
||||
@@ -227,54 +227,13 @@ public class DailyNotificationScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule an exact alarm for precise timing with enhanced Doze handling
|
||||
*
|
||||
* @param pendingIntent PendingIntent to trigger
|
||||
* @param triggerTime When to trigger the alarm
|
||||
* @return true if scheduling was successful
|
||||
*/
|
||||
private boolean scheduleExactAlarm(PendingIntent pendingIntent, long triggerTime) {
|
||||
try {
|
||||
// WARNING: This is the OLD scheduler - should be replaced with NotifyReceiver.scheduleExactNotification()
|
||||
// Deep logging to identify if this path is still being called (should not be for daily notifications)
|
||||
Log.w(TAG, "LEGACY SCHEDULER CALLED: Scheduling OS alarm: variant=LEGACY_SCHEDULER, triggerTime=" + triggerTime + ", pendingIntentHash=" + pendingIntent.hashCode());
|
||||
Log.w(TAG, "This should NOT be called for daily notifications - use NotifyReceiver.scheduleExactNotification() instead");
|
||||
|
||||
// Enhanced exact alarm scheduling for Android 12+ and Doze mode
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// Use setExactAndAllowWhileIdle for Doze mode compatibility
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
triggerTime,
|
||||
pendingIntent
|
||||
);
|
||||
|
||||
Log.d(TAG, "Exact alarm scheduled with Doze compatibility for " + formatTime(triggerTime));
|
||||
} else {
|
||||
// Pre-Android 6.0: Use standard exact alarm
|
||||
alarmManager.setExact(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
triggerTime,
|
||||
pendingIntent
|
||||
);
|
||||
|
||||
Log.d(TAG, "Exact alarm scheduled (pre-Android 6.0) for " + formatTime(triggerTime));
|
||||
}
|
||||
|
||||
// Log alarm scheduling details for debugging
|
||||
logAlarmSchedulingDetails(triggerTime);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (SecurityException e) {
|
||||
Log.e(TAG, "Security exception scheduling exact alarm - exact alarm permission may be denied", e);
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error scheduling exact alarm", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Legacy scheduleExactAlarm() method removed - was never called
|
||||
// All scheduling now goes through:
|
||||
// 1. exactAlarmManager.scheduleAlarm() (if available)
|
||||
// 2. pendingIntentManager.scheduleExactAlarm() (modern path)
|
||||
// 3. pendingIntentManager.scheduleWindowedAlarm() (fallback)
|
||||
//
|
||||
// For daily notifications, use NotifyReceiver.scheduleExactNotification() directly
|
||||
|
||||
/**
|
||||
* Log detailed alarm scheduling information for debugging
|
||||
@@ -513,6 +472,23 @@ public class DailyNotificationScheduler {
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000); // 24 hours from now
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a test alarm for testing purposes
|
||||
*
|
||||
* @param secondsFromNow Number of seconds from now to schedule the alarm
|
||||
*/
|
||||
public void testAlarm(int secondsFromNow) {
|
||||
try {
|
||||
Log.d(TAG, "Scheduling test alarm in " + secondsFromNow + " seconds");
|
||||
// Delegate to NotifyReceiver.testAlarm()
|
||||
org.timesafari.dailynotification.NotifyReceiver.Companion.testAlarm(context, secondsFromNow);
|
||||
Log.i(TAG, "Test alarm scheduled successfully");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error scheduling test alarm", e);
|
||||
throw new RuntimeException("Failed to schedule test alarm: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending notifications
|
||||
*
|
||||
@@ -600,6 +576,61 @@ public class DailyNotificationScheduler {
|
||||
return scheduledAlarms.containsKey(notificationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an alarm is scheduled in AlarmManager for a specific time
|
||||
*
|
||||
* Delegates to NotifyReceiver to check actual AlarmManager state via PendingIntent
|
||||
*
|
||||
* @param scheduleId Optional schedule ID to check
|
||||
* @param triggerAtMillis Optional trigger time in milliseconds to check
|
||||
* @return true if alarm is scheduled in AlarmManager, false otherwise
|
||||
*/
|
||||
public boolean isScheduled(String scheduleId, Long triggerAtMillis) {
|
||||
try {
|
||||
// Delegate to NotifyReceiver which checks actual AlarmManager state
|
||||
// Note: NotifyReceiver.isAlarmScheduled is a Kotlin companion object function with default parameters
|
||||
// From Java, we need to use Companion and provide explicit values (null is acceptable for optional params)
|
||||
// Kotlin Long? maps to java.lang.Long in Java
|
||||
return org.timesafari.dailynotification.NotifyReceiver.Companion.isAlarmScheduled(
|
||||
context,
|
||||
scheduleId,
|
||||
triggerAtMillis
|
||||
);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking alarm schedule status", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an alarm is scheduled in AlarmManager for a specific time
|
||||
*
|
||||
* @param triggerAtMillis Trigger time in milliseconds
|
||||
* @return true if alarm is scheduled in AlarmManager, false otherwise
|
||||
*/
|
||||
public boolean isScheduled(Long triggerAtMillis) {
|
||||
return isScheduled(null, triggerAtMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next scheduled alarm time from AlarmManager
|
||||
*
|
||||
* Delegates to NotifyReceiver to get actual AlarmManager next alarm clock
|
||||
*
|
||||
* @return Next alarm time in milliseconds, or null if no alarm is scheduled
|
||||
*/
|
||||
public Long getNextAlarmTime() {
|
||||
try {
|
||||
// Delegate to NotifyReceiver which checks actual AlarmManager state
|
||||
// Note: NotifyReceiver.getNextAlarmTime is a Kotlin companion object function
|
||||
// Kotlin Long? maps to java.lang.Long in Java
|
||||
return org.timesafari.dailynotification.NotifyReceiver.Companion.getNextAlarmTime(context);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error getting next alarm time", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduling statistics
|
||||
*
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -30,9 +30,9 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
|
||||
import com.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
import com.timesafari.dailynotification.DailyNotificationFetcher;
|
||||
import org.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
|
||||
import org.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
import org.timesafari.dailynotification.DailyNotificationFetcher;
|
||||
|
||||
/**
|
||||
* WorkManager worker for processing daily notifications
|
||||
@@ -127,13 +127,29 @@ public class DailyNotificationWorker extends Worker {
|
||||
try {
|
||||
Log.d(TAG, "DN|DISPLAY_START id=" + notificationId);
|
||||
|
||||
// Check if this is a static reminder (title/body in input data, not storage)
|
||||
Data inputData = getInputData();
|
||||
String scheduleId = inputData.getString("schedule_id");
|
||||
|
||||
// Dual (New Activity): resolve title/body from persisted config + content cache (relationship: contentTimeout, fallbackBehavior)
|
||||
if (scheduleId != null && scheduleId.startsWith(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_PREFIX)) {
|
||||
NotificationContent content = DualScheduleHelper.resolveDualContentBlocking(getApplicationContext(), notificationId);
|
||||
if (content != null) {
|
||||
boolean displayed = displayNotification(content);
|
||||
if (displayed) {
|
||||
Log.i(TAG, "DN|DISPLAY_OK dual id=" + notificationId);
|
||||
return Result.success();
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "DN|DISPLAY_SKIP dual_no_content id=" + notificationId);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// Check if this is a static reminder (title/body in input data, not storage)
|
||||
boolean isStaticReminder = inputData.getBoolean("is_static_reminder", false);
|
||||
NotificationContent content;
|
||||
|
||||
if (isStaticReminder) {
|
||||
// Static reminder: create NotificationContent from input data
|
||||
// Static reminder: create NotificationContent from input data (or resolve from DB by schedule_id)
|
||||
String title = inputData.getString("title");
|
||||
String body = inputData.getString("body");
|
||||
boolean sound = inputData.getBoolean("sound", true);
|
||||
@@ -142,7 +158,17 @@ public class DailyNotificationWorker extends Worker {
|
||||
if (priority == null) {
|
||||
priority = "normal";
|
||||
}
|
||||
|
||||
// Post-reboot/rollover: Intent may lack title/body; resolve from DB by canonical schedule_id
|
||||
if ((title == null || title.isEmpty() || body == null || body.isEmpty()) && scheduleId != null) {
|
||||
NotificationContent canonical = getContentByScheduleId(scheduleId);
|
||||
if (canonical != null && canonical.getTitle() != null && canonical.getBody() != null) {
|
||||
title = canonical.getTitle();
|
||||
body = canonical.getBody();
|
||||
sound = canonical.isSound();
|
||||
priority = canonical.getPriority() != null ? canonical.getPriority() : "normal";
|
||||
Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER_FROM_DB id=" + notificationId + " schedule_id=" + scheduleId);
|
||||
}
|
||||
}
|
||||
if (title == null || body == null) {
|
||||
Log.w(TAG, "DN|DISPLAY_SKIP static_reminder_missing_data id=" + notificationId);
|
||||
return Result.success();
|
||||
@@ -160,25 +186,34 @@ public class DailyNotificationWorker extends Worker {
|
||||
|
||||
Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER id=" + notificationId + " title=" + title);
|
||||
} else {
|
||||
// Regular notification: load from storage
|
||||
// Prefer Room storage; fallback to legacy SharedPreferences storage
|
||||
// Regular notification: load from storage (by notification_id, then by schedule_id for rollover/user content)
|
||||
content = getContentFromRoomOrLegacy(notificationId);
|
||||
|
||||
if (content == null) {
|
||||
// Content not found - likely removed during deduplication or cleanup
|
||||
// Return success instead of failure to prevent retries for intentionally removed notifications
|
||||
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
|
||||
return Result.success(); // Success prevents retry loops for removed notifications
|
||||
// Rollover/notify_* runs: prefer canonical reminder content by schedule_id so user text is shown
|
||||
if (scheduleId != null && (content == null || content.getTitle() == null || content.getTitle().isEmpty()
|
||||
|| content.getBody() == null || content.getBody().isEmpty())) {
|
||||
NotificationContent canonical = getContentByScheduleId(scheduleId);
|
||||
if (canonical != null && canonical.getTitle() != null && canonical.getBody() != null) {
|
||||
content = canonical;
|
||||
content.setId(notificationId); // keep run id for display/dismiss
|
||||
Log.d(TAG, "DN|DISPLAY_USE_CANONICAL id=" + notificationId + " schedule_id=" + scheduleId);
|
||||
}
|
||||
}
|
||||
if (content == null) {
|
||||
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// Check if notification is ready to display
|
||||
if (!content.isReadyToDisplay()) {
|
||||
Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// JIT Freshness Re-check (Soft TTL) - skip for static reminders
|
||||
// JIT Freshness Re-check (Soft TTL) - skip when content has title/body from Room
|
||||
boolean hasTitleBody = content.getTitle() != null && !content.getTitle().isEmpty()
|
||||
&& content.getBody() != null && !content.getBody().isEmpty();
|
||||
if (!hasTitleBody) {
|
||||
content = performJITFreshnessCheck(content);
|
||||
} else {
|
||||
Log.d(TAG, "DN|DISPLAY_USE_ROOM_CONTENT id=" + notificationId + " (skip JIT)");
|
||||
}
|
||||
}
|
||||
|
||||
// Display the notification
|
||||
@@ -361,7 +396,7 @@ public class DailyNotificationWorker extends Worker {
|
||||
|
||||
// Create one-time work request
|
||||
androidx.work.OneTimeWorkRequest softRefetchWork = new androidx.work.OneTimeWorkRequest.Builder(
|
||||
com.timesafari.dailynotification.SoftRefetchWorker.class)
|
||||
org.timesafari.dailynotification.SoftRefetchWorker.class)
|
||||
.setConstraints(constraints)
|
||||
.setInputData(inputData)
|
||||
.setInitialDelay(softRefetchTime - System.currentTimeMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
@@ -448,7 +483,7 @@ public class DailyNotificationWorker extends Worker {
|
||||
// Add action buttons
|
||||
// 1. Dismiss action
|
||||
Intent dismissIntent = new Intent(getApplicationContext(), DailyNotificationReceiver.class);
|
||||
dismissIntent.setAction("com.timesafari.daily.DISMISS");
|
||||
dismissIntent.setAction("org.timesafari.daily.DISMISS");
|
||||
dismissIntent.putExtra("notification_id", content.getId());
|
||||
|
||||
PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
|
||||
@@ -514,8 +549,41 @@ public class DailyNotificationWorker extends Worker {
|
||||
try {
|
||||
Log.d(TAG, "DN|RESCHEDULE_START id=" + content.getId());
|
||||
|
||||
// Calculate next occurrence using DST-safe ZonedDateTime
|
||||
long nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
|
||||
// Resolve schedule_id first so we can load rollover interval from DB
|
||||
Data inputDataForSchedule = getInputData();
|
||||
boolean preserveStaticReminder = inputDataForSchedule.getBoolean("is_static_reminder", false);
|
||||
String scheduleIdForRollover = inputDataForSchedule.getString("schedule_id");
|
||||
if (scheduleIdForRollover == null || scheduleIdForRollover.isEmpty()) {
|
||||
String notificationId = content.getId();
|
||||
if (preserveStaticReminder && notificationId != null && !notificationId.isEmpty()) {
|
||||
scheduleIdForRollover = notificationId;
|
||||
} else if (notificationId != null && notificationId.startsWith("daily_")) {
|
||||
scheduleIdForRollover = notificationId;
|
||||
}
|
||||
}
|
||||
// When firing run used daily_rollover_* id, resolve canonical schedule so we still apply rolloverIntervalMinutes
|
||||
String logicalScheduleIdForRollover = scheduleIdForRollover;
|
||||
if (scheduleIdForRollover != null && scheduleIdForRollover.startsWith("daily_rollover_")) {
|
||||
org.timesafari.dailynotification.Schedule canonical = org.timesafari.dailynotification.ScheduleHelper.getCanonicalRolloverScheduleBlocking(getApplicationContext());
|
||||
if (canonical != null) {
|
||||
logicalScheduleIdForRollover = canonical.getId();
|
||||
}
|
||||
}
|
||||
Integer rolloverMinutes = null;
|
||||
if (logicalScheduleIdForRollover != null && !logicalScheduleIdForRollover.isEmpty()) {
|
||||
org.timesafari.dailynotification.Schedule s = org.timesafari.dailynotification.ScheduleHelper.getScheduleBlocking(getApplicationContext(), logicalScheduleIdForRollover);
|
||||
if (s != null && s.getRolloverIntervalMinutes() != null && s.getRolloverIntervalMinutes() > 0) {
|
||||
rolloverMinutes = s.getRolloverIntervalMinutes();
|
||||
Log.d(TAG, "DN|ROLLOVER_INTERVAL scheduleId=" + logicalScheduleIdForRollover + " minutes=" + rolloverMinutes);
|
||||
}
|
||||
}
|
||||
long nextScheduledTime;
|
||||
if (rolloverMinutes != null && rolloverMinutes > 0) {
|
||||
nextScheduledTime = addMinutesToTime(content.getScheduledTime(), rolloverMinutes);
|
||||
Log.d(TAG, "DN|ROLLOVER_NEXT using_interval_minutes=" + rolloverMinutes + " next=" + nextScheduledTime);
|
||||
} else {
|
||||
nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
|
||||
}
|
||||
|
||||
// Check for existing notification at the same time to prevent duplicates
|
||||
DailyNotificationStorage legacyStorage = new DailyNotificationStorage(getApplicationContext());
|
||||
@@ -540,37 +608,35 @@ public class DailyNotificationWorker extends Worker {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract scheduleId from notificationId pattern or use fallback
|
||||
// Notification IDs are often "daily_${scheduleId}"
|
||||
String scheduleId = null;
|
||||
String cronExpression = null;
|
||||
|
||||
// Try to extract scheduleId from notificationId (e.g., "daily_1764578136269")
|
||||
String notificationId = content.getId();
|
||||
if (notificationId != null && notificationId.startsWith("daily_")) {
|
||||
scheduleId = notificationId; // Use notificationId as scheduleId
|
||||
} else {
|
||||
String scheduleId = scheduleIdForRollover;
|
||||
if (scheduleId == null || scheduleId.isEmpty()) {
|
||||
scheduleId = "daily_rollover_" + System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// Calculate cron from current scheduled time (extract hour:minute)
|
||||
// When using rollover interval, next time already set; otherwise compute from cron (tomorrow same time)
|
||||
if (rolloverMinutes == null || rolloverMinutes <= 0) {
|
||||
try {
|
||||
java.util.Calendar cal = java.util.Calendar.getInstance();
|
||||
cal.setTimeInMillis(content.getScheduledTime());
|
||||
int hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
|
||||
int minute = cal.get(java.util.Calendar.MINUTE);
|
||||
cronExpression = String.format("%d %d * * *", minute, hour);
|
||||
|
||||
// Recalculate next run time from cron (tomorrow at same time)
|
||||
nextScheduledTime = calculateNextRunTimeFromCron(cronExpression);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to calculate cron from scheduled time, using default", e);
|
||||
cronExpression = "0 9 * * *"; // Default to 9 AM
|
||||
cronExpression = "0 9 * * *";
|
||||
}
|
||||
} else {
|
||||
cronExpression = String.format("%d %d * * *",
|
||||
java.util.Calendar.getInstance().get(java.util.Calendar.MINUTE),
|
||||
java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY));
|
||||
}
|
||||
|
||||
// Create config for next notification
|
||||
com.timesafari.dailynotification.UserNotificationConfig config =
|
||||
new com.timesafari.dailynotification.UserNotificationConfig(
|
||||
org.timesafari.dailynotification.UserNotificationConfig config =
|
||||
new org.timesafari.dailynotification.UserNotificationConfig(
|
||||
true, // enabled
|
||||
cronExpression,
|
||||
content.getTitle() != null ? content.getTitle() : "Daily Notification",
|
||||
@@ -581,20 +647,29 @@ public class DailyNotificationWorker extends Worker {
|
||||
);
|
||||
|
||||
// Use centralized scheduling function with ROLLOVER_ON_FIRE source
|
||||
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
Log.d(TAG, "DN|ROLLOVER next=" + nextScheduledTime + " scheduleId=" + scheduleId + " static=" + preserveStaticReminder);
|
||||
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
getApplicationContext(),
|
||||
nextScheduledTime,
|
||||
config,
|
||||
false, // isStaticReminder
|
||||
null, // reminderId
|
||||
preserveStaticReminder, // isStaticReminder – preserve so next run keeps title/body
|
||||
preserveStaticReminder ? scheduleId : null, // reminderId
|
||||
scheduleId,
|
||||
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
|
||||
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
|
||||
false // skipPendingIntentIdempotence – rollover path does not skip
|
||||
);
|
||||
|
||||
if (scheduleId != null && !scheduleId.startsWith("daily_rollover_")) {
|
||||
org.timesafari.dailynotification.ScheduleHelper.updateScheduleNextRunTimeBlocking(
|
||||
getApplicationContext(), scheduleId, content.getScheduledTime(), nextScheduledTime);
|
||||
}
|
||||
// Log next scheduled time in readable format
|
||||
String nextTimeStr = formatScheduledTime(nextScheduledTime);
|
||||
Log.i(TAG, "DN|RESCHEDULE_OK id=" + content.getId() + " next=" + nextTimeStr + " scheduleId=" + scheduleId);
|
||||
|
||||
// Do not schedule prefetch for static reminders (single NotifyReceiver alarm is enough; avoids second alarm)
|
||||
if (preserveStaticReminder) {
|
||||
Log.d(TAG, "DN|RESCHEDULE_SKIP_PREFETCH static_reminder scheduleId=" + scheduleId);
|
||||
} else {
|
||||
// Schedule background fetch for next notification (5 minutes before scheduled time)
|
||||
try {
|
||||
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
|
||||
@@ -604,25 +679,18 @@ public class DailyNotificationWorker extends Worker {
|
||||
storageForFetcher,
|
||||
roomStorageForFetcher
|
||||
);
|
||||
|
||||
// Calculate fetch time (5 minutes before notification)
|
||||
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
|
||||
long currentTime = System.currentTimeMillis();
|
||||
|
||||
if (fetchTime > currentTime) {
|
||||
fetcher.scheduleFetch(fetchTime);
|
||||
Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() +
|
||||
" next_fetch=" + fetchTime +
|
||||
" next_notification=" + nextScheduledTime);
|
||||
Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() + " next_fetch=" + fetchTime + " next_notification=" + nextScheduledTime);
|
||||
} else {
|
||||
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() +
|
||||
" fetch_time=" + fetchTime +
|
||||
" current=" + currentTime);
|
||||
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() + " fetch_time=" + fetchTime + " current=" + currentTime);
|
||||
fetcher.scheduleImmediateFetch();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() +
|
||||
" error scheduling prefetch", e);
|
||||
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() + " error scheduling prefetch", e);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -632,6 +700,28 @@ public class DailyNotificationWorker extends Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load notification content by canonical schedule id (for static reminder / rollover user text).
|
||||
* Tries id then "daily_" + id to match getTitleBodyForSchedule / BootReceiver.
|
||||
*/
|
||||
private NotificationContent getContentByScheduleId(String scheduleId) {
|
||||
if (scheduleId == null || scheduleId.isEmpty()) return null;
|
||||
try {
|
||||
org.timesafari.dailynotification.DailyNotificationDatabase db =
|
||||
org.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
|
||||
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(scheduleId);
|
||||
if (entity == null) {
|
||||
entity = db.notificationContentDao().getNotificationById("daily_" + scheduleId);
|
||||
}
|
||||
if (entity != null) {
|
||||
return mapEntityToContent(entity);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "DN|CANONICAL_READ_FAIL schedule_id=" + scheduleId + " err=" + t.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to load content from Room; fallback to legacy storage
|
||||
*/
|
||||
@@ -640,8 +730,8 @@ public class DailyNotificationWorker extends Worker {
|
||||
try {
|
||||
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
// Use unified database (Kotlin schema with Java entities)
|
||||
com.timesafari.dailynotification.DailyNotificationDatabase db =
|
||||
com.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
|
||||
org.timesafari.dailynotification.DailyNotificationDatabase db =
|
||||
org.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
|
||||
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(notificationId);
|
||||
if (entity != null) {
|
||||
return mapEntityToContent(entity);
|
||||
@@ -688,7 +778,7 @@ public class DailyNotificationWorker extends Worker {
|
||||
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
NotificationContentEntity entity = new NotificationContentEntity(
|
||||
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
|
||||
"1.0.0",
|
||||
"1.2.1",
|
||||
null,
|
||||
"daily",
|
||||
content.getTitle(),
|
||||
@@ -725,6 +815,21 @@ public class DailyNotificationWorker extends Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add minutes to a timestamp (DST-safe via Calendar).
|
||||
* Used for rollover interval (e.g. 10 minutes for testing).
|
||||
*/
|
||||
private long addMinutesToTime(long timeMillis, int minutes) {
|
||||
try {
|
||||
java.util.Calendar cal = java.util.Calendar.getInstance();
|
||||
cal.setTimeInMillis(timeMillis);
|
||||
cal.add(java.util.Calendar.MINUTE, minutes);
|
||||
return cal.getTimeInMillis();
|
||||
} catch (Exception e) {
|
||||
return timeMillis + (minutes * 60 * 1000L);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next scheduled time with DST-safe handling
|
||||
*
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
/**
|
||||
* Information about a scheduled daily reminder
|
||||
@@ -9,7 +9,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -1,15 +1,15 @@
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.*
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.timesafari.dailynotification.entities.NotificationContentEntity
|
||||
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity
|
||||
import com.timesafari.dailynotification.entities.NotificationConfigEntity
|
||||
import com.timesafari.dailynotification.dao.NotificationContentDao
|
||||
import com.timesafari.dailynotification.dao.NotificationDeliveryDao
|
||||
import com.timesafari.dailynotification.dao.NotificationConfigDao
|
||||
import org.timesafari.dailynotification.entities.NotificationContentEntity
|
||||
import org.timesafari.dailynotification.entities.NotificationDeliveryEntity
|
||||
import org.timesafari.dailynotification.entities.NotificationConfigEntity
|
||||
import org.timesafari.dailynotification.dao.NotificationContentDao
|
||||
import org.timesafari.dailynotification.dao.NotificationDeliveryDao
|
||||
import org.timesafari.dailynotification.dao.NotificationConfigDao
|
||||
|
||||
/**
|
||||
* Unified SQLite schema for Daily Notification Plugin
|
||||
@@ -33,7 +33,9 @@ data class ContentCache(
|
||||
val fetchedAt: Long, // epoch ms
|
||||
val ttlSeconds: Int,
|
||||
val payload: ByteArray, // BLOB
|
||||
val meta: String? = null
|
||||
val meta: String? = null,
|
||||
/** dual | daily | legacy — see [ContentCacheScope] */
|
||||
val cacheScope: String = ContentCacheScope.LEGACY
|
||||
)
|
||||
|
||||
@Entity(tableName = "schedules")
|
||||
@@ -47,7 +49,9 @@ data class Schedule(
|
||||
val nextRunAt: Long? = null,
|
||||
val jitterMs: Int = 0,
|
||||
val backoffPolicy: String = "exp",
|
||||
val stateJson: String? = null
|
||||
val stateJson: String? = null,
|
||||
/** When > 0, next occurrence is this many minutes after current trigger (dev/testing). Null or 0 = 24h. */
|
||||
val rolloverIntervalMinutes: Int? = null
|
||||
)
|
||||
|
||||
@Entity(tableName = "callbacks")
|
||||
@@ -83,7 +87,7 @@ data class History(
|
||||
NotificationDeliveryEntity::class,
|
||||
NotificationConfigEntity::class
|
||||
],
|
||||
version = 2, // Incremented for unified schema
|
||||
version = 4, // 4: content_cache.cacheScope
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -118,7 +122,7 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
|
||||
DailyNotificationDatabase::class.java,
|
||||
DATABASE_NAME
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2) // Migration from Kotlin-only to unified
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.addCallback(roomCallback)
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
@@ -266,6 +270,24 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
|
||||
""".trimIndent())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration from version 2 to 3: add rollover_interval_minutes to schedules
|
||||
*/
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE schedules ADD COLUMN rollover_interval_minutes INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
/** Add cacheScope to content_cache for dual vs daily isolation */
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"ALTER TABLE content_cache ADD COLUMN cacheScope TEXT NOT NULL DEFAULT 'legacy'"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +299,9 @@ interface ContentCacheDao {
|
||||
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1")
|
||||
suspend fun getLatest(): ContentCache?
|
||||
|
||||
@Query("SELECT * FROM content_cache WHERE cacheScope = :scope ORDER BY fetchedAt DESC LIMIT 1")
|
||||
suspend fun getLatestByScope(scope: String): ContentCache?
|
||||
|
||||
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT :limit")
|
||||
suspend fun getHistory(limit: Int): List<ContentCache>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.content.Context;
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Re-enqueues dual (New Activity) prefetch from persisted SharedPreferences config
|
||||
* (boot recovery, after a successful dual fetch rollover).
|
||||
*/
|
||||
object DualScheduleFetchRecovery {
|
||||
private const val TAG = "DNP-DUAL-RECOVER"
|
||||
|
||||
/**
|
||||
* Parses [DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY] and enqueues the next delayed dual fetch.
|
||||
* @return true if a job was scheduled
|
||||
*/
|
||||
@JvmStatic
|
||||
fun enqueueFromPersistedConfig(context: Context): Boolean {
|
||||
return try {
|
||||
val prefs = context.getSharedPreferences(
|
||||
DailyNotificationConstants.DUAL_SCHEDULE_PREFS,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val jsonStr = prefs.getString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, null)
|
||||
?: return false
|
||||
val root = JSONObject(jsonStr)
|
||||
val contentFetchObj = root.optJSONObject("contentFetch") ?: return false
|
||||
val userNotificationObj = root.optJSONObject("userNotification") ?: return false
|
||||
val contentFetchConfig = parseContentFetchConfigJson(contentFetchObj)
|
||||
val userNotificationConfig = parseUserNotificationConfigJson(userNotificationObj)
|
||||
if (!contentFetchConfig.enabled) {
|
||||
Log.d(TAG, "contentFetch disabled, skip dual fetch recovery")
|
||||
return false
|
||||
}
|
||||
val nextFetchAt = ScheduleCronUtils.calculateNextRunTimeMillis(contentFetchConfig.schedule)
|
||||
val nextNotifyAt = ScheduleCronUtils.calculateNextRunTimeMillis(userNotificationConfig.schedule)
|
||||
FetchWorker.enqueueDualFetch(
|
||||
context,
|
||||
contentFetchConfig,
|
||||
nextFetchAt,
|
||||
nextNotifyAt
|
||||
)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "enqueueFromPersistedConfig failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseContentFetchConfigJson(configJson: JSONObject): ContentFetchConfig {
|
||||
val callbacksObj = configJson.optJSONObject("callbacks")
|
||||
return ContentFetchConfig(
|
||||
enabled = configJson.optBoolean("enabled", true),
|
||||
schedule = configJson.optString("schedule", "0 9 * * *"),
|
||||
url = configJson.optString("url").takeIf { it.isNotEmpty() },
|
||||
timeout = configJson.takeUnless { !it.has("timeout") || JSONObject.NULL == it.get("timeout") }
|
||||
?.optInt("timeout"),
|
||||
retryAttempts = configJson.takeUnless { !it.has("retryAttempts") || JSONObject.NULL == it.get("retryAttempts") }
|
||||
?.optInt("retryAttempts"),
|
||||
retryDelay = configJson.takeUnless { !it.has("retryDelay") || JSONObject.NULL == it.get("retryDelay") }
|
||||
?.optInt("retryDelay"),
|
||||
callbacks = CallbackConfig(
|
||||
apiService = callbacksObj?.optString("apiService")?.takeIf { it.isNotEmpty() },
|
||||
database = callbacksObj?.optString("database")?.takeIf { it.isNotEmpty() },
|
||||
reporting = callbacksObj?.optString("reporting")?.takeIf { it.isNotEmpty() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseUserNotificationConfigJson(configJson: JSONObject): UserNotificationConfig {
|
||||
return UserNotificationConfig(
|
||||
enabled = configJson.optBoolean("enabled", true),
|
||||
schedule = configJson.optString("schedule", "0 9 * * *"),
|
||||
title = configJson.optString("title").takeIf { it.isNotEmpty() },
|
||||
body = configJson.optString("body").takeIf { it.isNotEmpty() },
|
||||
sound = if (configJson.has("sound") && JSONObject.NULL != configJson.get("sound")) configJson.optBoolean("sound") else null,
|
||||
vibration = if (configJson.has("vibration") && JSONObject.NULL != configJson.get("vibration")) configJson.optBoolean("vibration") else null,
|
||||
priority = configJson.optString("priority").takeIf { it.isNotEmpty() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Helper for resolving dual (New Activity) notification content at fire time.
|
||||
* Applies relationship (contentTimeout, fallbackBehavior) using persisted config and content cache.
|
||||
*/
|
||||
object DualScheduleHelper {
|
||||
private const val TAG = "DNP-DUAL"
|
||||
|
||||
/**
|
||||
* Resolve title/body for a dual schedule: use cached content if within contentTimeout, else default from config.
|
||||
* Call from Worker when schedule_id starts with DUAL_NOTIFY_SCHEDULE_ID_PREFIX.
|
||||
*
|
||||
* @param context Application context
|
||||
* @param notificationId Notification run id for the display
|
||||
* @return NotificationContent with resolved title/body, or null if no config or skip
|
||||
*/
|
||||
@JvmStatic
|
||||
fun resolveDualContentBlocking(context: Context, notificationId: String): NotificationContent? {
|
||||
return try {
|
||||
val prefs = context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
val configStr = prefs.getString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, null) ?: return null
|
||||
val config = JSONObject(configStr)
|
||||
val userNotification = config.optJSONObject("userNotification") ?: return null
|
||||
val relationship = config.optJSONObject("relationship")
|
||||
val contentTimeoutMs = relationship?.optLong("contentTimeout", 300_000L) ?: 300_000L
|
||||
val fallbackBehavior = relationship?.optString("fallbackBehavior", "show_default") ?: "show_default"
|
||||
|
||||
val defaultTitle = userNotification.optString("title", "Daily Notification")
|
||||
val defaultBody = userNotification.optString("body", "Your daily update is ready")
|
||||
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val latestCache = runBlocking {
|
||||
db.contentCacheDao().getLatestByScope(ContentCacheScope.DUAL)
|
||||
}
|
||||
val nowMs = System.currentTimeMillis()
|
||||
|
||||
val (title: String, body: String) = if (latestCache != null && (nowMs - latestCache.fetchedAt) <= contentTimeoutMs) {
|
||||
val payloadStr = String(latestCache.payload, Charsets.UTF_8)
|
||||
try {
|
||||
val payload = JSONObject(payloadStr)
|
||||
if (payload.optBoolean("skipNotification", false)) {
|
||||
return null
|
||||
}
|
||||
Pair(
|
||||
payload.optString("title", defaultTitle),
|
||||
payload.optString("body", payload.optString("content", defaultBody))
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
Pair(defaultTitle, defaultBody)
|
||||
}
|
||||
} else {
|
||||
val staleSkip = latestCache?.let { cache ->
|
||||
try {
|
||||
JSONObject(String(cache.payload, Charsets.UTF_8)).optBoolean("skipNotification", false)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
if (staleSkip && fallbackBehavior == "skip") {
|
||||
return null
|
||||
}
|
||||
if (fallbackBehavior != "show_default") return null
|
||||
Pair(defaultTitle, defaultBody)
|
||||
}
|
||||
|
||||
val content = NotificationContent(title, body, nowMs)
|
||||
content.setId(notificationId)
|
||||
content.setSound(userNotification.optBoolean("sound", true))
|
||||
content.setPriority(userNotification.optString("priority", "normal"))
|
||||
Log.d(TAG, "Resolved dual content: useCache=${latestCache != null && (nowMs - latestCache.fetchedAt) <= contentTimeoutMs}")
|
||||
content
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "resolveDualContentBlocking failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Arms the dual (New Activity) user notification **after** prefetch completes (chained schedule).
|
||||
* Fires at [max]([nextNotifyAtMillis], now) so a late fetch delays delivery instead of showing stale API copy first.
|
||||
*/
|
||||
object DualScheduleNotifyScheduler {
|
||||
private const val TAG = "DNP-DUAL-NOTIFY"
|
||||
|
||||
/**
|
||||
* Schedule exact alarm for dual notify using persisted [DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY]
|
||||
* and [DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun scheduleChainedNotifyAlarm(context: Context, nextNotifyAtMillis: Long) {
|
||||
try {
|
||||
val prefs = context.getSharedPreferences(
|
||||
DailyNotificationConstants.DUAL_SCHEDULE_PREFS,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val configStr = prefs.getString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, null)
|
||||
?: run {
|
||||
Log.w(TAG, "No dual_schedule_config; skip chained notify")
|
||||
return
|
||||
}
|
||||
val scheduleId = prefs.getString(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY, null)
|
||||
?: run {
|
||||
Log.w(TAG, "No dual_notify_schedule_id; skip chained notify")
|
||||
return
|
||||
}
|
||||
val root = JSONObject(configStr)
|
||||
val userObj = root.optJSONObject("userNotification") ?: run {
|
||||
Log.w(TAG, "dual config missing userNotification")
|
||||
return
|
||||
}
|
||||
val config = parseUserNotificationConfig(userObj)
|
||||
val now = System.currentTimeMillis()
|
||||
val triggerAt = maxOf(nextNotifyAtMillis, now + 500L)
|
||||
Log.i(TAG, "Chained dual notify: scheduleId=$scheduleId triggerAt=$triggerAt (nextNotify=$nextNotifyAtMillis)")
|
||||
NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
triggerAt,
|
||||
config,
|
||||
scheduleId = scheduleId,
|
||||
source = ScheduleSource.INITIAL_SETUP,
|
||||
skipPendingIntentIdempotence = true
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "scheduleChainedNotifyAlarm failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseUserNotificationConfig(configJson: JSONObject): UserNotificationConfig {
|
||||
return UserNotificationConfig(
|
||||
enabled = configJson.optBoolean("enabled", true),
|
||||
schedule = configJson.optString("schedule", "0 9 * * *"),
|
||||
title = configJson.optString("title").takeIf { it.isNotEmpty() },
|
||||
body = configJson.optString("body").takeIf { it.isNotEmpty() },
|
||||
sound = if (configJson.has("sound") && JSONObject.NULL != configJson.get("sound")) {
|
||||
configJson.optBoolean("sound")
|
||||
} else {
|
||||
null
|
||||
},
|
||||
vibration = if (configJson.has("vibration") && JSONObject.NULL != configJson.get("vibration")) {
|
||||
configJson.optBoolean("vibration")
|
||||
} else {
|
||||
null
|
||||
},
|
||||
priority = configJson.optString("priority").takeIf { it.isNotEmpty() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* @created 2025-10-03 06:53:30 UTC
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -11,7 +11,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -1,15 +1,16 @@
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.text.Charsets
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
@@ -17,7 +18,7 @@ import org.json.JSONObject
|
||||
* Implements exponential backoff and network constraints
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.1.0
|
||||
* @version 2.0.0
|
||||
*/
|
||||
class FetchWorker(
|
||||
appContext: Context,
|
||||
@@ -27,12 +28,85 @@ class FetchWorker(
|
||||
companion object {
|
||||
private const val TAG = "DNP-FETCH"
|
||||
private const val WORK_NAME = "fetch_content"
|
||||
/** Unique work name for dual (New Activity) schedule fetch; cancel via cancelDualSchedule only. */
|
||||
const val WORK_NAME_DUAL = "fetch_dual"
|
||||
|
||||
private const val KEY_IS_DUAL = "is_dual"
|
||||
private const val KEY_CACHE_SCOPE = "cache_scope"
|
||||
private const val KEY_NEXT_NOTIFY_AT = "next_notify_at"
|
||||
|
||||
/**
|
||||
* Persisted for dual native fetch when the host returns no rows.
|
||||
* [DualScheduleHelper] must not display a notification; [doWork] skips chained notify.
|
||||
*/
|
||||
internal val dualEmptyNativeFetchPayload: ByteArray =
|
||||
"""{"skipNotification":true}""".toByteArray(Charsets.UTF_8)
|
||||
|
||||
/** True when [payload] is the dual-cache sentinel for “API / native had no content”. */
|
||||
@JvmStatic
|
||||
fun isDualSkipNotificationPayload(payload: ByteArray): Boolean {
|
||||
return try {
|
||||
JSONObject(String(payload, Charsets.UTF_8)).optBoolean("skipNotification", false)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleFetch(context: Context, config: ContentFetchConfig) {
|
||||
enqueueFetch(context, config, WORK_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual (New Activity) prefetch: delayed to [nextFetchAtMillis], scoped cache, optional native fetcher.
|
||||
*/
|
||||
fun enqueueDualFetch(
|
||||
context: Context,
|
||||
contentFetchConfig: ContentFetchConfig,
|
||||
nextFetchAtMillis: Long,
|
||||
nextNotifyAtMillis: Long
|
||||
) {
|
||||
val now = System.currentTimeMillis()
|
||||
val delayMs = (nextFetchAtMillis - now).coerceAtLeast(0L)
|
||||
val requiresNetwork = !contentFetchConfig.url.isNullOrBlank() ||
|
||||
DailyNotificationPlugin.getNativeFetcherStatic() != null
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(
|
||||
if (requiresNetwork) NetworkType.CONNECTED else NetworkType.NOT_REQUIRED
|
||||
)
|
||||
.build()
|
||||
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
30,
|
||||
TimeUnit.SECONDS
|
||||
)
|
||||
.setInputData(
|
||||
Data.Builder()
|
||||
.putString("url", contentFetchConfig.url)
|
||||
.putInt("timeout", contentFetchConfig.timeout ?: 30000)
|
||||
.putInt("retryAttempts", contentFetchConfig.retryAttempts ?: 3)
|
||||
.putInt("retryDelay", contentFetchConfig.retryDelay ?: 1000)
|
||||
.putLong("notificationTime", 0L)
|
||||
.putBoolean(KEY_IS_DUAL, true)
|
||||
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DUAL)
|
||||
.putLong(KEY_NEXT_NOTIFY_AT, nextNotifyAtMillis)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(WORK_NAME_DUAL, ExistingWorkPolicy.REPLACE, workRequest)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Dual fetch enqueued: delayMs=$delayMs, nextFetchAt=$nextFetchAtMillis, nextNotifyAt=$nextNotifyAtMillis"
|
||||
)
|
||||
}
|
||||
|
||||
private fun enqueueFetch(context: Context, config: ContentFetchConfig, workName: String) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
@@ -46,16 +120,13 @@ class FetchWorker(
|
||||
.putInt("timeout", config.timeout ?: 30000)
|
||||
.putInt("retryAttempts", config.retryAttempts ?: 3)
|
||||
.putInt("retryDelay", config.retryDelay ?: 1000)
|
||||
.putBoolean(KEY_IS_DUAL, false)
|
||||
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(
|
||||
WORK_NAME,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest
|
||||
)
|
||||
.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,6 +186,8 @@ class FetchWorker(
|
||||
.putInt("timeout", 30000)
|
||||
.putInt("retryAttempts", 3)
|
||||
.putInt("retryDelay", 1000)
|
||||
.putBoolean(KEY_IS_DUAL, false)
|
||||
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
|
||||
.build()
|
||||
)
|
||||
.addTag("prefetch")
|
||||
@@ -160,6 +233,8 @@ class FetchWorker(
|
||||
.putInt("retryAttempts", 3)
|
||||
.putInt("retryDelay", 1000)
|
||||
.putBoolean("immediate", true)
|
||||
.putBoolean(KEY_IS_DUAL, false)
|
||||
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
|
||||
.build()
|
||||
)
|
||||
.addTag("prefetch")
|
||||
@@ -179,17 +254,23 @@ class FetchWorker(
|
||||
val retryAttempts = inputData.getInt("retryAttempts", 3)
|
||||
val retryDelay = inputData.getInt("retryDelay", 1000)
|
||||
val notificationTime = inputData.getLong("notificationTime", 0L)
|
||||
val isDual = inputData.getBoolean(KEY_IS_DUAL, false)
|
||||
val cacheScope = inputData.getString(KEY_CACHE_SCOPE) ?: ContentCacheScope.LEGACY
|
||||
val nextNotifyAt = inputData.getLong(KEY_NEXT_NOTIFY_AT, 0L)
|
||||
|
||||
try {
|
||||
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime")
|
||||
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime, isDual=$isDual, scope=$cacheScope")
|
||||
|
||||
val payload = fetchContent(url, timeout, retryAttempts, retryDelay)
|
||||
val payload = resolvePayload(url, timeout, retryAttempts, retryDelay, isDual, nextNotifyAt)
|
||||
val skipDualChainedNotify =
|
||||
isDual && nextNotifyAt > 0L && isDualSkipNotificationPayload(payload)
|
||||
val contentCache = ContentCache(
|
||||
id = generateId(),
|
||||
fetchedAt = System.currentTimeMillis(),
|
||||
ttlSeconds = 3600, // 1 hour default TTL
|
||||
payload = payload,
|
||||
meta = "fetched_by_workmanager"
|
||||
meta = "fetched_by_workmanager",
|
||||
cacheScope = cacheScope
|
||||
)
|
||||
|
||||
// Store in database
|
||||
@@ -203,9 +284,9 @@ class FetchWorker(
|
||||
val notificationId = "notify_$notificationTime"
|
||||
val (title, body) = parsePayload(payload)
|
||||
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.0.2", // Plugin version
|
||||
"2.1.0", // Plugin version
|
||||
null, // timesafariDid - can be set if available
|
||||
"daily",
|
||||
title,
|
||||
@@ -242,20 +323,89 @@ class FetchWorker(
|
||||
)
|
||||
|
||||
Log.i(TAG, "Content fetch completed successfully")
|
||||
if (isDual && nextNotifyAt > 0L) {
|
||||
if (!skipDualChainedNotify) {
|
||||
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
|
||||
} else {
|
||||
Log.i(TAG, "Dual fetch: empty native content — skip chained notify")
|
||||
}
|
||||
DualScheduleFetchRecovery.enqueueFromPersistedConfig(applicationContext)
|
||||
}
|
||||
Result.success()
|
||||
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Network error during fetch", e)
|
||||
recordFailure("network_error", start, e)
|
||||
Result.retry()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unexpected error during fetch", e)
|
||||
recordFailure("unexpected_error", start, e)
|
||||
if (isDual && nextNotifyAt > 0L) {
|
||||
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
|
||||
}
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolvePayload(
|
||||
url: String?,
|
||||
timeout: Int,
|
||||
retryAttempts: Int,
|
||||
retryDelay: Int,
|
||||
isDual: Boolean,
|
||||
nextNotifyAt: Long
|
||||
): ByteArray {
|
||||
if (isDual && url.isNullOrBlank()) {
|
||||
val native = DailyNotificationPlugin.getNativeFetcherStatic()
|
||||
return if (native != null) {
|
||||
fetchNativeDualPayload(native, timeout, nextNotifyAt)
|
||||
} else {
|
||||
Log.w(TAG, "Dual fetch with no URL and no native fetcher; using mock content")
|
||||
generateMockContent()
|
||||
}
|
||||
}
|
||||
return fetchContent(url, timeout, retryAttempts, retryDelay)
|
||||
}
|
||||
|
||||
private suspend fun fetchNativeDualPayload(
|
||||
native: NativeNotificationContentFetcher,
|
||||
timeoutMs: Int,
|
||||
nextNotifyAtMillis: Long
|
||||
): ByteArray = withContext(Dispatchers.IO) {
|
||||
val metadata = java.util.HashMap<String, Any>()
|
||||
metadata["retryCount"] = 0
|
||||
metadata["immediate"] = false
|
||||
val scheduledTime: Long? = if (nextNotifyAtMillis > 0L) nextNotifyAtMillis else null
|
||||
val ctx = FetchContext(
|
||||
"prefetch",
|
||||
scheduledTime,
|
||||
System.currentTimeMillis(),
|
||||
metadata
|
||||
)
|
||||
val future = native.fetchContent(ctx)
|
||||
try {
|
||||
val contents = future.get(timeoutMs.toLong(), TimeUnit.MILLISECONDS)
|
||||
val list = contents?.toList() ?: emptyList()
|
||||
notificationContentsToDualPayloadBytes(list)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Native dual fetch failed", e)
|
||||
throw IOException("native_fetch_failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationContentsToDualPayloadBytes(contents: List<NotificationContent>): ByteArray {
|
||||
if (contents.isEmpty()) {
|
||||
return dualEmptyNativeFetchPayload
|
||||
}
|
||||
val c = contents[0]
|
||||
val title = c.getTitle() ?: "Daily Notification"
|
||||
val body = c.getBody() ?: ""
|
||||
val json = JSONObject()
|
||||
json.put("title", title)
|
||||
json.put("body", body)
|
||||
json.put("content", body)
|
||||
return json.toString().toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private suspend fun fetchContent(
|
||||
url: String?,
|
||||
timeout: Int,
|
||||
@@ -282,7 +432,6 @@ class FetchWorker(
|
||||
} else {
|
||||
throw IOException("HTTP $responseCode: ${connection.responseMessage}")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
if (attempt < retryAttempts - 1) {
|
||||
@@ -301,7 +450,7 @@ class FetchWorker(
|
||||
"timestamp": ${System.currentTimeMillis()},
|
||||
"content": "Daily notification content",
|
||||
"source": "mock_generator",
|
||||
"version": "1.1.0"
|
||||
"version": "2.1.4"
|
||||
}
|
||||
""".trimIndent()
|
||||
return mockData.toByteArray()
|
||||
@@ -15,9 +15,10 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@@ -142,5 +143,21 @@ public interface NativeNotificationContentFetcher {
|
||||
// This allows fetchers that don't need TypeScript-provided configuration
|
||||
// to ignore this method without implementing an empty body.
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional overload: distinct JWT strings for background use (e.g. one per day slot).
|
||||
* Persisted when {@code persistToken} is true; host fetchers (e.g. TimeSafari) should choose
|
||||
* which entry to use per fetch (e.g. day index modulo pool size).
|
||||
* Default delegates to {@link #configure(String, String, String)} so existing fetchers stay compatible.
|
||||
*
|
||||
* @param jwtTokenPool validated list (max length 128), or null if the host omitted the pool or sent an empty array
|
||||
*/
|
||||
default void configure(
|
||||
String apiBaseUrl,
|
||||
String activeDid,
|
||||
String jwtToken,
|
||||
@Nullable List<String> jwtTokenPool) {
|
||||
configure(apiBaseUrl, activeDid, jwtToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.util.Log;
|
||||
import java.util.UUID;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
@@ -16,6 +16,8 @@ import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
|
||||
/**
|
||||
@@ -54,6 +56,7 @@ public class NotificationStatusChecker {
|
||||
// Core permissions
|
||||
boolean postNotificationsGranted = checkPostNotificationsPermission();
|
||||
boolean exactAlarmsGranted = checkExactAlarmsPermission();
|
||||
boolean notificationsEnabledAtOsLevel = checkNotificationsEnabledAtOsLevel();
|
||||
|
||||
// Channel status
|
||||
boolean channelEnabled = channelManager.isChannelEnabled();
|
||||
@@ -63,14 +66,16 @@ public class NotificationStatusChecker {
|
||||
// Alarm manager status
|
||||
PendingIntentManager.AlarmStatus alarmStatus = pendingIntentManager.getAlarmStatus();
|
||||
|
||||
// Overall readiness
|
||||
// Overall readiness - all requirements must be met
|
||||
boolean canScheduleNow = postNotificationsGranted &&
|
||||
channelEnabled &&
|
||||
exactAlarmsGranted;
|
||||
exactAlarmsGranted &&
|
||||
notificationsEnabledAtOsLevel;
|
||||
|
||||
// Build status object
|
||||
status.put("postNotificationsGranted", postNotificationsGranted);
|
||||
status.put("exactAlarmsGranted", exactAlarmsGranted);
|
||||
status.put("notificationsEnabledAtOsLevel", notificationsEnabledAtOsLevel);
|
||||
status.put("channelEnabled", channelEnabled);
|
||||
status.put("channelImportance", channelImportance);
|
||||
status.put("channelId", channelId);
|
||||
@@ -83,6 +88,9 @@ public class NotificationStatusChecker {
|
||||
if (!postNotificationsGranted) {
|
||||
issues.put("postNotifications", "POST_NOTIFICATIONS permission not granted");
|
||||
}
|
||||
if (!notificationsEnabledAtOsLevel) {
|
||||
issues.put("osNotificationsDisabled", "Notifications disabled at OS level");
|
||||
}
|
||||
if (!channelEnabled) {
|
||||
issues.put("channelDisabled", "Notification channel is disabled or blocked");
|
||||
}
|
||||
@@ -96,6 +104,9 @@ public class NotificationStatusChecker {
|
||||
if (!postNotificationsGranted) {
|
||||
guidance.put("postNotifications", "Request notification permission in app settings");
|
||||
}
|
||||
if (!notificationsEnabledAtOsLevel) {
|
||||
guidance.put("osNotificationsDisabled", "Enable notifications in system settings");
|
||||
}
|
||||
if (!channelEnabled) {
|
||||
guidance.put("channelDisabled", "Enable notifications in system settings");
|
||||
}
|
||||
@@ -124,24 +135,56 @@ public class NotificationStatusChecker {
|
||||
|
||||
/**
|
||||
* Check POST_NOTIFICATIONS permission status
|
||||
* Always checks OS-level notification enablement for all API levels
|
||||
*
|
||||
* @return true if permission is granted, false otherwise
|
||||
* @return true if permission is granted AND notifications enabled at OS level, false otherwise
|
||||
*/
|
||||
private boolean checkPostNotificationsPermission() {
|
||||
try {
|
||||
boolean permissionGranted = false;
|
||||
boolean osLevelEnabled = false;
|
||||
|
||||
// Check POST_NOTIFICATIONS permission (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
return context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
permissionGranted = context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
} else {
|
||||
// Pre-Android 13, notifications are allowed by default
|
||||
return true;
|
||||
// Pre-Android 13: permission granted at install time
|
||||
permissionGranted = true;
|
||||
}
|
||||
|
||||
// Always check OS-level notification enablement (critical for all API levels)
|
||||
osLevelEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled();
|
||||
|
||||
// Both must be true
|
||||
boolean result = permissionGranted && osLevelEnabled;
|
||||
|
||||
if (!osLevelEnabled && permissionGranted) {
|
||||
Log.w(TAG, "DN|PERM_CHECK_WARN Permission granted but OS-level notifications disabled");
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "DN|PERM_CHECK_ERR postNotifications err=" + e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notifications are enabled at OS level
|
||||
* Separate check from permission check - users can disable at OS level even with permission
|
||||
*
|
||||
* @return true if notifications enabled at OS level, false otherwise
|
||||
*/
|
||||
private boolean checkNotificationsEnabledAtOsLevel() {
|
||||
try {
|
||||
return NotificationManagerCompat.from(context).areNotificationsEnabled();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "DN|OS_CHECK_ERR err=" + e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check SCHEDULE_EXACT_ALARM permission status
|
||||
*
|
||||
@@ -294,19 +337,25 @@ public class NotificationStatusChecker {
|
||||
|
||||
/**
|
||||
* Check if the notification system is ready to schedule notifications
|
||||
* Includes OS-level notification enablement check
|
||||
*
|
||||
* @return true if ready, false otherwise
|
||||
*/
|
||||
public boolean isReadyToSchedule() {
|
||||
try {
|
||||
boolean postNotificationsGranted = checkPostNotificationsPermission();
|
||||
boolean notificationsEnabledAtOsLevel = checkNotificationsEnabledAtOsLevel();
|
||||
boolean channelEnabled = channelManager.isChannelEnabled();
|
||||
boolean exactAlarmsGranted = checkExactAlarmsPermission();
|
||||
|
||||
boolean ready = postNotificationsGranted && channelEnabled && exactAlarmsGranted;
|
||||
boolean ready = postNotificationsGranted &&
|
||||
notificationsEnabledAtOsLevel &&
|
||||
channelEnabled &&
|
||||
exactAlarmsGranted;
|
||||
|
||||
Log.d(TAG, "DN|READY_CHECK ready=" + ready +
|
||||
" postGranted=" + postNotificationsGranted +
|
||||
" osEnabled=" + notificationsEnabledAtOsLevel +
|
||||
" channelEnabled=" + channelEnabled +
|
||||
" exactGranted=" + exactAlarmsGranted);
|
||||
|
||||
@@ -318,8 +367,113 @@ public class NotificationStatusChecker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive readiness report with issue codes and fix actions
|
||||
*
|
||||
* Returns a structured report with:
|
||||
* - Individual requirement booleans
|
||||
* - List of issues with stable codes, human messages, and fix actions
|
||||
* - Deep link suggestions for fixing issues
|
||||
*
|
||||
* @return JSObject containing readiness report
|
||||
*/
|
||||
public JSObject getReadinessReport() {
|
||||
try {
|
||||
Log.d(TAG, "DN|READINESS_REPORT_START");
|
||||
|
||||
JSObject report = new JSObject();
|
||||
|
||||
// Check all requirements
|
||||
boolean postNotificationsGranted = false;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
postNotificationsGranted = context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
} else {
|
||||
postNotificationsGranted = true; // Pre-Android 13: granted at install
|
||||
}
|
||||
|
||||
boolean notificationsEnabledAtOsLevel = checkNotificationsEnabledAtOsLevel();
|
||||
boolean channelEnabled = channelManager.isChannelEnabled();
|
||||
boolean exactAlarmsGranted = checkExactAlarmsPermission();
|
||||
|
||||
// Overall readiness
|
||||
boolean canScheduleNow = postNotificationsGranted &&
|
||||
notificationsEnabledAtOsLevel &&
|
||||
channelEnabled &&
|
||||
exactAlarmsGranted;
|
||||
|
||||
// Build requirements object
|
||||
JSObject requirements = new JSObject();
|
||||
requirements.put("postNotificationsGranted", postNotificationsGranted);
|
||||
requirements.put("notificationsEnabledAtOsLevel", notificationsEnabledAtOsLevel);
|
||||
requirements.put("channelEnabled", channelEnabled);
|
||||
requirements.put("exactAlarmsGranted", exactAlarmsGranted);
|
||||
requirements.put("canScheduleNow", canScheduleNow);
|
||||
|
||||
report.put("requirements", requirements);
|
||||
|
||||
// Build issues array with codes, messages, and fix actions
|
||||
com.getcapacitor.JSArray issuesArray = new com.getcapacitor.JSArray();
|
||||
|
||||
if (!postNotificationsGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
JSObject issue = new JSObject();
|
||||
issue.put("code", "POST_NOTIFICATIONS_DENIED");
|
||||
issue.put("humanMessage", "Notification permission not granted");
|
||||
issue.put("fixAction", "Request notification permission in app settings");
|
||||
issue.put("deepLink", "app://settings/notifications");
|
||||
issuesArray.put(issue);
|
||||
}
|
||||
|
||||
if (!notificationsEnabledAtOsLevel) {
|
||||
JSObject issue = new JSObject();
|
||||
issue.put("code", "OS_NOTIFICATIONS_DISABLED");
|
||||
issue.put("humanMessage", "Notifications disabled at system level");
|
||||
issue.put("fixAction", "Enable notifications in system settings");
|
||||
issue.put("deepLink", "android.settings.ACTION_APP_NOTIFICATION_SETTINGS");
|
||||
issuesArray.put(issue);
|
||||
}
|
||||
|
||||
if (!channelEnabled) {
|
||||
JSObject issue = new JSObject();
|
||||
issue.put("code", "CHANNEL_DISABLED");
|
||||
issue.put("humanMessage", "Notification channel is disabled or blocked");
|
||||
issue.put("fixAction", "Enable notification channel in system settings");
|
||||
issue.put("deepLink", "android.settings.CHANNEL_NOTIFICATION_SETTINGS");
|
||||
issuesArray.put(issue);
|
||||
}
|
||||
|
||||
if (!exactAlarmsGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
JSObject issue = new JSObject();
|
||||
issue.put("code", "EXACT_ALARMS_DENIED");
|
||||
issue.put("humanMessage", "Exact alarm permission not granted");
|
||||
issue.put("fixAction", "Grant 'Alarms & reminders' permission in system settings");
|
||||
issue.put("deepLink", "android.settings.REQUEST_SCHEDULE_EXACT_ALARM");
|
||||
issuesArray.put(issue);
|
||||
}
|
||||
|
||||
report.put("issues", issuesArray);
|
||||
report.put("issueCount", issuesArray.length());
|
||||
report.put("canScheduleNow", canScheduleNow);
|
||||
|
||||
Log.d(TAG, "DN|READINESS_REPORT_OK canSchedule=" + canScheduleNow +
|
||||
" issues=" + issuesArray.length());
|
||||
|
||||
return report;
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "DN|READINESS_REPORT_ERR err=" + e.getMessage(), e);
|
||||
|
||||
JSObject errorReport = new JSObject();
|
||||
errorReport.put("canScheduleNow", false);
|
||||
errorReport.put("error", e.getMessage());
|
||||
errorReport.put("issues", new com.getcapacitor.JSArray());
|
||||
return errorReport;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of issues preventing notification scheduling
|
||||
* Includes OS-level notification enablement check
|
||||
*
|
||||
* @return Array of issue descriptions
|
||||
*/
|
||||
@@ -331,6 +485,10 @@ public class NotificationStatusChecker {
|
||||
issues.add("POST_NOTIFICATIONS permission not granted");
|
||||
}
|
||||
|
||||
if (!checkNotificationsEnabledAtOsLevel()) {
|
||||
issues.add("Notifications disabled at OS level");
|
||||
}
|
||||
|
||||
if (!channelManager.isChannelEnabled()) {
|
||||
issues.add("Notification channel is disabled or blocked");
|
||||
}
|
||||
@@ -346,4 +504,37 @@ public class NotificationStatusChecker {
|
||||
return new String[]{"Error checking status: " + e.getMessage()};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification status information (schedules and history)
|
||||
*
|
||||
* This method delegates to a Kotlin helper function that handles the async
|
||||
* database operations. The helper is defined in DailyNotificationPlugin.kt
|
||||
* as a suspend function, so this Java method uses runBlocking to call it.
|
||||
*
|
||||
* Note: This method should typically be called from Kotlin code within a
|
||||
* coroutine scope. The plugin method handles the coroutine context.
|
||||
*
|
||||
* @param database Database instance for querying schedules and history
|
||||
* @return JSObject containing notification status (schedules, last notification time, etc.)
|
||||
*/
|
||||
public JSObject getNotificationStatus(org.timesafari.dailynotification.DailyNotificationDatabase database) {
|
||||
try {
|
||||
Log.d(TAG, "DN|NOTIFICATION_STATUS_START");
|
||||
|
||||
// Delegate to Kotlin helper function (uses runBlocking internally)
|
||||
// This is safe because status checks are quick operations
|
||||
return org.timesafari.dailynotification.NotificationStatusHelper.getNotificationStatusBlocking(database);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "DN|NOTIFICATION_STATUS_ERR err=" + e.getMessage(), e);
|
||||
|
||||
JSObject errorStatus = new JSObject();
|
||||
errorStatus.put("error", e.getMessage());
|
||||
errorStatus.put("isEnabled", false);
|
||||
errorStatus.put("isScheduled", false);
|
||||
errorStatus.put("scheduledCount", 0);
|
||||
return errorStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.AlarmManager.AlarmClockInfo
|
||||
@@ -21,7 +21,7 @@ import kotlinx.coroutines.runBlocking
|
||||
* Implements TTL-at-fire logic and notification delivery
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.1.0
|
||||
* @version 2.0.0
|
||||
*/
|
||||
/**
|
||||
* Source of schedule request - tracks which code path triggered scheduling
|
||||
@@ -122,6 +122,10 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
* @param reminderId Optional reminder ID for tracking (used as scheduleId if provided)
|
||||
* @param scheduleId Stable identifier for the schedule (used for requestCode stability)
|
||||
* @param source Source of the scheduling request (for debugging duplicate alarms)
|
||||
* @param skipPendingIntentIdempotence If true, skip PendingIntent-based idempotence checks.
|
||||
* Use when the caller has just cancelled this scheduleId (cancel-then-schedule path).
|
||||
* Android may still return the cancelled PendingIntent from cache briefly, which would
|
||||
* incorrectly cause the new schedule to be skipped.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun scheduleExactNotification(
|
||||
@@ -131,7 +135,8 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
isStaticReminder: Boolean = false,
|
||||
reminderId: String? = null,
|
||||
scheduleId: String? = null,
|
||||
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE
|
||||
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE,
|
||||
skipPendingIntentIdempotence: Boolean = false
|
||||
) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
@@ -142,14 +147,16 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
// Generate notification ID (use reminderId if provided, otherwise generate from trigger time)
|
||||
val notificationId = reminderId ?: "notify_${triggerAtMillis}"
|
||||
|
||||
// IDEMPOTENCE CHECK: Verify no existing alarm for this trigger time before scheduling
|
||||
// This prevents duplicate alarms when multiple scheduling paths race
|
||||
// Strategy: Check both by scheduleId (stable) and by trigger time (catches different scheduleIds for same time)
|
||||
val requestCode = getRequestCode(stableScheduleId)
|
||||
val checkIntent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
val checkIntent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
setPackage(context.packageName)
|
||||
action = "org.timesafari.daily.NOTIFICATION"
|
||||
}
|
||||
|
||||
// IDEMPOTENCE CHECK: Verify no existing alarm for this trigger time before scheduling.
|
||||
// Skip PendingIntent checks when caller just cancelled this schedule (Android may still
|
||||
// return the cancelled PendingIntent from cache and cause the new schedule to be skipped).
|
||||
if (!skipPendingIntentIdempotence) {
|
||||
// Check 1: Same scheduleId (stable requestCode) - most reliable
|
||||
var existingPendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
@@ -159,8 +166,6 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
)
|
||||
|
||||
// Check 2: If no match by scheduleId, check by trigger time (within 1 minute tolerance)
|
||||
// This catches cases where different scheduleIds are used for the same time
|
||||
// Try a range of request codes around the trigger time
|
||||
if (existingPendingIntent == null) {
|
||||
val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis)
|
||||
existingPendingIntent = PendingIntent.getBroadcast(
|
||||
@@ -171,15 +176,12 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
)
|
||||
}
|
||||
|
||||
// Check 3: Also check if AlarmManager already has an alarm for this exact time
|
||||
// This is a fallback for when PendingIntent checks fail but alarm still exists
|
||||
// We check the next alarm clock time (Android 5.0+)
|
||||
// Check 3: AlarmManager next alarm (Android 5.0+)
|
||||
if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
val nextAlarm = alarmManager.nextAlarmClock
|
||||
if (nextAlarm != null) {
|
||||
val nextAlarmTime = nextAlarm.triggerTime
|
||||
val timeDiff = Math.abs(nextAlarmTime - triggerAtMillis)
|
||||
// If there's an alarm within 1 minute of our target time, consider it a duplicate
|
||||
if (timeDiff < 60000) {
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(triggerAtMillis))
|
||||
@@ -191,15 +193,26 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
if (existingPendingIntent != null) {
|
||||
if (source == ScheduleSource.ROLLOVER_ON_FIRE) {
|
||||
// Rollover chain: same schedule id, new trigger time — treat as update: cancel then set
|
||||
Log.d(SCHEDULE_TAG, "ROLLOVER_ON_FIRE: cancelling existing alarm for id=$stableScheduleId to set new trigger")
|
||||
alarmManager.cancel(existingPendingIntent)
|
||||
} else {
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(triggerAtMillis))
|
||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
||||
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(SCHEDULE_TAG, "Skipping PendingIntent idempotence (caller just cancelled scheduleId=$stableScheduleId)")
|
||||
}
|
||||
|
||||
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
|
||||
// This prevents logical duplicates before even hitting AlarmManager
|
||||
// When skipPendingIntentIdempotence is true (e.g. "re-set" flow), skip this check so we don't
|
||||
// cancel the alarm and then skip re-scheduling, resulting in no alarm.
|
||||
if (!skipPendingIntentIdempotence) {
|
||||
try {
|
||||
runBlocking {
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
@@ -220,6 +233,9 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
} catch (e: Exception) {
|
||||
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
|
||||
}
|
||||
} else {
|
||||
Log.d(SCHEDULE_TAG, "Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=$stableScheduleId")
|
||||
}
|
||||
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(triggerAtMillis))
|
||||
@@ -234,14 +250,16 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
try {
|
||||
runBlocking {
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val contentCache = db.contentCacheDao().getLatest()
|
||||
val contentCache = db.contentCacheDao().getLatestByScope(ContentCacheScope.DAILY)
|
||||
?: db.contentCacheDao().getLatestByScope(ContentCacheScope.LEGACY)
|
||||
?: db.contentCacheDao().getLatest()
|
||||
|
||||
// Always create a notification content entity for recovery tracking
|
||||
// Phase 1: Recovery needs NotificationContentEntity to detect missed notifications
|
||||
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
val roomStorage = org.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
|
||||
val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.0.2", // Plugin version
|
||||
"2.1.0", // Plugin version
|
||||
null, // timesafariDid - can be set if available
|
||||
"daily",
|
||||
config.title,
|
||||
@@ -270,9 +288,11 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
// FIX: Use DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver
|
||||
// FIX: Set action to match manifest registration
|
||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
action = "com.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
|
||||
// FIX: Set action to match manifest registration; setPackage() ensures AlarmManager
|
||||
// delivery reaches this app on all OEMs (see daily-notification-plugin-android-receiver-issue)
|
||||
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
setPackage(context.packageName)
|
||||
action = "org.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
|
||||
putExtra("notification_id", notificationId) // DailyNotificationReceiver expects this extra
|
||||
putExtra("schedule_id", stableScheduleId) // Add stable scheduleId for tracking
|
||||
// Also preserve original extras for backward compatibility if needed
|
||||
@@ -309,7 +329,8 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
if (existingPendingIntent != null) {
|
||||
Log.w(SCHEDULE_TAG, "Cancelling existing alarm before rescheduling: requestCode=$requestCode, scheduleId=$stableScheduleId, source=$source")
|
||||
alarmManager.cancel(existingPendingIntent)
|
||||
existingPendingIntent.cancel()
|
||||
// Do not call existingPendingIntent.cancel(): the cached PendingIntent may be the same
|
||||
// object we pass to setAlarmClock below; cancelling it can prevent the new alarm from firing.
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(SCHEDULE_TAG, "Failed to cancel existing alarm before scheduling: $stableScheduleId", e)
|
||||
@@ -396,6 +417,63 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
)
|
||||
Log.i(TAG, "Inexact alarm scheduled (fallback): triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
||||
}
|
||||
|
||||
// Update database schedule with new nextRunAt so getNotificationStatus() returns correct value
|
||||
// This is critical for rollover scenarios where the UI needs to show the updated time
|
||||
// Strategy: Find existing enabled notify schedule and update it (there should only be one)
|
||||
// This ensures getNotificationStatus() finds the updated schedule, not a stale one
|
||||
try {
|
||||
runBlocking {
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
|
||||
// First, try to find schedule by the provided stableScheduleId
|
||||
var scheduleToUpdate = db.scheduleDao().getById(stableScheduleId)
|
||||
|
||||
// If not found by ID, only use "first enabled notify" fallback when this is NOT
|
||||
// a rollover id (daily_rollover_*). Rollover work may use a different notification_id
|
||||
// (e.g. from recovery); updating the app's schedule row here would overwrite
|
||||
// nextRunAt with the rollover time and can leave the app's alarm in a bad state.
|
||||
if (scheduleToUpdate == null && !stableScheduleId.startsWith("daily_rollover_")) {
|
||||
val allSchedules = db.scheduleDao().getAll()
|
||||
scheduleToUpdate = allSchedules.firstOrNull { it.kind == "notify" && it.enabled }
|
||||
}
|
||||
|
||||
// Calculate cron expression from trigger time (HH:mm format)
|
||||
val calendar = java.util.Calendar.getInstance().apply {
|
||||
timeInMillis = triggerAtMillis
|
||||
}
|
||||
val hour = calendar.get(java.util.Calendar.HOUR_OF_DAY)
|
||||
val minute = calendar.get(java.util.Calendar.MINUTE)
|
||||
val cronExpression = "${minute} ${hour} * * *"
|
||||
val clockTime = String.format("%02d:%02d", hour, minute)
|
||||
|
||||
if (scheduleToUpdate != null) {
|
||||
// Update existing schedule with new nextRunAt
|
||||
// Use the existing schedule's ID (not stableScheduleId) to ensure we update the right one
|
||||
db.scheduleDao().updateRunTimes(scheduleToUpdate.id, scheduleToUpdate.lastRunAt, triggerAtMillis)
|
||||
Log.d(SCHEDULE_TAG, "Updated schedule in database: id=${scheduleToUpdate.id}, nextRunAt=$triggerAtMillis (rollover)")
|
||||
} else {
|
||||
// No existing schedule found - create new one (shouldn't happen in normal flow)
|
||||
val newSchedule = Schedule(
|
||||
id = stableScheduleId,
|
||||
kind = "notify",
|
||||
cron = cronExpression,
|
||||
clockTime = clockTime,
|
||||
enabled = true,
|
||||
lastRunAt = null,
|
||||
nextRunAt = triggerAtMillis,
|
||||
jitterMs = 0,
|
||||
backoffPolicy = "exp",
|
||||
stateJson = null
|
||||
)
|
||||
db.scheduleDao().upsert(newSchedule)
|
||||
Log.d(SCHEDULE_TAG, "Created new schedule in database: id=$stableScheduleId, nextRunAt=$triggerAtMillis")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Log but don't fail - alarm is already scheduled, DB update is best-effort
|
||||
Log.w(SCHEDULE_TAG, "Failed to update schedule in database: $stableScheduleId (alarm still scheduled)", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,8 +486,9 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
fun cancelNotification(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
// FIX: Use DailyNotificationReceiver to match what was scheduled
|
||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
setPackage(context.packageName)
|
||||
action = "org.timesafari.daily.NOTIFICATION"
|
||||
}
|
||||
val requestCode = when {
|
||||
scheduleId != null -> getRequestCode(scheduleId)
|
||||
@@ -419,14 +498,38 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
return
|
||||
}
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
|
||||
// CRITICAL: Use FLAG_NO_CREATE to get existing PendingIntent, don't create new one
|
||||
// This matches the pattern used in scheduleExactNotification for proper cancellation
|
||||
val existingPendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
alarmManager.cancel(pendingIntent)
|
||||
Log.i(TAG, "Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
||||
|
||||
if (existingPendingIntent != null) {
|
||||
// Cancel both the alarm in AlarmManager AND the PendingIntent itself
|
||||
// This matches the pattern in scheduleExactNotification (lines 311-312)
|
||||
alarmManager.cancel(existingPendingIntent)
|
||||
existingPendingIntent.cancel()
|
||||
Log.i(TAG, "DNP-CANCEL: Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
||||
|
||||
// Verify cancellation by checking if alarm still exists
|
||||
val verifyIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode,
|
||||
intent,
|
||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
if (verifyIntent == null) {
|
||||
Log.d(TAG, "DNP-CANCEL: ✅ Cancellation verified - no PendingIntent found for requestCode=$requestCode")
|
||||
} else {
|
||||
Log.w(TAG, "DNP-CANCEL: ⚠️ Cancellation may have failed - PendingIntent still exists for requestCode=$requestCode")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "DNP-CANCEL: No existing PendingIntent found to cancel: scheduleId=$scheduleId, requestCode=$requestCode")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -439,8 +542,9 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
*/
|
||||
fun isAlarmScheduled(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null): Boolean {
|
||||
// FIX: Use DailyNotificationReceiver to match what was scheduled
|
||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
setPackage(context.packageName)
|
||||
action = "org.timesafari.daily.NOTIFICATION"
|
||||
}
|
||||
val requestCode = when {
|
||||
scheduleId != null -> getRequestCode(scheduleId)
|
||||
@@ -568,7 +672,9 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
|
||||
// Existing cached content logic for regular notifications
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val latestCache = db.contentCacheDao().getLatest()
|
||||
val latestCache = db.contentCacheDao().getLatestByScope(ContentCacheScope.DAILY)
|
||||
?: db.contentCacheDao().getLatestByScope(ContentCacheScope.LEGACY)
|
||||
?: db.contentCacheDao().getLatest()
|
||||
|
||||
if (latestCache == null) {
|
||||
Log.w(TAG, "No cached content available for notification")
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* PermissionManager.java
|
||||
*
|
||||
* Specialized manager for permission handling and notification settings
|
||||
* Handles notification permissions, channel management, and exact alarm settings
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 2.0.0 - Modular Architecture
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.os.PowerManager;
|
||||
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.PluginCall;
|
||||
|
||||
/**
|
||||
* Manager class for permission and settings management
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Request notification permissions
|
||||
* - Check permission status
|
||||
* - Manage notification channels
|
||||
* - Handle exact alarm settings
|
||||
* - Provide comprehensive status checking
|
||||
*/
|
||||
public class PermissionManager {
|
||||
|
||||
private static final String TAG = "PermissionManager";
|
||||
|
||||
private final Context context;
|
||||
private final ChannelManager channelManager;
|
||||
|
||||
/**
|
||||
* Initialize the PermissionManager
|
||||
*
|
||||
* @param context Android context
|
||||
* @param channelManager Channel manager for notification channels
|
||||
*/
|
||||
public PermissionManager(Context context, ChannelManager channelManager) {
|
||||
this.context = context;
|
||||
this.channelManager = channelManager;
|
||||
|
||||
Log.d(TAG, "PermissionManager initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permissions from the user
|
||||
*
|
||||
* @param call Plugin call
|
||||
* @param activity Activity for showing permission dialog (required for Android 13+)
|
||||
*/
|
||||
public void requestNotificationPermissions(PluginCall call, android.app.Activity activity) {
|
||||
try {
|
||||
Log.d(TAG, "Requesting notification permissions");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// For Android 13+, request POST_NOTIFICATIONS permission
|
||||
if (activity == null) {
|
||||
call.reject("Activity not available - required for permission request");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already granted
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(context,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
== android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
// Already granted
|
||||
JSObject result = new JSObject();
|
||||
result.put("status", "granted");
|
||||
result.put("granted", true);
|
||||
result.put("notifications", "granted");
|
||||
call.resolve(result);
|
||||
} else {
|
||||
// Request permission - activity must handle result via handleRequestPermissionsResult
|
||||
// Note: The plugin should save the call before calling this method
|
||||
androidx.core.app.ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
new String[]{android.Manifest.permission.POST_NOTIFICATIONS},
|
||||
org.timesafari.dailynotification.DailyNotificationConstants.PERMISSION_REQUEST_CODE // Centralized constant
|
||||
);
|
||||
|
||||
Log.d(TAG, "Permission dialog shown, waiting for user response");
|
||||
// Don't resolve here - wait for handleRequestPermissionsResult in plugin
|
||||
}
|
||||
} else {
|
||||
// For older versions, permissions are granted at install time
|
||||
JSObject result = new JSObject();
|
||||
result.put("status", "granted");
|
||||
result.put("granted", true);
|
||||
result.put("notifications", "granted");
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error requesting notification permissions", e);
|
||||
call.reject("Failed to request permissions: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permissions from the user (backward compatibility - requires activity)
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void requestNotificationPermissions(PluginCall call) {
|
||||
// This version cannot actually request permissions without activity
|
||||
// It will only check if already granted
|
||||
requestPermission(Manifest.permission.POST_NOTIFICATIONS, call);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive permission status
|
||||
* Returns PermissionStatus model (single source of truth)
|
||||
*
|
||||
* @return PermissionStatus with all permission states
|
||||
*/
|
||||
public org.timesafari.dailynotification.PermissionStatus getPermissionStatus() {
|
||||
boolean postNotificationsGranted = false;
|
||||
boolean exactAlarmsGranted = false;
|
||||
boolean notificationsEnabledAtOsLevel = false;
|
||||
boolean batteryOptimizationsIgnored = false;
|
||||
|
||||
// Check POST_NOTIFICATIONS permission (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
postNotificationsGranted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
} else {
|
||||
// Pre-Android 13: check OS-level notification enablement
|
||||
postNotificationsGranted = true; // Permission granted at install time
|
||||
}
|
||||
|
||||
// Always check OS-level notification enablement (important for all API levels)
|
||||
notificationsEnabledAtOsLevel = NotificationManagerCompat.from(context).areNotificationsEnabled();
|
||||
|
||||
// Check exact alarm permission (Android 12+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
|
||||
context.getSystemService(Context.ALARM_SERVICE);
|
||||
exactAlarmsGranted = alarmManager != null && alarmManager.canScheduleExactAlarms();
|
||||
} else {
|
||||
exactAlarmsGranted = true; // Pre-Android 12, exact alarms are always allowed
|
||||
}
|
||||
|
||||
// Check battery optimizations (Android 6+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
try {
|
||||
android.os.PowerManager powerManager = (android.os.PowerManager)
|
||||
context.getSystemService(Context.POWER_SERVICE);
|
||||
if (powerManager != null) {
|
||||
batteryOptimizationsIgnored = powerManager.isIgnoringBatteryOptimizations(context.getPackageName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Error checking battery optimizations", e);
|
||||
batteryOptimizationsIgnored = false;
|
||||
}
|
||||
} else {
|
||||
batteryOptimizationsIgnored = true; // Pre-Android 6, no battery optimization restrictions
|
||||
}
|
||||
|
||||
return new org.timesafari.dailynotification.PermissionStatus(
|
||||
postNotificationsGranted,
|
||||
exactAlarmsGranted,
|
||||
batteryOptimizationsIgnored,
|
||||
notificationsEnabledAtOsLevel,
|
||||
Build.VERSION.SDK_INT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the current status of notification permissions
|
||||
* Delegates to getPermissionStatus() and formats response for JS
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void checkPermissionStatus(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Checking permission status");
|
||||
|
||||
org.timesafari.dailynotification.PermissionStatus status = getPermissionStatus();
|
||||
|
||||
JSObject result = status.toJSObject();
|
||||
result.put("success", true);
|
||||
result.put("channelEnabled", channelManager.isChannelEnabled());
|
||||
result.put("channelImportance", channelManager.getChannelImportance());
|
||||
|
||||
// Add UI-friendly field names for compatibility
|
||||
// notificationsEnabled = postNotificationsGranted AND notificationsEnabledAtOsLevel
|
||||
boolean postNotificationsGranted = result.getBoolean("postNotificationsGranted", false);
|
||||
boolean notificationsEnabledAtOsLevel = result.getBoolean("notificationsEnabledAtOsLevel", false);
|
||||
result.put("notificationsEnabled", postNotificationsGranted && notificationsEnabledAtOsLevel);
|
||||
// exactAlarmEnabled = exactAlarmGranted
|
||||
boolean exactAlarmGranted = result.getBoolean("exactAlarmGranted", false);
|
||||
result.put("exactAlarmEnabled", exactAlarmGranted);
|
||||
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking permission status", e);
|
||||
call.reject("Failed to check permissions: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check exact alarm permission status
|
||||
* Returns detailed information about permission status and whether it can be requested
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void checkExactAlarmPermission(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Checking exact alarm permission");
|
||||
|
||||
boolean canSchedule = false;
|
||||
boolean canRequest = false;
|
||||
boolean required = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S;
|
||||
|
||||
if (required) {
|
||||
// Check if exact alarms can be scheduled
|
||||
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
|
||||
context.getSystemService(Context.ALARM_SERVICE);
|
||||
canSchedule = alarmManager != null && alarmManager.canScheduleExactAlarms();
|
||||
|
||||
// Check if permission can be requested (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// Try reflection to call Settings.canRequestScheduleExactAlarms()
|
||||
try {
|
||||
java.lang.reflect.Method method = Settings.class.getMethod(
|
||||
"canRequestScheduleExactAlarms",
|
||||
Context.class
|
||||
);
|
||||
canRequest = (Boolean) method.invoke(null, context);
|
||||
} catch (Exception e) {
|
||||
// Fallback heuristic: if exact alarms are not currently allowed,
|
||||
// assume we can request them (safe default)
|
||||
canRequest = !canSchedule;
|
||||
}
|
||||
} else {
|
||||
// Android 12 (API 31-32) - permission can always be requested
|
||||
canRequest = true;
|
||||
}
|
||||
} else {
|
||||
// Android 11 and below - permission not needed
|
||||
canSchedule = true;
|
||||
canRequest = true;
|
||||
}
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("canSchedule", canSchedule);
|
||||
result.put("canRequest", canRequest);
|
||||
result.put("required", required);
|
||||
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking exact alarm permission", e);
|
||||
call.reject("Permission check failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request exact alarm permission
|
||||
* Opens Settings intent to let user grant the permission
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void requestExactAlarmPermission(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Requesting exact alarm permission");
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
// Android 11 and below don't need this permission
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("message", "Exact alarm permission not required on this Android version");
|
||||
call.resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if permission is already granted
|
||||
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
|
||||
context.getSystemService(Context.ALARM_SERVICE);
|
||||
boolean canSchedule = alarmManager != null && alarmManager.canScheduleExactAlarms();
|
||||
|
||||
if (canSchedule) {
|
||||
// Permission already granted
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("message", "Exact alarm permission already granted");
|
||||
call.resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if app can request the permission (Android 13+)
|
||||
boolean canRequest = false;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// Try reflection to call Settings.canRequestScheduleExactAlarms()
|
||||
try {
|
||||
java.lang.reflect.Method method = Settings.class.getMethod(
|
||||
"canRequestScheduleExactAlarms",
|
||||
Context.class
|
||||
);
|
||||
canRequest = (Boolean) method.invoke(null, context);
|
||||
} catch (Exception e) {
|
||||
// Fallback heuristic: if exact alarms are not currently allowed,
|
||||
// assume we can request them (safe default)
|
||||
canRequest = !canSchedule;
|
||||
}
|
||||
} else {
|
||||
// Android 12 (API 31-32) - permission can always be requested
|
||||
canRequest = true;
|
||||
}
|
||||
|
||||
if (canRequest) {
|
||||
// Open Settings to let user grant permission
|
||||
try {
|
||||
Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM);
|
||||
intent.setData(android.net.Uri.parse("package:" + context.getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
context.startActivity(intent);
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("message", "Please grant 'Alarms & reminders' permission in Settings");
|
||||
call.resolve(result);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to open exact alarm settings", e);
|
||||
call.reject("Failed to open exact alarm settings: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
// User has already denied or permission is permanently denied
|
||||
// Direct user to app settings
|
||||
try {
|
||||
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
|
||||
intent.setData(android.net.Uri.parse("package:" + context.getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
context.startActivity(intent);
|
||||
|
||||
call.reject(
|
||||
"Permission denied. Please enable 'Alarms & reminders' in app settings.",
|
||||
"PERMISSION_DENIED"
|
||||
);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to open app settings", e);
|
||||
call.reject("Failed to open app settings: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error requesting exact alarm permission", e);
|
||||
call.reject("Permission request failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open exact alarm settings for the user
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void openExactAlarmSettings(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Opening exact alarm settings");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM);
|
||||
intent.setData(android.net.Uri.parse("package:" + context.getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
try {
|
||||
context.startActivity(intent);
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("message", "Exact alarm settings opened");
|
||||
call.resolve(result);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to open exact alarm settings", e);
|
||||
call.reject("Failed to open exact alarm settings: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("message", "Exact alarms not supported on this Android version");
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error opening exact alarm settings", e);
|
||||
call.reject("Failed to open exact alarm settings: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the notification channel is enabled
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void isChannelEnabled(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Checking channel status");
|
||||
|
||||
boolean enabled = channelManager.isChannelEnabled();
|
||||
int importance = channelManager.getChannelImportance();
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("enabled", enabled);
|
||||
result.put("importance", importance);
|
||||
result.put("channelId", channelManager.getDefaultChannelId());
|
||||
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking channel status", e);
|
||||
call.reject("Failed to check channel status: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open notification channel settings for the user
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void openChannelSettings(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Opening channel settings");
|
||||
|
||||
boolean opened = channelManager.openChannelSettings();
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("opened", opened);
|
||||
result.put("message", opened ? "Channel settings opened" : "Failed to open channel settings");
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error opening channel settings", e);
|
||||
call.reject("Failed to open channel settings: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive status of the notification system
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
public void checkStatus(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Checking comprehensive status");
|
||||
|
||||
// Check permissions
|
||||
boolean postNotificationsGranted = false;
|
||||
boolean exactAlarmsGranted = false;
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
postNotificationsGranted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
} else {
|
||||
postNotificationsGranted = NotificationManagerCompat.from(context).areNotificationsEnabled();
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
|
||||
context.getSystemService(Context.ALARM_SERVICE);
|
||||
exactAlarmsGranted = alarmManager.canScheduleExactAlarms();
|
||||
} else {
|
||||
exactAlarmsGranted = true;
|
||||
}
|
||||
|
||||
// Check channel status
|
||||
boolean channelEnabled = channelManager.isChannelEnabled();
|
||||
int channelImportance = channelManager.getChannelImportance();
|
||||
|
||||
// Determine overall status
|
||||
boolean canScheduleNow = postNotificationsGranted && channelEnabled && exactAlarmsGranted;
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("canScheduleNow", canScheduleNow);
|
||||
result.put("postNotificationsGranted", postNotificationsGranted);
|
||||
result.put("exactAlarmsGranted", exactAlarmsGranted);
|
||||
result.put("channelEnabled", channelEnabled);
|
||||
result.put("channelImportance", channelImportance);
|
||||
result.put("channelId", channelManager.getDefaultChannelId());
|
||||
result.put("androidVersion", Build.VERSION.SDK_INT);
|
||||
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking comprehensive status", e);
|
||||
call.reject("Failed to check status: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a specific permission
|
||||
*
|
||||
* @param permission Permission to request
|
||||
* @param call Plugin call
|
||||
*/
|
||||
private void requestPermission(String permission, PluginCall call) {
|
||||
try {
|
||||
// This would typically be handled by the Capacitor framework
|
||||
// For now, we'll check if the permission is already granted
|
||||
boolean granted = context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED;
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("success", true);
|
||||
result.put("granted", granted);
|
||||
result.put("permission", permission);
|
||||
result.put("message", granted ? "Permission already granted" : "Permission not granted");
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error requesting permission: " + permission, e);
|
||||
call.reject("Failed to request permission: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* PermissionStatus.kt
|
||||
*
|
||||
* Data model for permission status information
|
||||
* Single source of truth for permission state across plugin and services
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
/**
|
||||
* Comprehensive permission status model
|
||||
*
|
||||
* Represents the complete permission state for notification functionality
|
||||
* Used by both plugin and PermissionManager to ensure consistency
|
||||
*/
|
||||
data class PermissionStatus(
|
||||
/**
|
||||
* POST_NOTIFICATIONS permission granted (Android 13+)
|
||||
* Always true for Android < 13
|
||||
*/
|
||||
val postNotificationsGranted: Boolean,
|
||||
|
||||
/**
|
||||
* SCHEDULE_EXACT_ALARM permission granted (Android 12+)
|
||||
* Always true for Android < 12
|
||||
*/
|
||||
val exactAlarmGranted: Boolean,
|
||||
|
||||
/**
|
||||
* Battery optimizations ignored (exempted)
|
||||
* False if app is subject to battery optimization restrictions
|
||||
*/
|
||||
val batteryOptimizationsIgnored: Boolean,
|
||||
|
||||
/**
|
||||
* Notifications enabled at OS level
|
||||
* Checks NotificationManagerCompat.areNotificationsEnabled()
|
||||
* Important for pre-Android 13 where users can disable at OS level
|
||||
*/
|
||||
val notificationsEnabledAtOsLevel: Boolean,
|
||||
|
||||
/**
|
||||
* Android API level
|
||||
* Used for conditional logic based on OS version
|
||||
*/
|
||||
val apiLevel: Int
|
||||
) {
|
||||
/**
|
||||
* Overall readiness to schedule notifications
|
||||
* True if all required permissions are granted and notifications are enabled
|
||||
*/
|
||||
val canScheduleNow: Boolean
|
||||
get() = postNotificationsGranted &&
|
||||
exactAlarmGranted &&
|
||||
notificationsEnabledAtOsLevel
|
||||
|
||||
/**
|
||||
* Convert to JSObject for Capacitor response
|
||||
*/
|
||||
fun toJSObject(): com.getcapacitor.JSObject {
|
||||
return com.getcapacitor.JSObject().apply {
|
||||
put("postNotificationsGranted", postNotificationsGranted)
|
||||
put("exactAlarmGranted", exactAlarmGranted)
|
||||
put("batteryOptimizationsIgnored", batteryOptimizationsIgnored)
|
||||
put("notificationsEnabledAtOsLevel", notificationsEnabledAtOsLevel)
|
||||
put("apiLevel", apiLevel)
|
||||
put("canScheduleNow", canScheduleNow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending permission request tracking
|
||||
*
|
||||
* Tracks an in-flight permission request to prevent wrong-call resolution
|
||||
*/
|
||||
data class PendingPermissionRequest(
|
||||
/**
|
||||
* Unique identifier for this request
|
||||
* Used to match resume events with the correct request
|
||||
*/
|
||||
val requestNonce: String,
|
||||
|
||||
/**
|
||||
* Type of permission being requested
|
||||
*/
|
||||
val requestType: PermissionRequestType,
|
||||
|
||||
/**
|
||||
* Timestamp when request was initiated
|
||||
* Used to expire stale requests
|
||||
*/
|
||||
val requestedAtMs: Long,
|
||||
|
||||
/**
|
||||
* Plugin call reference (stored separately, not in data class)
|
||||
* Note: This is stored in plugin's savedCall, nonce is used to verify match
|
||||
*/
|
||||
// call: PluginCall - stored separately in plugin
|
||||
)
|
||||
|
||||
/**
|
||||
* Types of permission requests
|
||||
*/
|
||||
enum class PermissionRequestType {
|
||||
POST_NOTIFICATIONS,
|
||||
EXACT_ALARM,
|
||||
BATTERY_OPTIMIZATION
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
@@ -17,9 +17,9 @@ import java.util.concurrent.TimeUnit
|
||||
* Phase 2: Force stop detection and recovery
|
||||
*
|
||||
* Implements:
|
||||
* - [Plugin Requirements §3.1.2 - App Cold Start](../docs/alarms/03-plugin-requirements.md#312-app-cold-start)
|
||||
* - [Plugin Requirements §3.1.4 - Force Stop Recovery](../docs/alarms/03-plugin-requirements.md#314-force-stop-recovery-android-only)
|
||||
* Platform Reference: [Android §2.1.4](../docs/alarms/01-platform-capability-reference.md#214-alarms-can-be-restored-after-app-restart)
|
||||
* - [Plugin Requirements §3.1.2 - App Cold Start](../../../../../../../doc/alarms/03-plugin-requirements.md#312-app-cold-start)
|
||||
* - [Plugin Requirements §3.1.4 - Force Stop Recovery](../../../../../../../doc/alarms/03-plugin-requirements.md#314-force-stop-recovery-android-only)
|
||||
* Platform Reference: [Android §2.1.4](../../../../../../../doc/alarms/01-platform-capability-reference.md#214-alarms-can-be-restored-after-app-restart)
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 2.0.0 - Phase 2: Force stop detection
|
||||
@@ -42,12 +42,32 @@ class ReactivationManager(private val context: Context) {
|
||||
private const val TAG = "DNP-REACTIVATION"
|
||||
private const val RECOVERY_TIMEOUT_SECONDS = 2L
|
||||
|
||||
/**
|
||||
* Load persisted title/body for a schedule from NotificationContentEntity (post-reboot recovery).
|
||||
* Tries schedule.id then "daily_${schedule.id}" to match NotifyReceiver/ScheduleHelper id convention.
|
||||
* Internal so BootReceiver can use when rescheduling after boot.
|
||||
*/
|
||||
internal fun getTitleBodyForSchedule(db: DailyNotificationDatabase, schedule: Schedule): Pair<String, String>? {
|
||||
val entity = try {
|
||||
db.notificationContentDao().getNotificationById(schedule.id)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: try {
|
||||
db.notificationContentDao().getNotificationById("daily_${schedule.id}")
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
val t = entity.title?.takeIf { it.isNotBlank() } ?: return null
|
||||
val b = entity.body?.takeIf { it.isNotBlank() } ?: return null
|
||||
return Pair(t, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run boot-time recovery
|
||||
*
|
||||
* Phase 3: Boot recovery that restores alarms after device reboot
|
||||
*
|
||||
* Implements: [Plugin Requirements §3.1.1 - Boot Event](../docs/alarms/03-plugin-requirements.md#311-boot-event-android-only)
|
||||
* Implements: [Plugin Requirements §3.1.1 - Boot Event](../../../../../../../doc/alarms/03-plugin-requirements.md#311-boot-event-android-only)
|
||||
*
|
||||
* This method is called from BootReceiver when BOOT_COMPLETED is received.
|
||||
* It runs asynchronously with timeout protection to avoid blocking boot.
|
||||
@@ -68,12 +88,19 @@ class ReactivationManager(private val context: Context) {
|
||||
Log.i(TAG, "Starting boot recovery")
|
||||
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val dbStartTime = System.currentTimeMillis()
|
||||
val enabledSchedules = try {
|
||||
db.scheduleDao().getEnabled()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load schedules from DB", e)
|
||||
emptyList()
|
||||
}
|
||||
val dbDuration = System.currentTimeMillis() - dbStartTime
|
||||
if (dbDuration > 100) {
|
||||
Log.w(TAG, "Database query slow: ${dbDuration}ms for getEnabled()")
|
||||
} else {
|
||||
Log.d(TAG, "Database query: ${dbDuration}ms, schedules=${enabledSchedules.size}")
|
||||
}
|
||||
|
||||
if (enabledSchedules.isEmpty()) {
|
||||
Log.i(TAG, "BOOT: No schedules found")
|
||||
@@ -98,8 +125,8 @@ class ReactivationManager(private val context: Context) {
|
||||
markMissedNotificationForSchedule(schedule, nextRunTime, db)
|
||||
missedCount++
|
||||
|
||||
// Schedule next occurrence if repeating
|
||||
val nextOccurrence = calculateNextOccurrence(currentTime)
|
||||
// Schedule next occurrence (use rollover interval if set, else 24h)
|
||||
val nextOccurrence = calculateNextOccurrenceForSchedule(schedule, nextRunTime, currentTime)
|
||||
rescheduleAlarmForBoot(context, schedule, nextOccurrence, db)
|
||||
rescheduledCount++
|
||||
|
||||
@@ -128,6 +155,12 @@ class ReactivationManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if (enabledSchedules.any { it.id.startsWith("dual_fetch_") }) {
|
||||
if (DualScheduleFetchRecovery.enqueueFromPersistedConfig(context)) {
|
||||
Log.i(TAG, "Dual prefetch WorkManager re-enqueued from persisted config")
|
||||
}
|
||||
}
|
||||
|
||||
// Record recovery in history
|
||||
val result = RecoveryResult(
|
||||
missedCount = missedCount,
|
||||
@@ -211,11 +244,26 @@ class ReactivationManager(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun calculateNextOccurrence(fromTime: Long): Long {
|
||||
// For daily schedules, add 24 hours
|
||||
// This is simplified - production should handle weekly/monthly patterns
|
||||
return fromTime + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
/**
|
||||
* Next occurrence from a given trigger time. Uses schedule.rolloverIntervalMinutes when set and > 0 (dev/testing), else 24h.
|
||||
* Advances until result > currentTime so we don't reschedule in the past.
|
||||
*/
|
||||
private fun calculateNextOccurrenceForSchedule(schedule: Schedule, fromTime: Long, currentTime: Long): Long {
|
||||
val intervalMs = when {
|
||||
schedule.rolloverIntervalMinutes != null && schedule.rolloverIntervalMinutes!! > 0 ->
|
||||
schedule.rolloverIntervalMinutes!! * 60 * 1000L
|
||||
else -> 24 * 60 * 60 * 1000L
|
||||
}
|
||||
var next = fromTime + intervalMs
|
||||
while (next < currentTime) {
|
||||
next += intervalMs
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private suspend fun markMissedNotificationForSchedule(
|
||||
schedule: Schedule,
|
||||
scheduledTime: Long,
|
||||
@@ -238,9 +286,9 @@ class ReactivationManager(private val context: Context) {
|
||||
db.notificationContentDao().updateNotification(existing)
|
||||
} else {
|
||||
// Create new notification content entry for missed alarm
|
||||
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
val notification = org.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.0.2", // Plugin version
|
||||
"2.1.0", // Plugin version
|
||||
null, // timesafariDid
|
||||
"daily", // notificationType
|
||||
"Daily Notification",
|
||||
@@ -268,11 +316,13 @@ class ReactivationManager(private val context: Context) {
|
||||
db: DailyNotificationDatabase
|
||||
) {
|
||||
try {
|
||||
val (title, body) = getTitleBodyForSchedule(db, schedule)
|
||||
?: Pair("Daily Notification", "Your daily update is ready")
|
||||
val config = UserNotificationConfig(
|
||||
enabled = schedule.enabled,
|
||||
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
|
||||
title = "Daily Notification",
|
||||
body = "Your daily update is ready",
|
||||
title = title,
|
||||
body = body,
|
||||
sound = true,
|
||||
vibration = true,
|
||||
priority = "normal"
|
||||
@@ -283,7 +333,8 @@ class ReactivationManager(private val context: Context) {
|
||||
nextRunTime,
|
||||
config,
|
||||
scheduleId = schedule.id,
|
||||
source = ScheduleSource.BOOT_RECOVERY
|
||||
source = ScheduleSource.BOOT_RECOVERY,
|
||||
skipPendingIntentIdempotence = true
|
||||
)
|
||||
|
||||
// Update schedule in database (best effort)
|
||||
@@ -433,10 +484,10 @@ class ReactivationManager(private val context: Context) {
|
||||
*/
|
||||
private fun alarmsExist(): Boolean {
|
||||
return try {
|
||||
// Check if any PendingIntent for our receiver exists
|
||||
// This is more reliable than nextAlarmClock
|
||||
val intent = Intent(context, NotifyReceiver::class.java).apply {
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
// Check if any PendingIntent for our receiver exists (must match NotifyReceiver schedule path)
|
||||
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
setPackage(context.packageName)
|
||||
action = "org.timesafari.daily.NOTIFICATION"
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
@@ -529,6 +580,7 @@ class ReactivationManager(private val context: Context) {
|
||||
* @return RecoveryResult with counts
|
||||
*/
|
||||
private suspend fun performColdStartRecovery(): RecoveryResult {
|
||||
val startTime = System.currentTimeMillis()
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
@@ -627,7 +679,8 @@ class ReactivationManager(private val context: Context) {
|
||||
|
||||
recordRecoveryHistory(db, "cold_start", result)
|
||||
|
||||
Log.i(TAG, "Cold start recovery complete: $result")
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
Log.i(TAG, "Cold start recovery completed: duration=${duration}ms, missed=$missedCount, rescheduled=$rescheduledCount, verified=$verifiedCount, errors=${missedErrors + rescheduleErrors}")
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -652,6 +705,7 @@ class ReactivationManager(private val context: Context) {
|
||||
* @return RecoveryResult with counts
|
||||
*/
|
||||
private suspend fun performForceStopRecovery(): RecoveryResult {
|
||||
val startTime = System.currentTimeMillis()
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
@@ -707,7 +761,8 @@ class ReactivationManager(private val context: Context) {
|
||||
|
||||
recordRecoveryHistory(db, "force_stop", result)
|
||||
|
||||
Log.i(TAG, "Force stop recovery complete: $result")
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
Log.i(TAG, "Force stop recovery completed: duration=${duration}ms, missed=$missedCount, rescheduled=$rescheduledCount, errors=$errors")
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -805,13 +860,13 @@ class ReactivationManager(private val context: Context) {
|
||||
db: DailyNotificationDatabase
|
||||
) {
|
||||
try {
|
||||
// Use existing BootReceiver logic for calculating next run time
|
||||
// For now, use schedule.nextRunAt directly
|
||||
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
|
||||
?: Pair("Daily Notification", "Your daily update is ready")
|
||||
val config = UserNotificationConfig(
|
||||
enabled = schedule.enabled,
|
||||
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
|
||||
title = "Daily Notification",
|
||||
body = "Your daily update is ready",
|
||||
title = title,
|
||||
body = body,
|
||||
sound = true,
|
||||
vibration = true,
|
||||
priority = "normal"
|
||||
@@ -1001,9 +1056,9 @@ class ReactivationManager(private val context: Context) {
|
||||
db.notificationContentDao().updateNotification(existing)
|
||||
} else {
|
||||
// Create new notification content entry for missed alarm
|
||||
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
val notification = org.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.0.2", // Plugin version
|
||||
"2.1.0", // Plugin version
|
||||
null, // timesafariDid
|
||||
"daily", // notificationType
|
||||
"Daily Notification",
|
||||
@@ -1034,11 +1089,13 @@ class ReactivationManager(private val context: Context) {
|
||||
db: DailyNotificationDatabase
|
||||
) {
|
||||
try {
|
||||
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
|
||||
?: Pair("Daily Notification", "Your daily update is ready")
|
||||
val config = UserNotificationConfig(
|
||||
enabled = schedule.enabled,
|
||||
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
|
||||
title = "Daily Notification",
|
||||
body = "Your daily update is ready",
|
||||
title = title,
|
||||
body = body,
|
||||
sound = true,
|
||||
vibration = true,
|
||||
priority = "normal"
|
||||
@@ -1049,7 +1106,8 @@ class ReactivationManager(private val context: Context) {
|
||||
nextRunTime,
|
||||
config,
|
||||
scheduleId = schedule.id,
|
||||
source = ScheduleSource.BOOT_RECOVERY
|
||||
source = ScheduleSource.BOOT_RECOVERY,
|
||||
skipPendingIntentIdempotence = true
|
||||
)
|
||||
|
||||
// Update schedule in database (best effort)
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Shared cron → next wall-clock instant (daily "minute hour * * *" style).
|
||||
* Used by dual prefetch scheduling, rollover, and [DailyNotificationPlugin] scheduling.
|
||||
*/
|
||||
object ScheduleCronUtils {
|
||||
private const val TAG = "DNP-CRON"
|
||||
|
||||
/**
|
||||
* Next occurrence of the given daily cron after "now" (same logic as DailyNotificationPlugin.calculateNextRunTime).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun calculateNextRunTimeMillis(schedule: String): Long {
|
||||
try {
|
||||
val parts = schedule.trim().split("\\s+".toRegex())
|
||||
if (parts.size < 2) {
|
||||
Log.w(TAG, "Invalid cron format: $schedule, defaulting to 24h from now")
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
val minute = parts[0].toIntOrNull() ?: 0
|
||||
val hour = parts[1].toIntOrNull() ?: 9
|
||||
|
||||
if (minute < 0 || minute > 59 || hour < 0 || hour > 23) {
|
||||
Log.w(TAG, "Invalid time values in cron: $schedule, defaulting to 24h from now")
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
val calendar = java.util.Calendar.getInstance()
|
||||
val now = calendar.timeInMillis
|
||||
|
||||
calendar.set(java.util.Calendar.HOUR_OF_DAY, hour)
|
||||
calendar.set(java.util.Calendar.MINUTE, minute)
|
||||
calendar.set(java.util.Calendar.SECOND, 0)
|
||||
calendar.set(java.util.Calendar.MILLISECOND, 0)
|
||||
|
||||
var nextRun = calendar.timeInMillis
|
||||
if (nextRun <= now) {
|
||||
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
|
||||
nextRun = calendar.timeInMillis
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Next run: cron=$schedule, nextRun=${
|
||||
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(nextRun))
|
||||
}"
|
||||
)
|
||||
return nextRun
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error calculating next run for schedule: $schedule", e)
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Trace;
|
||||
@@ -24,7 +24,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -206,6 +206,74 @@ public final class TimeSafariIntegrationManager {
|
||||
return activeDid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure TimeSafari integration settings
|
||||
*
|
||||
* @param config Configuration options (may include apiServerUrl, did, etc.)
|
||||
*/
|
||||
public void configure(@NonNull org.json.JSONObject config) {
|
||||
try {
|
||||
logger.d("TS: configure() called");
|
||||
|
||||
// Extract and set API server URL if provided
|
||||
if (config.has("apiServerUrl")) {
|
||||
String url = config.optString("apiServerUrl", null);
|
||||
setApiServerUrl(url);
|
||||
}
|
||||
|
||||
// Extract and set active DID if provided
|
||||
if (config.has("did")) {
|
||||
String did = config.optString("did", null);
|
||||
setActiveDid(did);
|
||||
}
|
||||
|
||||
logger.i("TS: Configuration applied");
|
||||
} catch (Exception e) {
|
||||
logger.e("TS: Configuration failed", e);
|
||||
throw new RuntimeException("Configuration failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update starred plan IDs
|
||||
*
|
||||
* Stores the provided plan IDs in SharedPreferences for use by the fetcher.
|
||||
*
|
||||
* @param planIds List of plan IDs to star
|
||||
*/
|
||||
public void updateStarredPlans(@NonNull List<String> planIds) {
|
||||
try {
|
||||
logger.d("TS: updateStarredPlans() called with count=" + planIds.size());
|
||||
|
||||
// Validate all plan IDs are non-empty strings
|
||||
for (int i = 0; i < planIds.size(); i++) {
|
||||
String planId = planIds.get(i);
|
||||
if (planId == null || planId.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("planIds[" + i + "] must be a non-empty string");
|
||||
}
|
||||
}
|
||||
|
||||
// Store in SharedPreferences (matching TestNativeFetcher expectations)
|
||||
SharedPreferences preferences = appContext
|
||||
.getSharedPreferences("daily_notification_timesafari", Context.MODE_PRIVATE);
|
||||
|
||||
// Convert planIds list to JSON array string
|
||||
org.json.JSONArray jsonArray = new org.json.JSONArray();
|
||||
for (String planId : planIds) {
|
||||
jsonArray.put(planId);
|
||||
}
|
||||
|
||||
preferences.edit()
|
||||
.putString("starredPlanIds", jsonArray.toString())
|
||||
.apply();
|
||||
|
||||
logger.i("TS: Starred plans updated: count=" + planIds.size());
|
||||
} catch (Exception e) {
|
||||
logger.e("TS: Failed to update starred plans", e);
|
||||
throw new RuntimeException("Failed to update starred plans", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle DID change - clear caches and reschedule
|
||||
*/
|
||||
@@ -249,8 +317,10 @@ public final class TimeSafariIntegrationManager {
|
||||
* Pulls notifications from the server and schedules future items.
|
||||
* If forceFullSync is true, ignores local pagination windows.
|
||||
*
|
||||
* TODO: Extract logic from DailyNotificationPlugin.configureActiveDidIntegration()
|
||||
* TODO: Extract logic from DailyNotificationPlugin scheduling methods
|
||||
* Implementation Notes:
|
||||
* - Logic extraction from DailyNotificationPlugin.configureActiveDidIntegration() is planned
|
||||
* - Logic extraction from DailyNotificationPlugin scheduling methods is planned
|
||||
* - These extractions will be completed as part of future integration refactoring
|
||||
*
|
||||
* Note: EnhancedDailyNotificationFetcher returns CompletableFuture<TimeSafariNotificationBundle>
|
||||
* Need to convert bundle to NotificationContent[] for storage/scheduling
|
||||
@@ -9,10 +9,10 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.dao;
|
||||
package org.timesafari.dailynotification.dao;
|
||||
|
||||
import androidx.room.*;
|
||||
import com.timesafari.dailynotification.entities.NotificationConfigEntity;
|
||||
import org.timesafari.dailynotification.entities.NotificationConfigEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.dao;
|
||||
package org.timesafari.dailynotification.dao;
|
||||
|
||||
import androidx.room.*;
|
||||
import com.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
import org.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.dao;
|
||||
package org.timesafari.dailynotification.dao;
|
||||
|
||||
import androidx.room.*;
|
||||
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity;
|
||||
import org.timesafari.dailynotification.entities.NotificationDeliveryEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.entities;
|
||||
package org.timesafari.dailynotification.entities;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.room.ColumnInfo;
|
||||
@@ -9,7 +9,7 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.entities;
|
||||
package org.timesafari.dailynotification.entities;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.room.ColumnInfo;
|
||||
@@ -9,7 +9,7 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.entities;
|
||||
package org.timesafari.dailynotification.entities;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.room.ColumnInfo;
|
||||
@@ -9,18 +9,18 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.storage;
|
||||
package org.timesafari.dailynotification.storage;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.timesafari.dailynotification.DailyNotificationDatabase;
|
||||
import com.timesafari.dailynotification.dao.NotificationContentDao;
|
||||
import com.timesafari.dailynotification.dao.NotificationDeliveryDao;
|
||||
import com.timesafari.dailynotification.dao.NotificationConfigDao;
|
||||
import com.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity;
|
||||
import com.timesafari.dailynotification.entities.NotificationConfigEntity;
|
||||
import org.timesafari.dailynotification.DailyNotificationDatabase;
|
||||
import org.timesafari.dailynotification.dao.NotificationContentDao;
|
||||
import org.timesafari.dailynotification.dao.NotificationDeliveryDao;
|
||||
import org.timesafari.dailynotification.dao.NotificationConfigDao;
|
||||
import org.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
import org.timesafari.dailynotification.entities.NotificationDeliveryEntity;
|
||||
import org.timesafari.dailynotification.entities.NotificationConfigEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -52,7 +52,7 @@ public class DailyNotificationStorageRoom {
|
||||
private final ExecutorService executorService;
|
||||
|
||||
// Plugin version for migration tracking
|
||||
private static final String PLUGIN_VERSION = "1.0.0";
|
||||
private static final String PLUGIN_VERSION = "1.2.1";
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* DailyNotificationRecoveryTests.kt
|
||||
*
|
||||
* Combined edge case tests for Android DailyNotification plugin
|
||||
* Achieves parity with iOS P2.2 combined resilience tests
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Recovery tests for combined edge case scenarios
|
||||
*
|
||||
* These tests validate idempotency and correctness under combined stressors:
|
||||
* - DST boundary transitions
|
||||
* - Duplicate delivery events
|
||||
* - Cold start recovery
|
||||
* - Rollover scenarios
|
||||
*
|
||||
* Test labels: @resilience @combined-scenarios
|
||||
*
|
||||
* @resilience @combined-scenarios
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28]) // Use API 28 for Robolectric
|
||||
class DailyNotificationRecoveryTests {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var database: DailyNotificationDatabase
|
||||
private lateinit var reactivationManager: ReactivationManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
database = TestDBFactory.createInMemoryDatabase(context)
|
||||
reactivationManager = ReactivationManager(context)
|
||||
|
||||
// Clear any existing state
|
||||
TestDBFactory.clearAllSchedules(database)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestDBFactory.clearAllSchedules(database)
|
||||
database.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* @resilience @combined-scenarios
|
||||
*
|
||||
* Test Scenario A: DST boundary + duplicate delivery + cold start
|
||||
*
|
||||
* Simulates a "worst plausible day" where scheduling and recovery must be
|
||||
* correct under multiple stressors:
|
||||
* - Notification scheduled at DST boundary
|
||||
* - Duplicate delivery events arrive
|
||||
* - App cold starts during recovery
|
||||
*
|
||||
* Acceptance checks:
|
||||
* - Recovery is idempotent (running twice yields identical state)
|
||||
* - Only one logical delivery is recorded after dedupe
|
||||
* - Next scheduled notification time is consistent with DST boundary logic
|
||||
* - No crash, no invalid state written
|
||||
*/
|
||||
@Test
|
||||
fun test_combined_dst_boundary_duplicate_delivery_cold_start() = runBlocking {
|
||||
// Given: Schedule at DST boundary (spring forward scenario)
|
||||
// Use March 10, 2024 2:00 AM EST -> 3:00 AM EDT (America/New_York)
|
||||
val calendar = Calendar.getInstance(TimeZone.getTimeZone("America/New_York"))
|
||||
calendar.set(2024, Calendar.MARCH, 10, 2, 0, 0)
|
||||
calendar.set(Calendar.MILLISECOND, 0)
|
||||
val dstBoundaryTime = calendar.timeInMillis
|
||||
|
||||
val scheduleId = UUID.randomUUID().toString()
|
||||
|
||||
// Inject schedule at DST boundary
|
||||
TestDBFactory.injectDSTBoundarySchedule(
|
||||
database = database,
|
||||
id = scheduleId,
|
||||
dstBoundaryTime = dstBoundaryTime,
|
||||
kind = "notify"
|
||||
)
|
||||
|
||||
// Verify schedule exists
|
||||
val schedule = database.scheduleDao().getById(scheduleId)
|
||||
assertNotNull("Schedule should exist", schedule)
|
||||
assertEquals("Schedule should be at DST boundary", dstBoundaryTime, schedule?.nextRunAt)
|
||||
|
||||
// When: Simulate duplicate delivery by updating schedule twice rapidly
|
||||
// (In real scenario, this would be two delivery events arriving close together)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// First delivery: mark as delivered and schedule next
|
||||
database.scheduleDao().updateRunTimes(
|
||||
id = scheduleId,
|
||||
lastRunAt = currentTime,
|
||||
nextRunAt = dstBoundaryTime + (24 * 60 * 60 * 1000L) // 24 hours later
|
||||
)
|
||||
|
||||
// Simulate duplicate delivery immediately (within dedupe window)
|
||||
Thread.sleep(50) // 0.05 seconds
|
||||
|
||||
// Second delivery attempt (should be deduped)
|
||||
database.scheduleDao().updateRunTimes(
|
||||
id = scheduleId,
|
||||
lastRunAt = currentTime,
|
||||
nextRunAt = dstBoundaryTime + (24 * 60 * 60 * 1000L)
|
||||
)
|
||||
|
||||
// Verify only one next run time was set (deduplication)
|
||||
val scheduleAfterDuplicate = database.scheduleDao().getById(scheduleId)
|
||||
assertNotNull("Schedule should still exist after duplicate", scheduleAfterDuplicate)
|
||||
val nextRunTime = scheduleAfterDuplicate?.nextRunAt
|
||||
assertNotNull("Next run time should be set", nextRunTime)
|
||||
|
||||
// When: Simulate cold start (perform recovery)
|
||||
reactivationManager.performRecovery()
|
||||
|
||||
// Wait for recovery to complete (async operation)
|
||||
Thread.sleep(3000)
|
||||
|
||||
// Then: Verify recovery is idempotent (run again, should produce same state)
|
||||
reactivationManager.performRecovery()
|
||||
Thread.sleep(3000)
|
||||
|
||||
val scheduleAfterRecovery = database.scheduleDao().getById(scheduleId)
|
||||
assertNotNull("Schedule should exist after recovery", scheduleAfterRecovery)
|
||||
|
||||
// Verify next run time is DST-consistent (should be ~24 hours later, accounting for DST)
|
||||
val finalNextRunTime = scheduleAfterRecovery?.nextRunAt
|
||||
assertNotNull("Next run time should be set after recovery", finalNextRunTime)
|
||||
|
||||
// Verify time is in the future and approximately 24 hours later
|
||||
val expectedNextTime = dstBoundaryTime + (24 * 60 * 60 * 1000L)
|
||||
val timeDifference = Math.abs(finalNextRunTime!! - expectedNextTime)
|
||||
assertTrue("Next run time should be approximately 24 hours later (allowing 1 hour for DST)",
|
||||
timeDifference < (60 * 60 * 1000L)) // 1 hour tolerance for DST
|
||||
|
||||
// Verify recovery didn't crash and state is consistent
|
||||
assertTrue("Recovery should complete without crashing under DST + duplicate + cold start", true)
|
||||
}
|
||||
|
||||
/**
|
||||
* @resilience @combined-scenarios
|
||||
*
|
||||
* Test Scenario B: Rollover + duplicate delivery + cold start
|
||||
*
|
||||
* Validates that rollover logic is robust when combined with:
|
||||
* - Duplicate delivery events
|
||||
* - App restart during recovery
|
||||
*
|
||||
* Acceptance checks:
|
||||
* - Rollover is idempotent under re-entry
|
||||
* - Duplicate delivery does not double-apply state transitions
|
||||
* - Cold start reconciliation produces correct "current day" / "next" state
|
||||
*/
|
||||
@Test
|
||||
fun test_combined_rollover_duplicate_delivery_cold_start() = runBlocking {
|
||||
// Given: A schedule that was just delivered (past time)
|
||||
val scheduleId = UUID.randomUUID().toString()
|
||||
val pastTime = System.currentTimeMillis() - (60 * 60 * 1000L) // 1 hour ago
|
||||
|
||||
TestDBFactory.injectPastSchedule(
|
||||
database = database,
|
||||
id = scheduleId,
|
||||
pastTime = pastTime,
|
||||
kind = "notify"
|
||||
)
|
||||
|
||||
// Verify schedule exists
|
||||
val schedule = database.scheduleDao().getById(scheduleId)
|
||||
assertNotNull("Schedule should exist", schedule)
|
||||
assertTrue("Schedule should be in the past", schedule?.nextRunAt!! < System.currentTimeMillis())
|
||||
|
||||
// When: Trigger rollover (first delivery)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val nextDayTime = pastTime + (24 * 60 * 60 * 1000L) // 24 hours later
|
||||
|
||||
database.scheduleDao().updateRunTimes(
|
||||
id = scheduleId,
|
||||
lastRunAt = currentTime,
|
||||
nextRunAt = nextDayTime
|
||||
)
|
||||
|
||||
// Simulate duplicate delivery arriving immediately
|
||||
Thread.sleep(50) // 0.05 seconds
|
||||
|
||||
// Trigger rollover again (duplicate delivery)
|
||||
database.scheduleDao().updateRunTimes(
|
||||
id = scheduleId,
|
||||
lastRunAt = currentTime,
|
||||
nextRunAt = nextDayTime
|
||||
)
|
||||
|
||||
// Verify rollover state tracking prevents duplicate
|
||||
val scheduleAfterDuplicate = database.scheduleDao().getById(scheduleId)
|
||||
assertNotNull("Schedule should exist after duplicate", scheduleAfterDuplicate)
|
||||
assertEquals("Next run time should be set to next day", nextDayTime, scheduleAfterDuplicate?.nextRunAt)
|
||||
|
||||
// When: Simulate cold start (perform recovery)
|
||||
reactivationManager.performRecovery()
|
||||
Thread.sleep(3000)
|
||||
|
||||
// Then: Verify rollover state is correctly reconciled
|
||||
val scheduleAfterRecovery = database.scheduleDao().getById(scheduleId)
|
||||
assertNotNull("Schedule should exist after recovery", scheduleAfterRecovery)
|
||||
|
||||
// Verify rollover idempotency: run recovery again, should produce same state
|
||||
reactivationManager.performRecovery()
|
||||
Thread.sleep(3000)
|
||||
|
||||
val scheduleAfterSecondRecovery = database.scheduleDao().getById(scheduleId)
|
||||
assertNotNull("Schedule should exist after second recovery", scheduleAfterSecondRecovery)
|
||||
|
||||
// Should have consistent state (idempotency)
|
||||
val finalNextRunTime = scheduleAfterSecondRecovery?.nextRunAt
|
||||
assertNotNull("Next run time should be set after second recovery", finalNextRunTime)
|
||||
assertEquals("Recovery should be idempotent - same next run time",
|
||||
nextDayTime, finalNextRunTime)
|
||||
|
||||
// Verify state is correct: should have next day notification, not duplicate current day
|
||||
assertTrue("Next run time should be in the future",
|
||||
finalNextRunTime!! > System.currentTimeMillis())
|
||||
|
||||
assertTrue("Rollover + duplicate + cold start recovery should be idempotent", true)
|
||||
}
|
||||
|
||||
/**
|
||||
* @resilience @combined-scenarios
|
||||
*
|
||||
* Test Scenario C: Schema version + cold start recovery
|
||||
*
|
||||
* Confirms that Room database versioning:
|
||||
* - Is present (database uses version = 2 from DatabaseSchema.kt)
|
||||
* - Does not interfere with recovery logic
|
||||
*
|
||||
* Acceptance checks:
|
||||
* - Database works correctly (implicitly confirms version is correct)
|
||||
* - Version doesn't gate recovery
|
||||
* - Recovery works exactly the same with version present
|
||||
*/
|
||||
@Test
|
||||
fun test_combined_schema_version_cold_start_recovery() = runBlocking {
|
||||
// Given: Database with schema version (Room version = 2 from DatabaseSchema.kt)
|
||||
// Verify database works correctly (implicitly confirms version is correct)
|
||||
val testScheduleId = UUID.randomUUID().toString()
|
||||
val testSchedule = Schedule(
|
||||
id = testScheduleId,
|
||||
kind = "notify",
|
||||
cron = null,
|
||||
clockTime = null,
|
||||
enabled = true,
|
||||
lastRunAt = null,
|
||||
nextRunAt = System.currentTimeMillis(),
|
||||
jitterMs = 0,
|
||||
backoffPolicy = "exp",
|
||||
stateJson = null
|
||||
)
|
||||
database.scheduleDao().upsert(testSchedule)
|
||||
val retrieved = database.scheduleDao().getById(testScheduleId)
|
||||
assertNotNull("Database should work correctly (version is correct)", retrieved)
|
||||
database.scheduleDao().deleteById(testScheduleId)
|
||||
|
||||
// Given: Schedule in database (simulating cold start scenario)
|
||||
val scheduleId = UUID.randomUUID().toString()
|
||||
val futureTime = System.currentTimeMillis() + (60 * 60 * 1000L) // 1 hour from now
|
||||
|
||||
val schedule = Schedule(
|
||||
id = scheduleId,
|
||||
kind = "notify",
|
||||
cron = null,
|
||||
clockTime = null,
|
||||
enabled = true,
|
||||
lastRunAt = null,
|
||||
nextRunAt = futureTime,
|
||||
jitterMs = 0,
|
||||
backoffPolicy = "exp",
|
||||
stateJson = null
|
||||
)
|
||||
|
||||
database.scheduleDao().upsert(schedule)
|
||||
|
||||
// Verify schedule exists
|
||||
val createdSchedule = database.scheduleDao().getById(scheduleId)
|
||||
assertNotNull("Schedule should exist", createdSchedule)
|
||||
|
||||
// When: Perform recovery (schema version check should not interfere)
|
||||
reactivationManager.performRecovery()
|
||||
Thread.sleep(3000)
|
||||
|
||||
// Then: Recovery should work exactly the same (schema version doesn't interfere)
|
||||
val scheduleAfterRecovery = database.scheduleDao().getById(scheduleId)
|
||||
assertNotNull("Schedule should exist after recovery", scheduleAfterRecovery)
|
||||
|
||||
// Verify recovery didn't crash and state is correct
|
||||
assertTrue("Recovery should work identically with schema version present", true)
|
||||
|
||||
assertTrue("Schema version should not interfere with recovery logic", true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.text.Charsets
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28])
|
||||
class DualScheduleHelperTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.clear()
|
||||
.commit()
|
||||
runBlocking {
|
||||
DailyNotificationDatabase.getDatabase(context).contentCacheDao().deleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.clear()
|
||||
.commit()
|
||||
runBlocking {
|
||||
DailyNotificationDatabase.getDatabase(context).contentCacheDao().deleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshCache_skipNotification_returnsNull_evenWithShowDefault() {
|
||||
putDualConfig("""{"fallbackBehavior":"show_default","contentTimeout":600000}""")
|
||||
insertDualCache(
|
||||
payload = FetchWorker.dualEmptyNativeFetchPayload,
|
||||
fetchedAt = System.currentTimeMillis()
|
||||
)
|
||||
assertNull(DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshCache_skipNotification_returnsNull_withSkipFallback() {
|
||||
putDualConfig("""{"fallbackBehavior":"skip","contentTimeout":600000}""")
|
||||
insertDualCache(
|
||||
payload = FetchWorker.dualEmptyNativeFetchPayload,
|
||||
fetchedAt = System.currentTimeMillis()
|
||||
)
|
||||
assertNull(DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleCache_skipNotification_skipFallback_returnsNull() {
|
||||
putDualConfig("""{"fallbackBehavior":"skip","contentTimeout":1000}""")
|
||||
insertDualCache(
|
||||
payload = FetchWorker.dualEmptyNativeFetchPayload,
|
||||
fetchedAt = System.currentTimeMillis() - 60_000L
|
||||
)
|
||||
assertNull(DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleCache_skipNotification_showDefault_usesUserDefaults() {
|
||||
putDualConfig("""{"fallbackBehavior":"show_default","contentTimeout":1000}""")
|
||||
insertDualCache(
|
||||
payload = FetchWorker.dualEmptyNativeFetchPayload,
|
||||
fetchedAt = System.currentTimeMillis() - 60_000L
|
||||
)
|
||||
val content = DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test")
|
||||
assertNotNull(content)
|
||||
assertTrue(content!!.title == "New Activity")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshCache_realPayload_returnsContent() {
|
||||
putDualConfig("""{"fallbackBehavior":"skip","contentTimeout":600000}""")
|
||||
val json = JSONObject()
|
||||
json.put("title", "API Title")
|
||||
json.put("body", "API Body")
|
||||
insertDualCache(
|
||||
payload = json.toString().toByteArray(Charsets.UTF_8),
|
||||
fetchedAt = System.currentTimeMillis()
|
||||
)
|
||||
val content = DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test")
|
||||
assertNotNull(content)
|
||||
assertTrue(content!!.title == "API Title" && content.body == "API Body")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isDualSkipNotificationPayload_emptyObject_false() {
|
||||
assertFalse(FetchWorker.isDualSkipNotificationPayload("{}".toByteArray(Charsets.UTF_8)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isDualSkipNotificationPayload_sentinel_true() {
|
||||
assertTrue(FetchWorker.isDualSkipNotificationPayload(FetchWorker.dualEmptyNativeFetchPayload))
|
||||
}
|
||||
|
||||
private fun putDualConfig(relationshipJson: String) {
|
||||
val root = JSONObject()
|
||||
val user = JSONObject()
|
||||
user.put("enabled", true)
|
||||
user.put("schedule", "0 9 * * *")
|
||||
user.put("title", "New Activity")
|
||||
user.put("body", "Check your starred projects for updates")
|
||||
root.put("userNotification", user)
|
||||
root.put("relationship", JSONObject(relationshipJson))
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, root.toString())
|
||||
.commit()
|
||||
}
|
||||
|
||||
private fun insertDualCache(payload: ByteArray, fetchedAt: Long) {
|
||||
val cache = ContentCache(
|
||||
id = "test_dual_${System.nanoTime()}",
|
||||
fetchedAt = fetchedAt,
|
||||
ttlSeconds = 3600,
|
||||
payload = payload,
|
||||
meta = "test",
|
||||
cacheScope = ContentCacheScope.DUAL
|
||||
)
|
||||
runBlocking {
|
||||
DailyNotificationDatabase.getDatabase(context).contentCacheDao().upsert(cache)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* TestDBFactory.kt
|
||||
*
|
||||
* Test database factory for Android DailyNotification plugin recovery testing
|
||||
* Provides utilities to create test databases with intentionally invalid/corrupt data
|
||||
* for testing recovery scenarios.
|
||||
*
|
||||
* Similar to iOS TestDBFactory.swift, but uses Room in-memory databases
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Test database factory for recovery testing
|
||||
*
|
||||
* Provides utilities to create test databases with intentionally invalid/corrupt data
|
||||
* for testing recovery scenarios.
|
||||
*/
|
||||
object TestDBFactory {
|
||||
|
||||
/**
|
||||
* Create an in-memory test database
|
||||
*
|
||||
* Uses Room.inMemoryDatabaseBuilder() for isolation between tests.
|
||||
* Each test gets a fresh database instance.
|
||||
*
|
||||
* @param context Application context (can be mock/test context)
|
||||
* @return In-memory database instance
|
||||
*/
|
||||
fun createInMemoryDatabase(context: Context): DailyNotificationDatabase {
|
||||
return Room.inMemoryDatabaseBuilder(
|
||||
context,
|
||||
DailyNotificationDatabase::class.java
|
||||
)
|
||||
.allowMainThreadQueries() // Allow synchronous queries for testing
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject invalid schedule record into database
|
||||
*
|
||||
* Creates a schedule with empty ID or null required fields to test
|
||||
* recovery's ability to handle invalid data gracefully.
|
||||
*
|
||||
* @param database Database instance
|
||||
* @param id Schedule ID (can be empty for invalid test)
|
||||
* @param nextRunAt Next run time (can be null or invalid)
|
||||
* @param kind Schedule kind (can be invalid)
|
||||
*/
|
||||
fun injectInvalidSchedule(
|
||||
database: DailyNotificationDatabase,
|
||||
id: String = "",
|
||||
nextRunAt: Long? = null,
|
||||
kind: String = "notify"
|
||||
) {
|
||||
val schedule = Schedule(
|
||||
id = id,
|
||||
kind = kind,
|
||||
cron = null,
|
||||
clockTime = null,
|
||||
enabled = true,
|
||||
lastRunAt = null,
|
||||
nextRunAt = nextRunAt,
|
||||
jitterMs = 0,
|
||||
backoffPolicy = "exp",
|
||||
stateJson = null
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
try {
|
||||
database.scheduleDao().upsert(schedule)
|
||||
println("TestDBFactory: Injected invalid schedule: id='$id', nextRunAt=$nextRunAt")
|
||||
} catch (e: Exception) {
|
||||
println("TestDBFactory: Failed to inject invalid schedule: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject schedule with null/empty required fields
|
||||
*
|
||||
* Tests recovery's ability to handle null fields gracefully.
|
||||
*/
|
||||
fun injectScheduleWithNullFields(database: DailyNotificationDatabase) {
|
||||
injectInvalidSchedule(
|
||||
database = database,
|
||||
id = "",
|
||||
nextRunAt = null,
|
||||
kind = ""
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject duplicate schedule records (same ID, different times)
|
||||
*
|
||||
* Creates multiple schedule entries with the same ID but different
|
||||
* nextRunAt times to test duplicate delivery deduplication.
|
||||
*
|
||||
* @param database Database instance
|
||||
* @param id Schedule ID (same for all duplicates)
|
||||
* @param times List of nextRunAt times (one per duplicate)
|
||||
* @param kind Schedule kind
|
||||
*/
|
||||
fun injectDuplicateSchedules(
|
||||
database: DailyNotificationDatabase,
|
||||
id: String,
|
||||
times: List<Long>,
|
||||
kind: String = "notify"
|
||||
) {
|
||||
runBlocking {
|
||||
times.forEach { time ->
|
||||
val schedule = Schedule(
|
||||
id = id,
|
||||
kind = kind,
|
||||
cron = null,
|
||||
clockTime = null,
|
||||
enabled = true,
|
||||
lastRunAt = null,
|
||||
nextRunAt = time,
|
||||
jitterMs = 0,
|
||||
backoffPolicy = "exp",
|
||||
stateJson = null
|
||||
)
|
||||
|
||||
try {
|
||||
// Use upsert to allow overwriting (for testing duplicate delivery scenarios)
|
||||
database.scheduleDao().upsert(schedule)
|
||||
println("TestDBFactory: Injected duplicate schedule: id='$id', nextRunAt=$time")
|
||||
} catch (e: Exception) {
|
||||
// Room will throw on duplicate primary key - this is expected
|
||||
// For testing duplicate delivery, we need to use delivery records instead
|
||||
println("TestDBFactory: Duplicate schedule insert failed (expected): ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject schedule at DST boundary
|
||||
*
|
||||
* Creates a schedule with nextRunAt at a DST transition time
|
||||
* to test recovery's handling of DST boundary transitions.
|
||||
*
|
||||
* @param database Database instance
|
||||
* @param id Schedule ID
|
||||
* @param dstBoundaryTime Time at DST boundary (epoch ms)
|
||||
* @param kind Schedule kind
|
||||
*/
|
||||
fun injectDSTBoundarySchedule(
|
||||
database: DailyNotificationDatabase,
|
||||
id: String,
|
||||
dstBoundaryTime: Long,
|
||||
kind: String = "notify"
|
||||
) {
|
||||
val schedule = Schedule(
|
||||
id = id,
|
||||
kind = kind,
|
||||
cron = null,
|
||||
clockTime = null,
|
||||
enabled = true,
|
||||
lastRunAt = null,
|
||||
nextRunAt = dstBoundaryTime,
|
||||
jitterMs = 0,
|
||||
backoffPolicy = "exp",
|
||||
stateJson = null
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
try {
|
||||
database.scheduleDao().upsert(schedule)
|
||||
println("TestDBFactory: Injected DST boundary schedule: id='$id', time=$dstBoundaryTime")
|
||||
} catch (e: Exception) {
|
||||
println("TestDBFactory: Failed to inject DST boundary schedule: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject past schedule (already delivered, needs rollover)
|
||||
*
|
||||
* Creates a schedule with nextRunAt in the past to test
|
||||
* rollover recovery scenarios.
|
||||
*
|
||||
* @param database Database instance
|
||||
* @param id Schedule ID
|
||||
* @param pastTime Time in the past (epoch ms)
|
||||
* @param kind Schedule kind
|
||||
*/
|
||||
fun injectPastSchedule(
|
||||
database: DailyNotificationDatabase,
|
||||
id: String,
|
||||
pastTime: Long,
|
||||
kind: String = "notify"
|
||||
) {
|
||||
val schedule = Schedule(
|
||||
id = id,
|
||||
kind = kind,
|
||||
cron = null,
|
||||
clockTime = null,
|
||||
enabled = true,
|
||||
lastRunAt = null,
|
||||
nextRunAt = pastTime,
|
||||
jitterMs = 0,
|
||||
backoffPolicy = "exp",
|
||||
stateJson = null
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
try {
|
||||
database.scheduleDao().upsert(schedule)
|
||||
println("TestDBFactory: Injected past schedule: id='$id', time=$pastTime")
|
||||
} catch (e: Exception) {
|
||||
println("TestDBFactory: Failed to inject past schedule: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all schedules from database
|
||||
*
|
||||
* Useful for test cleanup between scenarios.
|
||||
*
|
||||
* @param database Database instance
|
||||
*/
|
||||
fun clearAllSchedules(database: DailyNotificationDatabase) {
|
||||
runBlocking {
|
||||
try {
|
||||
val allSchedules = database.scheduleDao().getAll()
|
||||
allSchedules.forEach { schedule ->
|
||||
database.scheduleDao().deleteById(schedule.id)
|
||||
}
|
||||
println("TestDBFactory: Cleared all schedules")
|
||||
} catch (e: Exception) {
|
||||
println("TestDBFactory: Failed to clear schedules: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.timesafari.dailynotification',
|
||||
appId: 'org.timesafari.dailynotification',
|
||||
appName: 'DailyNotification Test App',
|
||||
webDir: 'www',
|
||||
server: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{
|
||||
"name": "DailyNotification",
|
||||
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
**Purpose:** Single navigation hub for active documentation; separates contracts, progress truth, guides, and archived/reference-only material.
|
||||
**Owner:** Development Team
|
||||
**Last Updated:** 2025-12-22
|
||||
**Last Updated:** 2025-12-23
|
||||
**Status:** active
|
||||
**Baseline Tag:** `v1.0.11-p0-p1.4-complete`
|
||||
**Baseline:** See `doc/progress/00-STATUS.md` for current baseline tag
|
||||
|
||||
This index provides organized access to all documentation in the repository. For a complete audit trail of file movements, see [CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md).
|
||||
This index provides organized access to all documentation in the repository. For a complete audit trail of file movements, see [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -14,10 +14,12 @@ This index provides organized access to all documentation in the repository. For
|
||||
|
||||
These are **policy-as-code**. Any gate (push, release, publish) MUST call `./ci/run.sh`.
|
||||
|
||||
- **System Invariants:** `docs/SYSTEM_INVARIANTS.md` — Single authoritative document naming and explaining all enforced invariants
|
||||
- **System Invariants:** `doc/SYSTEM_INVARIANTS.md` — Single authoritative document naming and explaining all enforced invariants
|
||||
- **Local CI Contract:** `./ci/run.sh` — Single source of truth for CI/release gates
|
||||
- **Verification / Invariants:** `./scripts/verify.sh` — Encodes packaging, core-purity, and build invariants
|
||||
- **CI Usage & Setup:** `ci/README.md` — Local CI documentation
|
||||
- **Performance Characteristics:** `doc/PERFORMANCE.md` — Performance characteristics and benchmarks
|
||||
- **Troubleshooting Guide:** `doc/TROUBLESHOOTING.md` — Common issues and solutions
|
||||
|
||||
---
|
||||
|
||||
@@ -32,19 +34,36 @@ These files define the current truth about project state, decisions, and verific
|
||||
- **[04-PARITY-MATRIX.md](./progress/04-PARITY-MATRIX.md)** — iOS/Android parity tracking
|
||||
- **[05-CHATGPT-FEEDBACK-PACKAGE.md](./progress/05-CHATGPT-FEEDBACK-PACKAGE.md)** — AI collaboration package
|
||||
- **[P2-DESIGN.md](./progress/P2-DESIGN.md)** — P2 scope, invariants, and acceptance criteria (design-only)
|
||||
- **[P2.1-REFACTORING-COMPLETE.md](./progress/P2.1-REFACTORING-COMPLETE.md)** — P2.1 native plugin refactoring complete summary (Android + iOS)
|
||||
- **[TODO.md](./progress/TODO.md)** — Project TODO and improvement tracking
|
||||
- **[TODAY_SUMMARY.md](./progress/TODAY_SUMMARY.md)** — Dated work summaries
|
||||
- **[SESSION_RECONSTITUTION.md](./progress/SESSION_RECONSTITUTION.md)** — P2.1 Batch A session reconstitution
|
||||
- **[BATCH_A_COMPLETION_SUMMARY.md](./progress/BATCH_A_COMPLETION_SUMMARY.md)** — P2.1 Batch A completion summary
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
- **[Getting Started Guide](./GETTING_STARTED.md)** — Installation, platform setup, and basic usage
|
||||
|
||||
## Examples
|
||||
|
||||
- **[Quick Start](./examples/QUICK_START.md)** — Minimal working example
|
||||
- **[Common Patterns](./examples/COMMON_PATTERNS.md)** — Common integration patterns and best practices
|
||||
|
||||
---
|
||||
|
||||
## Archive & Reference-only
|
||||
|
||||
- **`docs/_archive/`** — Historical artifacts, preserved for audit trail (not part of active doc surface)
|
||||
- `docs/_archive/2025-legacy-doc/` — Legacy documentation from 2025
|
||||
- **`doc/_archive/`** — Historical artifacts, preserved for audit trail (not part of active doc surface)
|
||||
- `doc/_archive/PR_DESCRIPTION.md`, `MERGE_READY_SUMMARY.md` — One-off PR/merge artifacts (2025-10)
|
||||
- `doc/_archive/2025-legacy-doc/` — Legacy documentation from 2025
|
||||
- [IMPLEMENTATION_CHECKLIST_LEGACY.md](./_archive/2025-legacy-doc/IMPLEMENTATION_CHECKLIST_LEGACY.md) — iOS Phase 1 checklist (historical)
|
||||
- `docs/_archive/2025-12-16-consolidation/` — 2025-12-16 consolidation artifacts (audit trail)
|
||||
- `doc/_archive/2025-12-16-consolidation/` — 2025-12-16 consolidation artifacts (audit trail)
|
||||
- [CONSOLIDATION_COMPLETE.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_COMPLETE.md) — Consolidation completion summary
|
||||
- [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md) — Complete file mapping (139 files)
|
||||
- **`docs/_reference/`** — Reference templates (not used by current workflow)
|
||||
- `docs/_reference/github-actions-ci.yml` — GitHub Actions CI template (reference only)
|
||||
- **`doc/_reference/`** — Reference templates (not used by current workflow)
|
||||
- `doc/_reference/github-actions-ci.yml` — GitHub Actions CI template (reference only)
|
||||
|
||||
---
|
||||
|
||||
@@ -54,7 +73,7 @@ These files define the current truth about project state, decisions, and verific
|
||||
|
||||
1. **[README.md](../README.md)** - Project overview and getting started
|
||||
2. **[ARCHITECTURE.md](../ARCHITECTURE.md)** - System architecture
|
||||
3. **[docs/integration/QUICK_START.md](./integration/QUICK_START.md)** - Quick integration guide
|
||||
3. **[doc/integration/QUICK_START.md](./integration/QUICK_START.md)** - Quick integration guide
|
||||
4. **[BUILDING.md](../BUILDING.md)** - Build instructions
|
||||
|
||||
---
|
||||
@@ -76,7 +95,7 @@ These files define the current truth about project state, decisions, and verific
|
||||
|
||||
## Integration Documentation
|
||||
|
||||
**Location:** `docs/integration/`
|
||||
**Location:** `doc/integration/`
|
||||
|
||||
- **[INTEGRATION_GUIDE.md](./integration/INTEGRATION_GUIDE.md)** - Complete integration guide
|
||||
- **[QUICK_START.md](./integration/QUICK_START.md)** - Quick integration path
|
||||
@@ -90,7 +109,7 @@ These files define the current truth about project state, decisions, and verific
|
||||
|
||||
### iOS
|
||||
|
||||
**Location:** `docs/platform/ios/`
|
||||
**Location:** `doc/platform/ios/`
|
||||
|
||||
- **[IOS_IMPLEMENTATION_CHECKLIST.md](./platform/ios/IOS_IMPLEMENTATION_CHECKLIST.md)** - iOS implementation checklist
|
||||
- **[IMPLEMENTATION_DIRECTIVE.md](./platform/ios/IMPLEMENTATION_DIRECTIVE.md)** - iOS implementation directive
|
||||
@@ -105,7 +124,7 @@ These files define the current truth about project state, decisions, and verific
|
||||
|
||||
### Android
|
||||
|
||||
**Location:** `docs/platform/android/`
|
||||
**Location:** `doc/platform/android/`
|
||||
|
||||
- **[IMPLEMENTATION_DIRECTIVE.md](./platform/android/IMPLEMENTATION_DIRECTIVE.md)** - Primary Android implementation directive
|
||||
- **[PHASE1_DIRECTIVE.md](./platform/android/PHASE1_DIRECTIVE.md)** - Phase 1 directive
|
||||
@@ -121,7 +140,7 @@ These files define the current truth about project state, decisions, and verific
|
||||
|
||||
## Testing Documentation
|
||||
|
||||
**Location:** `docs/testing/`
|
||||
**Location:** `doc/testing/`
|
||||
|
||||
### General Testing
|
||||
|
||||
@@ -164,7 +183,7 @@ Test app-specific documentation remains with the test apps but is indexed here:
|
||||
|
||||
## Alarm System Documentation
|
||||
|
||||
**Location:** `docs/alarms/`
|
||||
**Location:** `doc/alarms/`
|
||||
|
||||
The alarm system documentation is well-organized and kept in its current location:
|
||||
|
||||
@@ -184,7 +203,7 @@ The alarm system documentation is well-organized and kept in its current locatio
|
||||
|
||||
## Design & Research Documentation
|
||||
|
||||
**Location:** `docs/design/`
|
||||
**Location:** `doc/design/`
|
||||
|
||||
- **[STARRED_PROJECTS_POLLING_IMPLEMENTATION.md](./design/STARRED_PROJECTS_POLLING_IMPLEMENTATION.md)** - Starred projects polling implementation
|
||||
- **[exploration-findings-initial.md](./design/exploration-findings-initial.md)** - Initial exploration findings
|
||||
@@ -194,63 +213,83 @@ The alarm system documentation is well-organized and kept in its current locatio
|
||||
|
||||
---
|
||||
|
||||
## Feature-Specific Documentation
|
||||
## Architecture (Storage & Core Tech)
|
||||
|
||||
**Location:** `docs/`
|
||||
**Location:** `doc/architecture/`
|
||||
|
||||
### Storage & Database
|
||||
- **[CROSS_PLATFORM_STORAGE_PATTERN.md](./architecture/CROSS_PLATFORM_STORAGE_PATTERN.md)** - Cross-platform storage pattern
|
||||
- **[DATABASE_INTERFACES.md](./architecture/DATABASE_INTERFACES.md)** - Database interfaces
|
||||
- **[DATABASE_INTERFACES_IMPLEMENTATION.md](./architecture/DATABASE_INTERFACES_IMPLEMENTATION.md)** - Database interfaces implementation
|
||||
- **[NATIVE_FETCHER_CONFIGURATION.md](./architecture/NATIVE_FETCHER_CONFIGURATION.md)** - Native fetcher configuration
|
||||
|
||||
- **[CROSS_PLATFORM_STORAGE_PATTERN.md](./CROSS_PLATFORM_STORAGE_PATTERN.md)** - Cross-platform storage pattern
|
||||
- **[DATABASE_INTERFACES.md](./DATABASE_INTERFACES.md)** - Database interfaces
|
||||
- **[DATABASE_INTERFACES_IMPLEMENTATION.md](./DATABASE_INTERFACES_IMPLEMENTATION.md)** - Database interfaces implementation
|
||||
---
|
||||
|
||||
### Native Fetcher
|
||||
## Deployment
|
||||
|
||||
- **[NATIVE_FETCHER_CONFIGURATION.md](./NATIVE_FETCHER_CONFIGURATION.md)** - Native fetcher configuration
|
||||
**Location:** `doc/deployment/`
|
||||
|
||||
### Prefetch & Scheduling
|
||||
- **[deployment-guide.md](./deployment/deployment-guide.md)** - Deployment guide (primary)
|
||||
- **[DEPLOYMENT_CHECKLIST.md](./deployment/DEPLOYMENT_CHECKLIST.md)** - Deployment checklist
|
||||
|
||||
- **[prefetch-scheduling-diagnosis.md](./prefetch-scheduling-diagnosis.md)** - Prefetch scheduling diagnosis
|
||||
- **[prefetch-scheduling-trace.md](./prefetch-scheduling-trace.md)** - Prefetch scheduling trace
|
||||
---
|
||||
|
||||
### Recovery & Startup
|
||||
## Compliance & Operations
|
||||
|
||||
- **[app-startup-recovery-solution.md](./app-startup-recovery-solution.md)** - App startup recovery solution
|
||||
**Location:** `doc/compliance/`
|
||||
|
||||
### Platform Capabilities
|
||||
- **[accessibility-localization.md](./compliance/accessibility-localization.md)** - Accessibility and localization
|
||||
- **[legal-store-compliance.md](./compliance/legal-store-compliance.md)** - Legal and store compliance
|
||||
- **[observability-dashboards.md](./compliance/observability-dashboards.md)** - Observability dashboards
|
||||
|
||||
- **[platform-capability-reference.md](./platform-capability-reference.md)** - Platform capability reference
|
||||
- **[plugin-requirements-implementation.md](./plugin-requirements-implementation.md)** - Plugin requirements implementation
|
||||
---
|
||||
|
||||
### Feature Implementation
|
||||
## Feature-Specific (Integration, Design, Progress)
|
||||
|
||||
- **[getting-valid-plan-ids.md](./getting-valid-plan-ids.md)** - Getting valid plan IDs
|
||||
- **[host-request-configuration.md](./host-request-configuration.md)** - Host request configuration
|
||||
- **[hydrate-plan-implementation-guide.md](./hydrate-plan-implementation-guide.md)** - Hydrate plan implementation guide
|
||||
- **[user-zero-stars-implementation.md](./user-zero-stars-implementation.md)** - User zero stars implementation
|
||||
### Integration (`doc/integration/`)
|
||||
|
||||
### Compliance & Operations
|
||||
- **[getting-valid-plan-ids.md](./integration/getting-valid-plan-ids.md)** - Getting valid plan IDs
|
||||
- **[host-request-configuration.md](./integration/host-request-configuration.md)** - Host request configuration
|
||||
- **[hydrate-plan-implementation-guide.md](./integration/hydrate-plan-implementation-guide.md)** - Hydrate plan implementation guide
|
||||
- **[user-zero-stars-implementation.md](./integration/user-zero-stars-implementation.md)** - User zero stars implementation
|
||||
- **[capacitor-platform-service-clean-changes.md](./integration/capacitor-platform-service-clean-changes.md)** - Capacitor platform service changes
|
||||
- **[ACTION_PLAN_INTEGRATION_FIXES.md](./integration/ACTION_PLAN_INTEGRATION_FIXES.md)** - Integration fixes action plan
|
||||
|
||||
- **[accessibility-localization.md](./accessibility-localization.md)** - Accessibility and localization
|
||||
- **[legal-store-compliance.md](./legal-store-compliance.md)** - Legal and store compliance
|
||||
- **[observability-dashboards.md](./observability-dashboards.md)** - Observability dashboards
|
||||
### Design (`doc/design/`) — plans, prefetch, recovery
|
||||
|
||||
### Deployment
|
||||
- **[P1.5-CONSOLIDATION-PLAN.md](./design/P1.5-CONSOLIDATION-PLAN.md)** - P1.5 consolidation plan
|
||||
- **[P1.5-STEP4-CLUSTERS.md](./design/P1.5-STEP4-CLUSTERS.md)** - P1.5 step 4 clusters
|
||||
- **[P1.5-STEP4-DECISIONS.md](./design/P1.5-STEP4-DECISIONS.md)** - P1.5 step 4 decisions
|
||||
- **[P2.1-NATIVE-REFACTORING-ANALYSIS.md](./design/P2.1-NATIVE-REFACTORING-ANALYSIS.md)** - P2.1 native refactoring analysis
|
||||
- **[FEEDBACK-RESPONSE-PLAN.md](./design/FEEDBACK-RESPONSE-PLAN.md)** - Feedback response plan
|
||||
- **[prefetch-scheduling-diagnosis.md](./design/prefetch-scheduling-diagnosis.md)** - Prefetch scheduling diagnosis
|
||||
- **[prefetch-scheduling-trace.md](./design/prefetch-scheduling-trace.md)** - Prefetch scheduling trace
|
||||
- **[app-startup-recovery-solution.md](./design/app-startup-recovery-solution.md)** - App startup recovery solution
|
||||
- **[plugin-requirements-implementation.md](./design/plugin-requirements-implementation.md)** - Plugin requirements implementation
|
||||
|
||||
- **[deployment-guide.md](./deployment-guide.md)** - Deployment guide (primary)
|
||||
- **[DEPLOYMENT_CHECKLIST.md](./DEPLOYMENT_CHECKLIST.md)** - Deployment checklist
|
||||
- **[DEPLOYMENT_SUMMARY.md](./DEPLOYMENT_SUMMARY.md)** - Deployment summary
|
||||
### Progress (`doc/progress/`)
|
||||
|
||||
### Utilities
|
||||
- **[DEPLOYMENT_SUMMARY.md](./progress/DEPLOYMENT_SUMMARY.md)** - Deployment summary
|
||||
- **[TODO-CLASSIFICATION.md](./progress/TODO-CLASSIFICATION.md)** - TODO classification
|
||||
|
||||
### Platform — Android (`doc/platform/android/`)
|
||||
|
||||
- **[CONSUMING_APP_ANDROID_NOTES.md](./platform/android/CONSUMING_APP_ANDROID_NOTES.md)** - Consuming app Android notes
|
||||
- **[CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md](./platform/android/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md)** - Optional Android ID cleanup
|
||||
- **[TIMESAFARI_ANDROID_COMPARISON.md](./platform/android/TIMESAFARI_ANDROID_COMPARISON.md)** - TimeSafari Android comparison
|
||||
|
||||
### Platform Capabilities (canonical)
|
||||
|
||||
- **[01-platform-capability-reference.md](./alarms/01-platform-capability-reference.md)** - Platform capability reference (canonical; deprecated root copy archived in `_archive/`)
|
||||
|
||||
### Utilities (meta)
|
||||
|
||||
- **[file-organization-summary.md](./file-organization-summary.md)** - File organization summary
|
||||
- **[capacitor-platform-service-clean-changes.md](./capacitor-platform-service-clean-changes.md)** - Capacitor platform service changes
|
||||
|
||||
---
|
||||
|
||||
## AI / Prompting / Automation Artifacts
|
||||
|
||||
**Location:** `docs/ai/`
|
||||
**Location:** `doc/ai/`
|
||||
|
||||
These are derived operational artifacts for AI-assisted development:
|
||||
|
||||
@@ -266,9 +305,9 @@ These are derived operational artifacts for AI-assisted development:
|
||||
|
||||
## Archive Documentation
|
||||
|
||||
**Location:** `docs/archive/2025-legacy-doc/`
|
||||
**Location:** `doc/archive/2025-legacy-doc/`
|
||||
|
||||
Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md) for complete archive listing.
|
||||
Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md) for complete archive listing.
|
||||
|
||||
**Notable archived content:**
|
||||
- Historical directives (`doc/directives/`)
|
||||
@@ -286,18 +325,20 @@ Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](.
|
||||
|
||||
| Category | Count | Location |
|
||||
|----------|-------|----------|
|
||||
| **Core Documentation** | 8 | Root + `docs/` |
|
||||
| **Integration** | 5 | `docs/integration/` |
|
||||
| **Platform (iOS)** | 10 | `docs/platform/ios/` |
|
||||
| **Platform (Android)** | 9 | `docs/platform/android/` |
|
||||
| **Testing** | 13 | `docs/testing/` |
|
||||
| **Alarms** | 11 | `docs/alarms/` |
|
||||
| **Design & Research** | 5 | `docs/design/` |
|
||||
| **Feature-Specific** | 18 | `docs/` |
|
||||
| **AI Artifacts** | 7 | `docs/ai/` |
|
||||
| **Deployment** | 3 | `docs/` |
|
||||
| **Core Documentation** | 8 | Root + `doc/` |
|
||||
| **Integration** | 5 | `doc/integration/` |
|
||||
| **Platform (iOS)** | 10 | `doc/platform/ios/` |
|
||||
| **Platform (Android)** | 9 | `doc/platform/android/` |
|
||||
| **Testing** | 13 | `doc/testing/` |
|
||||
| **Alarms** | 11 | `doc/alarms/` |
|
||||
| **Design & Research** | 5 | `doc/design/` |
|
||||
| **Architecture** | 4 | `doc/architecture/` |
|
||||
| **Deployment** | 2 | `doc/deployment/` |
|
||||
| **Compliance** | 3 | `doc/compliance/` |
|
||||
| **Feature-Specific (integration, design, progress, platform)** | 14 | `doc/integration/`, `doc/design/`, `doc/progress/`, `doc/platform/android/` |
|
||||
| **AI Artifacts** | 7 | `doc/ai/` |
|
||||
| **Test Apps** | 20+ | `test-apps/*/` |
|
||||
| **Archive** | 29 | `docs/archive/2025-legacy-doc/` |
|
||||
| **Archive** | 29 | `doc/archive/2025-legacy-doc/` |
|
||||
|
||||
### By Status
|
||||
|
||||
@@ -320,13 +361,13 @@ Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](.
|
||||
- **Test on Android** → See [Android Test App Docs](../test-apps/android-test-app/docs/)
|
||||
- **Understand alarms** → Browse [Alarms Documentation](./alarms/)
|
||||
- **Troubleshoot** → Check platform-specific troubleshooting guides
|
||||
- **Deploy** → See [Deployment Guide](./deployment-guide.md)
|
||||
- **Deploy** → See [Deployment Guide](./deployment/deployment-guide.md)
|
||||
|
||||
### By Platform
|
||||
|
||||
- **iOS** → `docs/platform/ios/`
|
||||
- **Android** → `docs/platform/android/`
|
||||
- **Cross-Platform** → `docs/alarms/`, `docs/integration/`
|
||||
- **iOS** → `doc/platform/ios/`
|
||||
- **Android** → `doc/platform/android/`
|
||||
- **Cross-Platform** → `doc/alarms/`, `doc/integration/`
|
||||
|
||||
### By Phase
|
||||
|
||||
@@ -340,19 +381,19 @@ Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](.
|
||||
|
||||
### Updating This Index
|
||||
|
||||
**Index-first rule:** New docs must be linked from `docs/00-INDEX.md` or explicitly placed under `_archive/` / `_reference/`.
|
||||
**Index-first rule:** New docs must be linked from `doc/00-INDEX.md` or explicitly placed under `_archive/` / `_reference/`.
|
||||
|
||||
When adding new documentation:
|
||||
|
||||
1. Place file in appropriate category directory
|
||||
2. Add entry to this index in the correct section
|
||||
3. Update the "Document Map by Category" table if needed
|
||||
4. Update [CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md) if consolidating
|
||||
4. Update [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md) if consolidating
|
||||
|
||||
### Consolidation Reference
|
||||
|
||||
For complete consolidation audit trail, see:
|
||||
- **[CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md)** - Complete file mapping
|
||||
- **[CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md)** - Complete file mapping
|
||||
|
||||
---
|
||||
|
||||
173
doc/COMPLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md
Normal file
173
doc/COMPLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Completion Plan: scheduleDualNotification (Plugin + Consuming App)
|
||||
|
||||
**Purpose:** Checklist of what needs to be done to complete the dual-schedule (New Activity) implementation in the plugin and in the consuming app. For review before making changes.
|
||||
|
||||
**Status:** Cron parsing, stable dual ID, cancelDualSchedule, and updateDualScheduleConfig are implemented on iOS and Android. The **relationship** (contentTimeout / fallbackBehavior) is implemented: dual config is persisted; on iOS the pending dual notification is updated when the fetch completes; on Android the Worker resolves config + cache at fire time and shows the resolved title/body.
|
||||
|
||||
**Related:** Consuming app feedback doc `plugin-feedback-ios-scheduleDualNotification.md`; plugin `src/definitions.ts` (`DualScheduleConfiguration`, `scheduleDualNotification`, `cancelDualSchedule`).
|
||||
|
||||
**For app-side implementation (crowd-funder-for-time-pwa):** All plugin work below is **done**. Use this doc in the app repo to implement app changes. Require plugin **v2.1.0+** (or current local plugin with dual schedule + relationship). Focus on **§2** and the **Consuming app** rows in **§3**. Key tasks: (1) Verify plugin is linked and built so `scheduleDualNotification` is not UNIMPLEMENTED (§2.1); (2) In Edit-time flow, call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when user saves new time, with fallback to `scheduleDualNotification` (§2.4). App paths: `src/views/AccountViewView.vue` (e.g. `editNewActivityNotification()` ~1504–1520), `src/services/notifications/dualScheduleConfig.ts`, `src/services/notifications/NativeNotificationService.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Context: Two notification types in the consuming app
|
||||
|
||||
The consuming app (crowd-funder-for-time-pwa) has **two independent notification types**, both using a user-set time from the app DB and firing once a day at that time:
|
||||
|
||||
| Type | Content source | Plugin API | Cancel API |
|
||||
|------|----------------|------------|------------|
|
||||
| **Daily Reminder** | User-set text from app DB | `scheduleDailyReminder(id, title, body, time, …)` | `cancelDailyReminder({ reminderId })` |
|
||||
| **New Activity** | API-fetched content (native fetcher) | `scheduleDualNotification({ config })` | `cancelDualSchedule()` |
|
||||
|
||||
- Both can be **on at the same time**; the app turns each on/off and sets its time independently.
|
||||
- **Isolation requirement:** Cancelling one must not affect the other. So:
|
||||
- `cancelDualSchedule()` must cancel **only** the dual schedule (content fetch + user notification for New Activity). It must **not** remove Daily Reminder notifications (iOS uses `reminder_<id>` e.g. `reminder_daily_timesafari_reminder`).
|
||||
- `cancelDailyReminder({ reminderId })` must cancel **only** that reminder; it must **not** cancel the dual content-fetch task or New Activity user notification.
|
||||
|
||||
The completion plan below assumes this separation. Any new plugin code (e.g. iOS `cancelDualSchedule`, or dual user-notification identifiers) must preserve it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Plugin (daily-notification-plugin) — iOS
|
||||
|
||||
### 1.1 Fix UNIMPLEMENTED (bridge / integration)
|
||||
|
||||
- **Ensure the native method is actually invoked.** Capacitor returns `UNIMPLEMENTED` when the bridge doesn't call the native handler. In the **consuming app**:
|
||||
- Confirm the app depends on this plugin (or an up-to-date fork) and that `npx cap sync ios` / build includes the plugin's native code.
|
||||
- If they use Capacitor 6, check the [Capacitor 6 plugin registration / UNIMPLEMENTED issues](https://github.com/ionic-team/capacitor-docs/issues/325) and apply any required registration or build fixes so `scheduleDualNotification` is exposed and called.
|
||||
- No code changes are required in the plugin for this; the handler and registration already exist.
|
||||
|
||||
### 1.2 Cron parsing (align with Android)
|
||||
|
||||
- **Replace the stub `calculateNextRunTime(from:)`** in `ios/Plugin/DailyNotificationPlugin.swift` (lines 767–771) with real cron parsing.
|
||||
- **Reference:** Android's `calculateNextRunTime(schedule: String)` in `DailyNotificationPlugin.kt` (lines 2336–2378): supports `"minute hour * * *"`, uses device timezone, returns next occurrence (today or tomorrow).
|
||||
- **Behavior:** For a given cron string (e.g. `"25 18 * * *"`), compute the next run as `Date`/`TimeInterval` and use that for:
|
||||
- `BGAppRefreshTaskRequest.earliestBeginDate`
|
||||
- `UNCalendarNotificationTrigger` (or equivalent) for the user notification so it fires at the correct local time daily.
|
||||
- Right now the implementation ignores the cron and always uses 86400 seconds, so schedules are wrong.
|
||||
|
||||
### 1.3 Use `relationship` (contentTimeout + fallbackBehavior) — implemented
|
||||
|
||||
- **Intent:** When the user notification fires at `userNotification.schedule`, show **API-derived content** if the fetch completed and is within `relationship.contentTimeout`; otherwise show `userNotification.title` / `userNotification.body` (per `fallbackBehavior: "show_default"`).
|
||||
- **Implemented:** Dual config (userNotification + relationship) is persisted when scheduling/updating. On **iOS**, after the content fetch completes in `handleBackgroundFetch`, the plugin replaces the pending dual notification with resolved title/body (from cache if within contentTimeout, else default). On **Android**, when the Worker runs for a `dual_notify_*` schedule, it loads the persisted config and content cache and resolves title/body at fire time, then displays one notification with that content. See **§1.3a** for implementation details (retained for reference).
|
||||
|
||||
### 1.3a Implementation plan: relationship (contentTimeout / fallbackBehavior)
|
||||
|
||||
Implement when ready so the New Activity notification can show API content when the fetch succeeds in time, or default text otherwise.
|
||||
|
||||
**Prerequisite: persist dual config (both platforms)**
|
||||
|
||||
When `scheduleDualNotification` or `updateDualScheduleConfig` runs, persist enough of the config for later use:
|
||||
|
||||
- **userNotification:** `schedule` (cron), `title`, `body` (and any other fields needed to build the notification).
|
||||
- **relationship:** `contentTimeout`, `fallbackBehavior`.
|
||||
|
||||
So when we later resolve content (after fetch or at fire time), we have the default text and the rules. No new API surface; store what we already receive.
|
||||
|
||||
- **iOS:** e.g. a single key in UserDefaults (or alongside `native_fetcher_config`), e.g. `dual_schedule_config`, with this structure (e.g. JSON).
|
||||
- **Android:** e.g. SharedPreferences or a keyed config; the code that runs at notification time (or after fetch) must be able to read it.
|
||||
|
||||
**iOS: update the pending notification when the fetch completes**
|
||||
|
||||
- When the content fetch runs (e.g. in `handleBackgroundFetch`), we already store the result. After a successful fetch:
|
||||
1. **Read the persisted dual config.** If none (no dual schedule or legacy flow), skip.
|
||||
2. **Resolve content:** Load the content just stored (or latest from cache) and its timestamp. If content exists and `(now - contentTimestamp) <= relationship.contentTimeout`, use that title/body; else use `userNotification.title` / `userNotification.body`.
|
||||
3. **Replace the pending dual notification:** Remove the pending request with identifier `dualNotificationRequestIdentifier`, then add a new `UNNotificationRequest` with the same identifier, the same trigger (recompute from `userNotification.schedule` in stored config), and the resolved title/body.
|
||||
|
||||
- **Edge cases:** If the fetch completes after the notification time (next trigger already in the past), do not replace. If the fetch fails, leave the existing pending notification as-is (it already has default title/body).
|
||||
|
||||
**Android: resolve content when the notification is about to fire**
|
||||
|
||||
- On Android the “notification” is an alarm that fires at notify time and then runs code (e.g. `NotifyReceiver` / `DailyNotificationReceiver`) to display the notification. We cannot change the alarm’s “content” after the fact the same way as on iOS; we decide what to show when the alarm fires.
|
||||
- **Persist dual config** when scheduling (same as above), keyed so the receiver can find it (e.g. by schedule id or a single “current dual config” key).
|
||||
- **When the receiver runs** for a dual schedule (e.g. for `dual_notify_*` or the known dual schedule id): load the persisted dual config, load the latest content from the content cache and its timestamp, apply relationship (use cache if within `contentTimeout`, else default), then show one notification with that resolved title/body.
|
||||
- The receiver must be dual-aware: for dual schedules it resolves title/body from config + cache + relationship instead of using fixed payload from the alarm.
|
||||
|
||||
**Summary**
|
||||
|
||||
| Step | iOS | Android |
|
||||
|------|-----|---------|
|
||||
| 1. Persist dual config | Store `userNotification` + `relationship` when scheduling/updating dual (e.g. UserDefaults). | Same; store when scheduling dual (e.g. SharedPreferences), keyed for the receiver. |
|
||||
| 2. Where relationship is applied | In **handleBackgroundFetch** after storing content: resolve cache vs default, then **replace** the pending dual notification (same id, same trigger, new title/body). | In the **receiver** at notify time: load config + cache, resolve cache vs default, then **show** the notification with that title/body. |
|
||||
| 3. Edge cases | Do not replace if next trigger is in the past; if fetch fails, leave existing default notification. | Receiver runs at fire time; “too old” handled by contentTimeout; if no config, fall back to alarm payload. |
|
||||
|
||||
### 1.4 Implement and register `cancelDualSchedule()` on iOS
|
||||
|
||||
- **Current state:** `cancelDualSchedule` is in `definitions.ts` and the web implementation, but there is **no** `@objc func cancelDualSchedule(_ call: CAPPluginCall)` in the iOS plugin and **no** `CAPPluginMethod(name: "cancelDualSchedule", ...)` in the plugin's method list (around 2195–2199).
|
||||
- **Required:**
|
||||
- Add `cancelDualSchedule(_ call: CAPPluginCall)` that:
|
||||
- Cancels the BGAppRefreshTaskRequest for the dual content fetch (e.g. cancel the task with `fetchTaskIdentifier` or the identifier used for the dual schedule).
|
||||
- Cancels **only** the pending user notification(s) created for the dual (New Activity) schedule — e.g. by a **dedicated request identifier** (see below). Must **not** remove Daily Reminder notifications (those use identifier `reminder_<id>` e.g. `reminder_daily_timesafari_reminder`).
|
||||
- When implementing or refactoring the dual user notification in `scheduleUserNotification(config:)` (or the path used by `scheduleDualNotification`), use a **stable, dedicated identifier** for the dual notification (e.g. `"dual_daily_notification"` or `"new_activity"`) so `cancelDualSchedule` can remove only that request. Currently the code uses `"daily-notification-\(Date().timeIntervalSince1970)"`, which is unique per call and not suitable for targeted cancellation.
|
||||
- Append `CAPPluginMethod(name: "cancelDualSchedule", returnType: CAPPluginReturnPromise)` in the same method list.
|
||||
- **Result:** Turning off "New Activity" in the app and calling `DailyNotification.cancelDualSchedule()` will no longer get `UNIMPLEMENTED` and will clear only the dual schedule, leaving Daily Reminder untouched.
|
||||
|
||||
### 1.5 Implement `updateDualScheduleConfig` for Edit time (recommended)
|
||||
|
||||
- **Use case:** The consuming app has an **Edit** button for New Activity that lets the user **change the time** of the notification. That flow is exactly what `updateDualScheduleConfig(config: DualScheduleConfiguration)` is for: "update the existing dual schedule with new config" (same config shape as `scheduleDualNotification`).
|
||||
- **Current app behavior:** Edit is implemented by calling `scheduleNewActivityDualNotification(timeText)` again (i.e. `scheduleDualNotification({ config })` with the new time). That can create duplicate pending notifications if the plugin does not replace the existing dual schedule (e.g. iOS currently uses a unique identifier per call: `daily-notification-<timestamp>`).
|
||||
- **Recommendation:** Implement **`updateDualScheduleConfig`** on iOS (and Android) for clear semantics: "change time" → call `updateDualScheduleConfig(newConfig)`. Implementation can be cancel existing dual schedule then schedule with new config (same as cancel + scheduleDualNotification under the hood). The consuming app should then call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when the user saves a new time from the Edit dialog, instead of (or with fallback to) `scheduleDualNotification`.
|
||||
- **Replace semantics:** Whether or not the app uses `updateDualScheduleConfig`, the plugin must ensure that when a dual schedule already exists and the app calls `scheduleDualNotification` again (e.g. on Edit), the result is **replace** not **add** — no duplicate content-fetch tasks or user notifications. Using a stable dual notification identifier (see 1.4) and replacing the existing request when scheduling achieves this.
|
||||
- **Other optional methods:** `pauseDualSchedule` and `resumeDualSchedule` remain optional; they are in `definitions.ts` but not required for the current app flow.
|
||||
|
||||
### 1.6 Android parity
|
||||
|
||||
- **cancelDualSchedule / updateDualScheduleConfig:** Implemented; Android now exposes both methods and uses `FetchWorker.WORK_NAME_DUAL` so only dual fetch work is cancelled. For **relationship** (contentTimeout / fallbackBehavior), see §1.3a (resolve at fire time in receiver).
|
||||
|
||||
---
|
||||
|
||||
## 2. Consuming app (crowd-funder-for-time-pwa)
|
||||
|
||||
### 2.1 Ensure plugin is linked and built (fix UNIMPLEMENTED)
|
||||
|
||||
- **Verify** the app's iOS project is using the plugin from this repo (or a release that includes the iOS implementation):
|
||||
- `package.json` dependency points to the right plugin (path, git, or npm).
|
||||
- Run `npx cap sync ios` and confirm `DailyNotificationPlugin` (and its Swift files) are in the app's `ios/App/` or Pods.
|
||||
- Clean build and run on device/simulator; confirm in Xcode that the plugin's `scheduleDualNotification` is registered and that a breakpoint in the Swift handler is hit when turning on New Activity.
|
||||
- If the app is on **Capacitor 6**, follow any documented steps for plugin registration so native methods are not reported as unimplemented.
|
||||
- No change to `buildDualScheduleConfig` or call order is needed; the config shape and sequence (configureNativeFetcher → updateStarredPlans → scheduleDualNotification) already match the plugin's expectations.
|
||||
|
||||
### 2.2 Error handling (optional but useful)
|
||||
|
||||
- **Current:** `AccountViewView.vue` treats `code === "UNIMPLEMENTED"` with a "not yet available on this device" message and any other error as "Could not schedule… try again."
|
||||
- **Improvement:** Once the plugin implements and registers `cancelDualSchedule` on iOS, the app can:
|
||||
- Keep handling `UNIMPLEMENTED` for older builds or platforms where the method is still missing.
|
||||
- Optionally surface more specific errors (e.g. `code === "SCHEDULING_FAILED"` or message strings from the plugin) so the user gets clearer feedback when scheduling fails for a reason other than "not implemented."
|
||||
- No change is strictly required for completion; the current flow is valid.
|
||||
|
||||
### 2.3 Turn-off flow
|
||||
|
||||
- The app already calls `DailyNotification.cancelDualSchedule()` when the user turns off New Activity (with a guard for `DailyNotification?.cancelDualSchedule`). Once the plugin implements and registers `cancelDualSchedule` on iOS (and optionally on Android), this will work without any app code change.
|
||||
|
||||
### 2.4 Edit time flow (New Activity)
|
||||
|
||||
- **Current:** When the user taps **Edit** and picks a new time, the app calls `scheduleNewActivityDualNotification(timeText)` (i.e. `scheduleDualNotification({ config })` again). See `editNewActivityNotification()` in `AccountViewView.vue` (~1504–1520).
|
||||
- **Recommended:** Once the plugin implements `updateDualScheduleConfig` (see 1.5), the app should call **`updateDualScheduleConfig({ config })`** when the user saves a new time from the Edit dialog, with `config = buildDualScheduleConfig({ notifyTime: timeText })`. That makes the intent explicit ("update existing schedule") and avoids relying on replace semantics inside `scheduleDualNotification`. The app can keep a fallback to `scheduleDualNotification` when `updateDualScheduleConfig` is not available (e.g. older plugin version).
|
||||
|
||||
---
|
||||
|
||||
## 3. Summary table
|
||||
|
||||
| Where | What | Status / action |
|
||||
|-------|------|------------------|
|
||||
| **Plugin iOS** | `scheduleDualNotification` handler + registration | Done; fix bridge/build in app if still UNIMPLEMENTED. |
|
||||
| **Plugin iOS** | Cron parsing in `calculateNextRunTime(from:)` | Done; real cron parsing (match Android semantics). |
|
||||
| **Plugin iOS** | Use `relationship.contentTimeout` and `fallbackBehavior` | **Done;** persist dual config; in handleBackgroundFetch replace pending notification with resolved content. |
|
||||
| **Plugin iOS** | `cancelDualSchedule()` implementation + registration | Done; cancel BG task + dual user notification only; stable identifier. |
|
||||
| **Plugin iOS** | `updateDualScheduleConfig(config)` | Done; cancel then schedule with new config. |
|
||||
| **Plugin Android** | `cancelDualSchedule()` | Done; cancel dual schedules + WorkManager WORK_NAME_DUAL only. |
|
||||
| **Plugin Android** | `updateDualScheduleConfig(config)` | Done; cancel then schedule with new config. |
|
||||
| **Plugin Android** | Use `relationship` (contentTimeout / fallbackBehavior) | **Done;** persist dual config; in Worker at fire time (dual_notify_*) resolve config + cache and show resolved title/body. |
|
||||
| **Plugin both** | Replace semantics for dual schedule | Done; stable dual identifier, replace before add. |
|
||||
| **Plugin both** | Isolation of Daily Reminder vs New Activity | Done; cancelDualSchedule does not touch reminder_*. |
|
||||
| **Consuming app** | Plugin linked and built for iOS | Verify dependency, `cap sync`, and build so native `scheduleDualNotification` is called. |
|
||||
| **Consuming app** | Edit time: use `updateDualScheduleConfig` | In `editNewActivityNotification()`, call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when user saves new time; fallback to `scheduleDualNotification` if unavailable. |
|
||||
| **Consuming app** | Error handling / UX | Optional: refine messages once plugin returns specific error codes. |
|
||||
|
||||
---
|
||||
|
||||
## 4. References
|
||||
|
||||
- **Plugin:** `ios/Plugin/DailyNotificationPlugin.swift` (scheduleDualNotification ~350–379, scheduleBackgroundFetch/scheduleUserNotification ~731–770, calculateNextRunTime ~767–771, method list ~2195–2199), `ios/Plugin/DailyNotificationScheduleHelper.swift` (~98–106), `src/definitions.ts` (DualScheduleConfiguration, cancelDualSchedule).
|
||||
- **Android reference:** `android/.../DailyNotificationPlugin.kt` (scheduleDualNotification ~1369–1420, calculateNextRunTime ~2336–2378).
|
||||
- **Consuming app:** `doc/plugin-feedback-ios-scheduleDualNotification.md`, `src/views/AccountViewView.vue` (~1237–1245, ~1259–1300, ~1501–1548 `editNewActivityNotification`), `src/services/notifications/dualScheduleConfig.ts`, `src/services/notifications/reminderIds.ts` (Daily Reminder vs New Activity IDs), `src/services/notifications/NativeNotificationService.ts` (Daily Reminder uses `scheduleDailyReminder` / `cancelDailyReminder`).
|
||||
@@ -0,0 +1,78 @@
|
||||
# Consuming app handoff: iOS native fetcher + chained dual schedule
|
||||
|
||||
This document is for the **host app** repository (e.g. crowd-funder-for-time-pwa) after bumping `@timesafari/daily-notification-plugin` to a version that includes:
|
||||
|
||||
- **iOS** `NativeNotificationContentFetcher`–style registration (`DailyNotificationPlugin.registerNativeFetcher`)
|
||||
- **iOS** `updateStarredPlans` / `getStarredPlans` (parity with Android `daily_notification_timesafari` / `starredPlanIds` semantics)
|
||||
- **iOS** chained dual flow: user notification is **armed only after** prefetch completes (delay if fetch is late; max slip 15 minutes before fallback copy)
|
||||
- **Android** chained dual flow: exact **notify** alarm is scheduled **after** dual prefetch completes (no longer scheduled at initial `scheduleDualNotification` before fetch)
|
||||
|
||||
Material from `doc/new-activity-notifications-ios-android-parity.md` still applies; this file adds **app-side** steps not spelled out there.
|
||||
|
||||
---
|
||||
|
||||
## 1. iOS — register native fetcher before `configureNativeFetcher`
|
||||
|
||||
The plugin now **rejects** `configureNativeFetcher` if no fetcher is registered (aligned with Android).
|
||||
|
||||
**In `AppDelegate` (or earliest app startup before Capacitor calls into the plugin):**
|
||||
|
||||
```swift
|
||||
import CapacitorDailyNotification // actual product module name may match the Pod (e.g. CapacitorDailyNotification)
|
||||
|
||||
// After: import DailyNotificationPlugin if your target uses a different module name — use the same module that exposes DailyNotificationPlugin.
|
||||
|
||||
DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)
|
||||
```
|
||||
|
||||
Implement **`TimeSafariNativeFetcher`** as a Swift type that:
|
||||
|
||||
- Conforms to `NativeNotificationContentFetcher`
|
||||
- Implements `fetchContent(context: FetchContext) async throws -> [NotificationContent]` with the same **Endorser** behavior as `TimeSafariNativeFetcher.java` (`POST …/api/v2/report/plansLastUpdatedBetween`, starred plan IDs, JWT pool selection, aggregation copy, pagination / `last_acked_jwt_id` as in Java)
|
||||
- Implements `configure(apiBaseUrl:activeDid:jwtToken:jwtTokenPool:)` if the fetcher needs credentials pushed from TypeScript (optional; TS still persists `native_fetcher_config` UserDefaults key)
|
||||
|
||||
**Starred plan IDs for the fetcher:** Read JSON array string from UserDefaults key **`daily_notification_timesafari.starredPlanIds`** (written by `updateStarredPlans` from JS). Format matches Android: JSON array of strings.
|
||||
|
||||
---
|
||||
|
||||
## 2. iOS — `UNUserNotificationCenterDelegate` / rollover
|
||||
|
||||
Chained dual notifications set:
|
||||
|
||||
- `notification_id` = `org.timesafari.dailynotification.dual` (same stable identifier as before)
|
||||
- `scheduled_time` = `NSNumber` (fire time in ms)
|
||||
|
||||
Ensure your existing `DailyNotificationDelivered` bridge still forwards **`notification_id`** and **`scheduled_time`** from **notification content `userInfo`** (not only from a custom payload). Foreground presentation handlers should read `notification.request.content.userInfo`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Android — no API change for `setNativeFetcher`
|
||||
|
||||
Host apps that already call `DailyNotificationPlugin.setNativeFetcher(TimeSafariNativeFetcher(...))` and `configureNativeFetcher` from JS keep that flow.
|
||||
|
||||
**Behavior change:** the dual **notify** alarm is no longer scheduled at the initial `scheduleDualNotification` call; it is scheduled when **dual prefetch work finishes** (success or hard failure path), at `max(nextNotifyAt, now)` so late prefetch delays the notification.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bump and sync
|
||||
|
||||
1. Bump **`@timesafari/daily-notification-plugin`** in the app `package.json`.
|
||||
2. `npm install`
|
||||
3. `npx cap sync ios && npx cap sync android`
|
||||
4. iOS: `cd ios/App && pod install` (adjust path if your app uses a different `ios` layout)
|
||||
5. Clean build in Xcode / Android Studio
|
||||
|
||||
---
|
||||
|
||||
## 5. QA focus
|
||||
|
||||
- **iOS:** Register fetcher **before** any `configureNativeFetcher` from the web layer; confirm `updateStarredPlans` is no longer `UNIMPLEMENTED`.
|
||||
- **Both:** New Activity dual path: first notification should appear **after** prefetch for that cycle, not at a fixed time with stale API text.
|
||||
- **Android:** Regression-test `cancelDualSchedule` and Daily Reminder (should remain independent).
|
||||
|
||||
---
|
||||
|
||||
## 6. Assumptions
|
||||
|
||||
- Swift host implements `TimeSafariNativeFetcher`; the plugin does **not** embed `plansLastUpdatedBetween` on iOS when a host fetcher is registered (mirrors Android).
|
||||
- Module import name for the Capacitor iOS plugin follows your Pod (`CapacitorDailyNotification` in `CapacitorDailyNotification.podspec`).
|
||||
164
doc/GETTING_STARTED.md
Normal file
164
doc/GETTING_STARTED.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Getting Started
|
||||
|
||||
**Purpose:** Step-by-step installation and setup guide for Daily Notification Plugin.
|
||||
**Owner:** Development Team
|
||||
**Last Updated:** 2025-12-22
|
||||
**Status:** active
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install @timesafari/daily-notification-plugin
|
||||
```
|
||||
|
||||
### yarn
|
||||
|
||||
```bash
|
||||
yarn add @timesafari/daily-notification-plugin
|
||||
```
|
||||
|
||||
### pnpm
|
||||
|
||||
```bash
|
||||
pnpm add @timesafari/daily-notification-plugin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platform Setup
|
||||
|
||||
### iOS
|
||||
|
||||
1. **Add to `Info.plist`:**
|
||||
|
||||
```xml
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>org.timesafari.dailynotification.fetch</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
2. **Register background task in `AppDelegate.swift`:**
|
||||
|
||||
```swift
|
||||
import BackgroundTasks
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: "org.timesafari.dailynotification.fetch",
|
||||
using: nil) { task in
|
||||
// Handle background fetch task
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
1. **Add permissions to `AndroidManifest.xml`:**
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
```
|
||||
|
||||
> **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.
|
||||
|
||||
2. **Register WorkManager in `Application.kt`:**
|
||||
|
||||
```kotlin
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
|
||||
class MyApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
WorkManager.initialize(
|
||||
this,
|
||||
Configuration.Builder()
|
||||
.setMinimumLoggingLevel(android.util.Log.INFO)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### 1. Import the Plugin
|
||||
|
||||
```typescript
|
||||
import { DailyNotification } from '@timesafari/daily-notification-plugin';
|
||||
```
|
||||
|
||||
### 2. Request Permission
|
||||
|
||||
```typescript
|
||||
const { state } = await DailyNotification.requestPermission();
|
||||
if (state !== 'granted') {
|
||||
console.error('Notification permission denied');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create a Schedule
|
||||
|
||||
```typescript
|
||||
const { schedule } = await DailyNotification.createSchedule({
|
||||
id: 'morning-notification',
|
||||
kind: 'notify',
|
||||
clockTime: '09:00',
|
||||
enabled: true
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Verify Schedule
|
||||
|
||||
```typescript
|
||||
const { schedules } = await DailyNotification.getSchedules();
|
||||
console.log('Active schedules:', schedules);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Quick Start Guide](./examples/QUICK_START.md)** — Minimal working example
|
||||
- **[Common Patterns](./examples/COMMON_PATTERNS.md)** — Common integration patterns
|
||||
- **[Integration Guide](./integration/INTEGRATION_GUIDE.md)** — Full integration guide
|
||||
- **[Troubleshooting](./TROUBLESHOOTING.md)** — Common issues and solutions
|
||||
|
||||
---
|
||||
|
||||
## Authoritative Documentation
|
||||
|
||||
- **[Documentation Index](./00-INDEX.md)** — Complete documentation navigation
|
||||
- **[System Invariants](./SYSTEM_INVARIANTS.md)** — Enforced system invariants
|
||||
- **[CI Usage](../ci/README.md)** — Local CI documentation (`./ci/run.sh`)
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues, questions, or contributions:
|
||||
|
||||
1. Check [Troubleshooting Guide](./TROUBLESHOOTING.md)
|
||||
2. Review [System Invariants](./SYSTEM_INVARIANTS.md)
|
||||
3. Check [Progress Documentation](./progress/00-STATUS.md) for current status
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
- [README.md](../README.md) — Complete plugin documentation
|
||||
- [Performance Characteristics](./PERFORMANCE.md) — Performance expectations
|
||||
|
||||
61
doc/PERFORMANCE.md
Normal file
61
doc/PERFORMANCE.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Performance Characteristics
|
||||
|
||||
**Purpose:** Expected performance characteristics and benchmarks for Daily Notification Plugin operations.
|
||||
**Owner:** Development Team
|
||||
**Last Updated:** 2025-12-22
|
||||
**Status:** active
|
||||
|
||||
---
|
||||
|
||||
## Expected Operation Times
|
||||
|
||||
### Scheduling Operations
|
||||
- **Schedule creation:** < 50ms (typical), < 100ms (p95)
|
||||
- **Schedule update:** < 50ms (typical), < 100ms (p95)
|
||||
- **Schedule deletion:** < 50ms (typical), < 100ms (p95)
|
||||
|
||||
### Recovery Operations
|
||||
- **Cold start recovery:** < 500ms (typical), < 1000ms (p95)
|
||||
- **Force stop recovery:** < 500ms (typical), < 1000ms (p95)
|
||||
- **Boot recovery:** < 1000ms (typical), < 2000ms (p95)
|
||||
|
||||
### Database Operations
|
||||
- **Query (getEnabled):** < 50ms (typical), < 100ms (p95)
|
||||
- **Query (getById):** < 10ms (typical), < 20ms (p95)
|
||||
- **Insert/Update:** < 50ms (typical), < 100ms (p95)
|
||||
|
||||
## Memory Footprint
|
||||
|
||||
- **In-memory metrics:** ~10KB per 100 metrics
|
||||
- **Event logs:** ~5KB per 100 events
|
||||
- **Total overhead:** < 100KB (development mode), < 10KB (production, metrics disabled)
|
||||
|
||||
## Platform-Specific Considerations
|
||||
|
||||
### iOS
|
||||
- Background task time limits: ~30 seconds
|
||||
- CoreData auto-migration: typically < 100ms
|
||||
|
||||
### Android
|
||||
- WorkManager execution time limits: flexible (minutes)
|
||||
- Room migrations: typically < 200ms
|
||||
|
||||
### Web
|
||||
- No background execution limits
|
||||
- No native database operations
|
||||
|
||||
## Measurement Methodology
|
||||
|
||||
Metrics are collected using:
|
||||
- `performance.now()` (Web/TypeScript)
|
||||
- `System.currentTimeMillis()` (Android)
|
||||
- `Date.timeIntervalSince()` (iOS)
|
||||
|
||||
All timings are in milliseconds.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
- [SYSTEM_INVARIANTS.md](./SYSTEM_INVARIANTS.md) — Enforced system invariants
|
||||
- [docs/progress/03-TEST-RUNS.md](./progress/03-TEST-RUNS.md) — Test run history
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
**Owner:** Development Team
|
||||
**Last Updated:** 2025-12-22
|
||||
**Status:** active
|
||||
**Baseline:** `v1.0.11-p0-p1.4-complete`
|
||||
**Baseline:** `v1.0.11-p0-p1.4-p1.5-p2.6-p2.7-complete`
|
||||
|
||||
---
|
||||
|
||||
@@ -303,17 +303,19 @@ Documentation must follow the index-first rule and maintain drift guards. New do
|
||||
|
||||
### What
|
||||
|
||||
The baseline tag `v1.0.11-p0-p1.4-complete` represents a known-good architectural baseline where all invariants are enforced. P2 work must not invalidate this baseline.
|
||||
The baseline tag `v1.0.11-p0-p1.4-p1.5-p2.6-p2.7-complete` represents a known-good architectural baseline where all invariants are enforced. Future work must not invalidate this baseline.
|
||||
|
||||
**Specific rules:**
|
||||
- Baseline tag: `v1.0.11-p0-p1.4-complete`
|
||||
- Baseline tag: `v1.0.11-p0-p1.4-p1.5-p2.6-p2.7-complete`
|
||||
- This tag represents:
|
||||
- All P0 invariants enforced (packaging, CI authority, exports)
|
||||
- All P1.4 invariants enforced (core module purity)
|
||||
- All P1.5 invariants enforced (documentation structure)
|
||||
- All P2.6 invariants enforced (type safety)
|
||||
- All P2.7 invariants enforced (system invariants documentation)
|
||||
- All tooling in place (`verify.sh`, `ci/run.sh`)
|
||||
- P2 work must not require rollback to this baseline
|
||||
- P2 work must not break any invariant enforced at baseline
|
||||
- Future work must not require rollback to this baseline
|
||||
- Future work must not break any invariant enforced at baseline
|
||||
|
||||
### Why
|
||||
|
||||
@@ -327,29 +329,29 @@ The baseline tag `v1.0.11-p0-p1.4-complete` represents a known-good architectura
|
||||
**Enforced by:** Git tag + process (not automatically enforced, but baseline must remain valid)
|
||||
|
||||
**Enforcement mechanism:**
|
||||
1. **Git tag:** `v1.0.11-p0-p1.4-complete` exists in repository
|
||||
1. **Git tag:** `v1.0.11-p0-p1.4-p1.5-p2.6-p2.7-complete` exists in repository
|
||||
2. **Process enforcement:** Team must not break baseline (CI will catch invariant violations)
|
||||
3. **Validation:** Can verify baseline by checking out tag and running `./ci/run.sh` (should pass)
|
||||
|
||||
**Location:**
|
||||
- Baseline tag: `v1.0.11-p0-p1.4-complete` (Git tag)
|
||||
- Baseline description: `docs/progress/00-STATUS.md:121` (Baseline Tag section)
|
||||
- P2 constraint: `docs/progress/P2-DESIGN.md:117-125` (Baseline Tag Integrity section)
|
||||
- Baseline tag: `v1.0.11-p0-p1.4-p1.5-p2.6-p2.7-complete` (Git tag)
|
||||
- Baseline description: `docs/progress/00-STATUS.md:126` (Baseline Tag section)
|
||||
- Previous baseline: `v1.0.11-p0-p1.4-complete` (historical reference)
|
||||
|
||||
**Verification command:**
|
||||
```bash
|
||||
# Verify baseline is still valid:
|
||||
git checkout v1.0.11-p0-p1.4-complete
|
||||
git checkout v1.0.11-p0-p1.4-p1.5-p2.6-p2.7-complete
|
||||
./ci/run.sh # Should pass
|
||||
git checkout - # Return to current branch
|
||||
```
|
||||
|
||||
### Where
|
||||
|
||||
- **Baseline tag:** `v1.0.11-p0-p1.4-complete` (Git tag)
|
||||
- **Baseline description:** `docs/progress/00-STATUS.md:121` (Baseline Tag section)
|
||||
- **P2 constraint:** `docs/progress/P2-DESIGN.md:117-125` (Baseline Tag Integrity section)
|
||||
- **Status doc:** `docs/progress/00-STATUS.md:15-23` (What This Baseline Includes section)
|
||||
- **Baseline tag:** `v1.0.11-p0-p1.4-p1.5-p2.6-p2.7-complete` (Git tag)
|
||||
- **Baseline description:** `docs/progress/00-STATUS.md:126` (Baseline Tag section)
|
||||
- **Previous baseline:** `v1.0.11-p0-p1.4-complete` (historical reference)
|
||||
- **Status doc:** `docs/progress/00-STATUS.md:17-25` (What This Baseline Includes section)
|
||||
|
||||
---
|
||||
|
||||
@@ -364,7 +366,7 @@ git checkout - # Return to current branch
|
||||
| CI Authority | `ci/README.md` (contract) | ⚠️ Process | Manual review |
|
||||
| Export Correctness | `verify.sh` → `check_build()` | ✅ Yes | `./ci/run.sh` |
|
||||
| Documentation Structure | `docs/00-INDEX.md` (index-first rule) | ⚠️ Process | Manual review |
|
||||
| Baseline Integrity | Git tag + process | ⚠️ Process | `git checkout v1.0.11-p0-p1.4-complete && ./ci/run.sh` |
|
||||
| Baseline Integrity | Git tag + process | ⚠️ Process | `git checkout v1.0.11-p0-p1.4-p1.5-p2.6-p2.7-complete && ./ci/run.sh` |
|
||||
|
||||
**Legend:**
|
||||
- ✅ **Hard-Fail:** CI automatically fails if violated
|
||||
151
doc/TROUBLESHOOTING.md
Normal file
151
doc/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
**Purpose:** Common issues, symptoms, causes, and solutions.
|
||||
**Owner:** Development Team
|
||||
**Last Updated:** 2025-12-22
|
||||
**Status:** active
|
||||
|
||||
---
|
||||
|
||||
## CI Failures
|
||||
|
||||
### Symptom: `./ci/run.sh` fails
|
||||
|
||||
**Causes:**
|
||||
- Forbidden files in package
|
||||
- Core module imports platform deps
|
||||
- Export paths don't match artifacts
|
||||
|
||||
**Solutions:**
|
||||
1. Check forbidden files: `npm pack --dry-run | grep -E "xcuserdata|xcuserstate|DerivedData|ios/App/"`
|
||||
2. Check core purity: `grep -r "@capacitor\|react\|fs\|path\|os" src/core/`
|
||||
3. Check exports: `node -e "const p=require('./package.json'); console.log(JSON.stringify(p.exports, null, 2))"`
|
||||
|
||||
---
|
||||
|
||||
## Packaging Failures
|
||||
|
||||
### Symptom: `npm pack` includes forbidden files
|
||||
|
||||
**Causes:**
|
||||
- `package.json` `files` field is too permissive
|
||||
- `.npmignore` is missing or incomplete
|
||||
|
||||
**Solutions:**
|
||||
1. Review `package.json` `files` field (should be whitelist)
|
||||
2. Add to `.npmignore`: `**/xcuserdata/`, `**/*.xcuserstate`, `**/DerivedData/`, `ios/App/`, `.DS_Store`
|
||||
3. Run `npm pack --dry-run` to verify
|
||||
|
||||
---
|
||||
|
||||
## Platform Test Failures
|
||||
|
||||
### Symptom: Android tests fail in CI
|
||||
|
||||
**Causes:**
|
||||
- Robolectric SDK version mismatch
|
||||
- Missing test dependencies
|
||||
- Test database setup issues
|
||||
|
||||
**Solutions:**
|
||||
1. Check `@Config(sdk = [34])` matches Robolectric version
|
||||
2. Verify `android/build.gradle` has test dependencies
|
||||
3. Check `TestDBFactory` creates in-memory database correctly
|
||||
|
||||
### Symptom: iOS tests not running in CI
|
||||
|
||||
**Causes:**
|
||||
- macOS runner not available
|
||||
- xcodebuild not found
|
||||
- Test app not configured
|
||||
|
||||
**Solutions:**
|
||||
1. Use scheduled/manual workflows for iOS tests
|
||||
2. Verify `xcodebuild` is available: `xcodebuild -version`
|
||||
3. Check test app configuration in `test-apps/ios-test-app/`
|
||||
|
||||
---
|
||||
|
||||
## Build Failures
|
||||
|
||||
### Symptom: TypeScript compilation fails
|
||||
|
||||
**Causes:**
|
||||
- Type errors in source code
|
||||
- Missing type definitions
|
||||
- Incorrect import paths
|
||||
|
||||
**Solutions:**
|
||||
1. Run `npx tsc --noEmit` to see all type errors
|
||||
2. Check import paths match `package.json` exports
|
||||
3. Verify all dependencies are installed: `npm install`
|
||||
|
||||
### Symptom: Build succeeds but runtime errors occur
|
||||
|
||||
**Causes:**
|
||||
- Missing runtime dependencies
|
||||
- Incorrect module resolution
|
||||
- Platform-specific code not available
|
||||
|
||||
**Solutions:**
|
||||
1. Check `dist/` directory contains expected files
|
||||
2. Verify `package.json` exports match build artifacts
|
||||
3. Test on actual platform (not just build)
|
||||
|
||||
---
|
||||
|
||||
## Permission Issues
|
||||
|
||||
### Symptom: Notifications not appearing
|
||||
|
||||
**Causes:**
|
||||
- Permission not granted
|
||||
- Battery optimization killing background tasks
|
||||
- Platform-specific permission issues
|
||||
|
||||
**Solutions:**
|
||||
1. Check permission status: `await DailyNotification.checkPermission()`
|
||||
2. Request permission: `await DailyNotification.requestPermission()`
|
||||
3. Check battery optimization settings (Android)
|
||||
4. Verify Info.plist/AndroidManifest.xml permissions
|
||||
|
||||
---
|
||||
|
||||
## Recovery Issues
|
||||
|
||||
### Symptom: Missed notifications after app restart
|
||||
|
||||
**Causes:**
|
||||
- Recovery not running on app launch
|
||||
- Database corruption
|
||||
- Platform-specific recovery limitations
|
||||
|
||||
**Solutions:**
|
||||
1. Check recovery logs in history: `await DailyNotification.getHistory({ kind: 'recovery' })`
|
||||
2. Verify recovery is called on app launch
|
||||
3. Check database integrity
|
||||
4. Review platform-specific recovery constraints
|
||||
|
||||
---
|
||||
|
||||
## Performance Issues
|
||||
|
||||
### Symptom: Slow database queries
|
||||
|
||||
**Causes:**
|
||||
- Large number of schedules
|
||||
- Missing database indexes
|
||||
- Database corruption
|
||||
|
||||
**Solutions:**
|
||||
1. Check query performance in logs (warnings if > 100ms)
|
||||
2. Review database schema for missing indexes
|
||||
3. Consider database cleanup/migration
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
- [SYSTEM_INVARIANTS.md](./SYSTEM_INVARIANTS.md) — Enforced system invariants
|
||||
- [PERFORMANCE.md](./PERFORMANCE.md) — Performance characteristics
|
||||
- [docs/progress/03-TEST-RUNS.md](./progress/03-TEST-RUNS.md) — Test run history
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
# Documentation Consolidation Source Map
|
||||
|
||||
**Date:** 2025-12-16
|
||||
**Purpose:** Complete audit trail of all markdown file destinations during consolidation
|
||||
**Total Files Mapped:** 139
|
||||
|
||||
This document guarantees no information loss by tracking every file's destination.
|
||||
|
||||
---
|
||||
|
||||
## Legend
|
||||
|
||||
- **Canonical**: File kept in active documentation, possibly edited/merged
|
||||
- **Merged**: Content incorporated into canonical document, original archived
|
||||
- **Archived**: File preserved verbatim in archive, referenced from index
|
||||
|
||||
---
|
||||
|
||||
## Root Canonical Files (Keep As-Is)
|
||||
|
||||
**Note (2025-03):** The following were moved after consolidation: `TODO.md` → `doc/progress/TODO.md`; `TODAY_SUMMARY.md` → `doc/progress/TODAY_SUMMARY.md`; `SESSION_RECONSTITUTION.md` → `doc/progress/SESSION_RECONSTITUTION.md`; `BATCH_A_COMPLETION_SUMMARY.md` → `doc/progress/BATCH_A_COMPLETION_SUMMARY.md`; `PR_DESCRIPTION.md` → `doc/_archive/PR_DESCRIPTION.md`; `MERGE_READY_SUMMARY.md` → `doc/_archive/MERGE_READY_SUMMARY.md`.
|
||||
|
||||
| Original Path | Status | Notes |
|
||||
|--------------|--------|-------|
|
||||
| `README.md` | Canonical | Main entry point, will link to doc/00-INDEX.md |
|
||||
| `ARCHITECTURE.md` | Canonical | Foundational architecture document |
|
||||
| `BUILDING.md` | Canonical | Build instructions |
|
||||
| `CHANGELOG.md` | Canonical | Version history |
|
||||
| `CONTRIBUTING.md` | Canonical | Contribution guidelines |
|
||||
| `SECURITY.md` | Canonical | Security documentation |
|
||||
| `API.md` | Canonical | API reference |
|
||||
| `USAGE.md` | Canonical | Usage guide |
|
||||
| `TODO.md` | Moved | → `doc/progress/TODO.md` |
|
||||
| `PR_DESCRIPTION.md` | Moved | → `doc/_archive/PR_DESCRIPTION.md` |
|
||||
| `MERGE_READY_SUMMARY.md` | Moved | → `doc/_archive/MERGE_READY_SUMMARY.md` |
|
||||
|
||||
---
|
||||
|
||||
## Integration Documentation (Consolidate to `doc/integration/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `INTEGRATION_GUIDE.md` | `doc/integration/INTEGRATION_GUIDE.md` | Canonical | Primary integration guide |
|
||||
| `QUICK_INTEGRATION.md` | `doc/integration/QUICK_START.md` | Canonical | Quick start guide |
|
||||
| `AI_INTEGRATION_GUIDE.md` | `doc/ai/AI_INTEGRATION_GUIDE.md` | Canonical | AI-specific integration |
|
||||
| `doc/INTEGRATION_CHECKLIST.md` | `doc/integration/CHECKLIST.md` | Merged | Merge into INTEGRATION_GUIDE.md |
|
||||
| `doc/INTEGRATION_REFACTOR_CONTEXT.md` | `doc/integration/REFACTOR_NOTES.md` | Merged | Merge context into refactor notes |
|
||||
| `doc/INTEGRATION_REFACTOR_QUICK_START.md` | `doc/integration/REFACTOR_NOTES.md` | Merged | Merge into refactor notes |
|
||||
| `doc/aar-integration-troubleshooting.md` | `doc/integration/TROUBLESHOOTING.md` | Merged | Merge into troubleshooting guide |
|
||||
| `doc/integration-point-refactor-analysis.md` | `doc/integration/REFACTOR_NOTES.md` | Merged | Merge into refactor notes |
|
||||
|
||||
---
|
||||
|
||||
## Legacy Documentation (Archive to `doc/archive/2025-legacy-doc/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/BACKGROUND_DATA_FETCHING_PLAN.md` | `doc/archive/2025-legacy-doc/BACKGROUND_DATA_FETCHING_PLAN.md` | Archived | Historical planning doc |
|
||||
| `doc/BUILD_FIXES_SUMMARY.md` | `doc/archive/2025-legacy-doc/BUILD_FIXES_SUMMARY.md` | Archived | Historical build fixes |
|
||||
| `doc/BUILD_SCRIPT_IMPROVEMENTS.md` | `doc/archive/2025-legacy-doc/BUILD_SCRIPT_IMPROVEMENTS.md` | Archived | Historical build improvements |
|
||||
| `doc/directives/0001-Daily-Notification-Plugin-Implementation-Directive.md` | `doc/archive/2025-legacy-doc/directives/0001-Daily-Notification-Plugin-Implementation-Directive.md` | Archived | Historical directive |
|
||||
| `doc/directives/0002-Daily-Notification-Plugin-Recommendations.md` | `doc/archive/2025-legacy-doc/directives/0002-Daily-Notification-Plugin-Recommendations.md` | Archived | Historical recommendations |
|
||||
| `doc/directives/0003-iOS-Android-Parity-Directive.md` | `doc/archive/2025-legacy-doc/directives/0003-iOS-Android-Parity-Directive.md` | Archived | Historical directive |
|
||||
| `doc/implementation-roadmap.md` | `doc/archive/2025-legacy-doc/implementation-roadmap.md` | Archived | Historical roadmap |
|
||||
| `doc/IOS_ANDROID_ERROR_CODE_MAPPING.md` | `doc/archive/2025-legacy-doc/IOS_ANDROID_ERROR_CODE_MAPPING.md` | Archived | Historical mapping |
|
||||
| `doc/IOS_PHASE1_FINAL_SUMMARY.md` | `doc/archive/2025-legacy-doc/IOS_PHASE1_FINAL_SUMMARY.md` | Archived | Historical summary |
|
||||
| `doc/IOS_PHASE1_GAPS_ANALYSIS.md` | `doc/archive/2025-legacy-doc/IOS_PHASE1_GAPS_ANALYSIS.md` | Archived | Historical analysis |
|
||||
| `doc/IOS_PHASE1_IMPLEMENTATION_CHECKLIST.md` | `doc/platform/ios/IMPLEMENTATION_CHECKLIST.md` | Merged | Promote to canonical iOS docs |
|
||||
| `doc/IOS_PHASE1_QUICK_REFERENCE.md` | `doc/archive/2025-legacy-doc/IOS_PHASE1_QUICK_REFERENCE.md` | Archived | Historical quick reference |
|
||||
| `doc/IOS_PHASE1_READY_FOR_TESTING.md` | `doc/archive/2025-legacy-doc/IOS_PHASE1_READY_FOR_TESTING.md` | Archived | Historical testing status |
|
||||
| `doc/IOS_PHASE1_TESTING_GUIDE.md` | `doc/testing/IOS_PHASE1_TESTING_GUIDE.md` | Merged | Promote to testing docs |
|
||||
| `doc/IOS_TEST_APP_SETUP_GUIDE.md` | `doc/testing/IOS_TEST_APP_SETUP.md` | Merged | Promote to testing docs |
|
||||
| `doc/migration-guide.md` | `doc/platform/ios/MIGRATION_GUIDE.md` | Merged | Promote to canonical iOS docs |
|
||||
| `doc/notification-system.md` | `doc/archive/2025-legacy-doc/notification-system.md` | Archived | Historical system doc |
|
||||
| `doc/PHASE1_COMPLETION_SUMMARY.md` | `doc/archive/2025-legacy-doc/PHASE1_COMPLETION_SUMMARY.md` | Archived | Historical summary |
|
||||
| `doc/RESEARCH_COMPLETE.md` | `doc/archive/2025-legacy-doc/RESEARCH_COMPLETE.md` | Archived | Historical research doc |
|
||||
| `doc/STARRED_PROJECTS_POLLING_IMPLEMENTATION.md` | `doc/design/STARRED_PROJECTS_POLLING_IMPLEMENTATION.md` | Canonical | Promote to design docs (large, relevant) |
|
||||
| `doc/test-app-ios/ENHANCEMENTS_APPLIED.md` | `doc/archive/2025-legacy-doc/test-app-ios/ENHANCEMENTS_APPLIED.md` | Archived | Historical enhancements |
|
||||
| `doc/test-app-ios/IOS_LOGGING_GUIDE.md` | `doc/testing/IOS_LOGGING_GUIDE.md` | Merged | Promote to testing docs |
|
||||
| `doc/test-app-ios/IOS_PREFETCH_GLOSSARY.md` | `doc/platform/ios/PREFETCH_GLOSSARY.md` | Merged | Promote to iOS docs |
|
||||
| `doc/test-app-ios/IOS_PREFETCH_TESTING.md` | `doc/testing/IOS_PREFETCH_TESTING.md` | Merged | Promote to testing docs |
|
||||
| `doc/test-app-ios/IOS_TEST_APP_REQUIREMENTS.md` | `doc/testing/IOS_TEST_APP_REQUIREMENTS.md` | Merged | Promote to testing docs |
|
||||
| `doc/UI_REQUIREMENTS.md` | `doc/archive/2025-legacy-doc/UI_REQUIREMENTS.md` | Archived | Historical requirements |
|
||||
|
||||
---
|
||||
|
||||
## Platform Documentation - iOS (Consolidate to `doc/platform/ios/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/IOS_IMPLEMENTATION_CHECKLIST.md` | `doc/platform/ios/IMPLEMENTATION_CHECKLIST.md` | Canonical | Primary iOS checklist |
|
||||
| `doc/ios-implementation-directive.md` | `doc/platform/ios/IMPLEMENTATION_DIRECTIVE.md` | Canonical | iOS implementation directive |
|
||||
| `doc/IOS_IMPLEMENTATION_DOCUMENTATION_REVIEW.md` | `doc/platform/ios/DOCUMENTATION_REVIEW.md` | Canonical | Documentation review |
|
||||
| `doc/ios-core-data-migration.md` | `doc/platform/ios/CORE_DATA_MIGRATION.md` | Canonical | Core Data migration guide |
|
||||
| `doc/ios-recovery-scenario-mapping.md` | `doc/platform/ios/RECOVERY_SCENARIO_MAPPING.md` | Canonical | Recovery scenario mapping |
|
||||
| `doc/ios-rollover-edge-case-plan.md` | `doc/platform/ios/ROLLOVER_EDGE_CASES.md` | Canonical | Rollover edge cases |
|
||||
| `doc/ios-rollover-implementation-review.md` | `doc/platform/ios/ROLLOVER_IMPLEMENTATION_REVIEW.md` | Canonical | Rollover implementation review |
|
||||
| `doc/ios-rollover-open-questions-answers.md` | `doc/platform/ios/ROLLOVER_QA.md` | Canonical | Rollover Q&A |
|
||||
| `doc/ios-troubleshooting-guide.md` | `doc/platform/ios/TROUBLESHOOTING.md` | Canonical | iOS troubleshooting |
|
||||
|
||||
---
|
||||
|
||||
## Platform Documentation - Android (Consolidate to `doc/platform/android/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/android-implementation-directive.md` | `doc/platform/android/IMPLEMENTATION_DIRECTIVE.md` | Canonical | Primary Android directive |
|
||||
| `doc/android-implementation-directive-phase1.md` | `doc/platform/android/PHASE1_DIRECTIVE.md` | Canonical | Phase 1 directive |
|
||||
| `doc/android-implementation-directive-phase2.md` | `doc/platform/android/PHASE2_DIRECTIVE.md` | Canonical | Phase 2 directive |
|
||||
| `doc/android-implementation-directive-phase3.md` | `doc/platform/android/PHASE3_DIRECTIVE.md` | Canonical | Phase 3 directive |
|
||||
| `doc/android-alarm-persistence-directive.md` | `doc/platform/android/ALARM_PERSISTENCE_DIRECTIVE.md` | Canonical | Alarm persistence directive |
|
||||
| `doc/android-app-analysis.md` | `doc/platform/android/APP_ANALYSIS.md` | Canonical | App analysis |
|
||||
| `doc/android-app-improvement-plan.md` | `doc/platform/android/APP_IMPROVEMENT_PLAN.md` | Canonical | App improvement plan |
|
||||
| `android/BUILDING.md` | `doc/platform/android/BUILDING.md` | Canonical | Android build guide |
|
||||
| `android/DATABASE_CONSOLIDATION_PLAN.md` | `doc/platform/android/DATABASE_CONSOLIDATION_PLAN.md` | Canonical | Database consolidation plan |
|
||||
|
||||
---
|
||||
|
||||
## Testing Documentation (Consolidate to `doc/testing/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/comprehensive-testing-guide-v2.md` | `doc/testing/COMPREHENSIVE_GUIDE.md` | Canonical | Primary testing guide |
|
||||
| `doc/testing-quick-reference.md` | `doc/testing/QUICK_REFERENCE.md` | Canonical | Quick reference |
|
||||
| `doc/testing-quick-reference-v2.md` | `doc/testing/QUICK_REFERENCE.md` | Merged | Merge into QUICK_REFERENCE.md |
|
||||
| `doc/manual_smoke_test.md` | `doc/testing/MANUAL_SMOKE_TEST.md` | Canonical | Manual smoke test |
|
||||
| `doc/notification-testing-procedures.md` | `doc/testing/NOTIFICATION_PROCEDURES.md` | Canonical | Notification testing |
|
||||
| `doc/reboot-testing-procedure.md` | `doc/testing/REBOOT_PROCEDURE.md` | Canonical | Reboot testing |
|
||||
| `doc/reboot-testing-steps.md` | `doc/testing/REBOOT_PROCEDURE.md` | Merged | Merge into REBOOT_PROCEDURE.md |
|
||||
| `doc/boot-receiver-testing-guide.md` | `doc/testing/BOOT_RECEIVER_GUIDE.md` | Canonical | Boot receiver testing |
|
||||
| `doc/standalone-emulator-guide.md` | `doc/testing/EMULATOR_GUIDE.md` | Canonical | Emulator guide |
|
||||
| `doc/localhost-testing-guide.md` | `doc/testing/LOCALHOST_GUIDE.md` | Canonical | Localhost testing |
|
||||
|
||||
---
|
||||
|
||||
## Alarm System Documentation (Keep in `doc/alarms/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/alarms/000-UNIFIED-ALARM-DIRECTIVE.md` | `doc/alarms/000-UNIFIED-ALARM-DIRECTIVE.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/01-platform-capability-reference.md` | `doc/alarms/01-platform-capability-reference.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/02-plugin-behavior-exploration.md` | `doc/alarms/02-plugin-behavior-exploration.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/03-plugin-requirements.md` | `doc/alarms/03-plugin-requirements.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/ACTIVATION-GUIDE.md` | `doc/alarms/ACTIVATION-GUIDE.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/PHASE1-EMULATOR-TESTING.md` | `doc/alarms/PHASE1-EMULATOR-TESTING.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/PHASE1-VERIFICATION.md` | `doc/alarms/PHASE1-VERIFICATION.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/PHASE2-EMULATOR-TESTING.md` | `doc/alarms/PHASE2-EMULATOR-TESTING.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/PHASE2-VERIFICATION.md` | `doc/alarms/PHASE2-VERIFICATION.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/PHASE3-EMULATOR-TESTING.md` | `doc/alarms/PHASE3-EMULATOR-TESTING.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/PHASE3-VERIFICATION.md` | `doc/alarms/PHASE3-VERIFICATION.md` | Canonical | Keep as-is |
|
||||
|
||||
---
|
||||
|
||||
## AI / ChatGPT Documentation (Consolidate to `doc/ai/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `chatgpt-assessment-package.md` | `doc/ai/chatgpt-assessment-package.md` | Canonical | AI artifacts |
|
||||
| `chatgpt-files-overview.md` | `doc/ai/chatgpt-files-overview.md` | Canonical | AI artifacts |
|
||||
| `chatgpt-improvement-directives-template.md` | `doc/ai/chatgpt-improvement-directives-template.md` | Canonical | AI artifacts |
|
||||
| `code-summary-for-chatgpt.md` | `doc/ai/code-summary-for-chatgpt.md` | Canonical | AI artifacts |
|
||||
| `key-code-snippets-for-chatgpt.md` | `doc/ai/key-code-snippets-for-chatgpt.md` | Canonical | AI artifacts |
|
||||
| `doc/chatgpt-analysis-guide.md` | `doc/ai/chatgpt-analysis-guide.md` | Canonical | AI artifacts |
|
||||
|
||||
---
|
||||
|
||||
## Design & Research Documentation (Consolidate to `doc/design/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/exploration-findings-initial.md` | `doc/design/exploration-findings-initial.md` | Canonical | Design research |
|
||||
| `doc/explore-alarm-behavior-directive.md` | `doc/design/explore-alarm-behavior-directive.md` | Canonical | Design research |
|
||||
| `doc/improve-alarm-directives.md` | `doc/design/improve-alarm-directives.md` | Canonical | Design research |
|
||||
| `doc/plugin-behavior-exploration-template.md` | `doc/design/plugin-behavior-exploration-template.md` | Canonical | Design template |
|
||||
|
||||
---
|
||||
|
||||
## Deployment Documentation (Keep in `doc/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `DEPLOYMENT_CHECKLIST.md` | `doc/DEPLOYMENT_CHECKLIST.md` | Canonical | Move to doc/ |
|
||||
| `DEPLOYMENT_SUMMARY.md` | `doc/DEPLOYMENT_SUMMARY.md` | Canonical | Move to doc/ |
|
||||
| `doc/deployment-guide.md` | `doc/DEPLOYMENT_GUIDE.md` | Canonical | Primary deployment guide |
|
||||
|
||||
---
|
||||
|
||||
## Feature-Specific Documentation (Keep in `doc/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/CROSS_PLATFORM_STORAGE_PATTERN.md` | `doc/CROSS_PLATFORM_STORAGE_PATTERN.md` | Canonical | Keep as-is |
|
||||
| `doc/DATABASE_INTERFACES.md` | `doc/DATABASE_INTERFACES.md` | Canonical | Keep as-is |
|
||||
| `doc/DATABASE_INTERFACES_IMPLEMENTATION.md` | `doc/DATABASE_INTERFACES_IMPLEMENTATION.md` | Canonical | Keep as-is |
|
||||
| `doc/NATIVE_FETCHER_CONFIGURATION.md` | `doc/NATIVE_FETCHER_CONFIGURATION.md` | Canonical | Keep as-is |
|
||||
| `doc/platform-capability-reference.md` | `doc/platform-capability-reference.md` | Canonical | Keep as-is |
|
||||
| `doc/plugin-requirements-implementation.md` | `doc/plugin-requirements-implementation.md` | Canonical | Keep as-is |
|
||||
| `doc/prefetch-scheduling-diagnosis.md` | `doc/prefetch-scheduling-diagnosis.md` | Canonical | Keep as-is |
|
||||
| `doc/prefetch-scheduling-trace.md` | `doc/prefetch-scheduling-trace.md` | Canonical | Keep as-is |
|
||||
| `doc/app-startup-recovery-solution.md` | `doc/app-startup-recovery-solution.md` | Canonical | Keep as-is |
|
||||
| `doc/getting-valid-plan-ids.md` | `doc/getting-valid-plan-ids.md` | Canonical | Keep as-is |
|
||||
| `doc/host-request-configuration.md` | `doc/host-request-configuration.md` | Canonical | Keep as-is |
|
||||
| `doc/hydrate-plan-implementation-guide.md` | `doc/hydrate-plan-implementation-guide.md` | Canonical | Keep as-is |
|
||||
| `doc/user-zero-stars-implementation.md` | `doc/user-zero-stars-implementation.md` | Canonical | Keep as-is |
|
||||
| `doc/accessibility-localization.md` | `doc/accessibility-localization.md` | Canonical | Keep as-is |
|
||||
| `doc/legal-store-compliance.md` | `doc/legal-store-compliance.md` | Canonical | Keep as-is |
|
||||
| `doc/observability-dashboards.md` | `doc/observability-dashboards.md` | Canonical | Keep as-is |
|
||||
| `doc/file-organization-summary.md` | `doc/file-organization-summary.md` | Canonical | Keep as-is |
|
||||
| `doc/capacitor-platform-service-clean-changes.md` | `doc/capacitor-platform-service-clean-changes.md` | Canonical | Keep as-is |
|
||||
|
||||
---
|
||||
|
||||
## Test App Documentation (Keep with Test Apps, Index in `doc/testing/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `test-apps/BUILD_PROCESS.md` | `test-apps/BUILD_PROCESS.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/android-test-app/doc/PHASE1_TEST0_GOLDEN.md` | `test-apps/android-test-app/doc/PHASE1_TEST0_GOLDEN.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/android-test-app/doc/PHASE1_TEST1_GOLDEN.md` | `test-apps/android-test-app/doc/PHASE1_TEST1_GOLDEN.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/doc/BUILD_QUICK_REFERENCE.md` | `test-apps/daily-notification-test/doc/BUILD_QUICK_REFERENCE.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/doc/NOTIFICATION_STACK_IMPROVEMENT_PLAN.md` | `test-apps/daily-notification-test/doc/NOTIFICATION_STACK_IMPROVEMENT_PLAN.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/doc/PLUGIN_DETECTION_GUIDE.md` | `test-apps/daily-notification-test/doc/PLUGIN_DETECTION_GUIDE.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/doc/VUE3_NOTIFICATION_IMPLEMENTATION_GUIDE.md` | `test-apps/daily-notification-test/doc/VUE3_NOTIFICATION_IMPLEMENTATION_GUIDE.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/IMPLEMENTATION_COMPLETE.md` | `test-apps/daily-notification-test/IMPLEMENTATION_COMPLETE.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM.md` | `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM_RESULTS.md` | `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM_RESULTS.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/README.md` | `test-apps/daily-notification-test/README.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/TODO_NATIVE_FETCHER.md` | `test-apps/daily-notification-test/TODO_NATIVE_FETCHER.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/BUILD_NOTES.md` | `test-apps/ios-test-app/BUILD_NOTES.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/BUILD_SUCCESS.md` | `test-apps/ios-test-app/BUILD_SUCCESS.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/COMPILATION_FIXES.md` | `test-apps/ios-test-app/COMPILATION_FIXES.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/COMPILATION_STATUS.md` | `test-apps/ios-test-app/COMPILATION_STATUS.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/COMPILATION_SUMMARY.md` | `test-apps/ios-test-app/COMPILATION_SUMMARY.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/README.md` | `test-apps/ios-test-app/README.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/SETUP_COMPLETE.md` | `test-apps/ios-test-app/SETUP_COMPLETE.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/SETUP_STATUS.md` | `test-apps/ios-test-app/SETUP_STATUS.md` | Canonical | Keep with test apps |
|
||||
|
||||
---
|
||||
|
||||
## Plugin-Specific Documentation (Keep in `ios/Plugin/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `ios/Plugin/README.md` | `ios/Plugin/README.md` | Canonical | Keep with plugin code |
|
||||
|
||||
---
|
||||
|
||||
## Cursor Rules Documentation (Keep in `.cursor/rules/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `.cursor/rules/README.md` | `.cursor/rules/README.md` | Canonical | Keep with cursor rules |
|
||||
| `.cursor/rules/architecture/README.md` | `.cursor/rules/architecture/README.md` | Canonical | Keep with cursor rules |
|
||||
| `.cursor/rules/meta_rule_architecture.md` | `.cursor/rules/meta_rule_architecture.md` | Canonical | Keep with cursor rules |
|
||||
|
||||
---
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
- **Total Files:** 139
|
||||
- **Canonical (Active):** ~95 files
|
||||
- **Merged:** ~15 files
|
||||
- **Archived:** ~29 files
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] All 139 files have a destination
|
||||
- [ ] No file is marked for deletion
|
||||
- [ ] All merged content is traceable
|
||||
- [ ] Archive structure preserves original paths
|
||||
- [ ] Index references all canonical files
|
||||
- [ ] README.md links to doc/00-INDEX.md
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-16
|
||||
**Status:** Complete - Ready for Implementation
|
||||
|
||||
@@ -50,7 +50,7 @@ fi
|
||||
xcrun simctl install "$SIMULATOR_ID" "$APP_PATH"
|
||||
|
||||
# Launch app
|
||||
xcrun simctl launch "$SIMULATOR_ID" com.timesafari.dailynotification.test
|
||||
xcrun simctl launch "$SIMULATOR_ID" org.timesafari.dailynotification.test
|
||||
```
|
||||
|
||||
**Result:** ✅ Simulator now boots and app launches automatically
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user