Browse Source

refactor(android)!: restructure to standard Capacitor plugin layout

Restructure Android project from nested module layout to standard
Capacitor plugin structure following community conventions.

Structure Changes:
- Move plugin code from android/plugin/ to android/src/main/java/
- Move test app from android/app/ to test-apps/android-test-app/app/
- Remove nested android/plugin module structure
- Remove nested android/app test app structure

Build Infrastructure:
- Add Gradle wrapper files (gradlew, gradlew.bat, gradle/wrapper/)
- Transform android/build.gradle from root project to library module
- Update android/settings.gradle for standalone plugin builds
- Add android/gradle.properties with AndroidX configuration
- Add android/consumer-rules.pro for ProGuard rules

Configuration Updates:
- Add prepare script to package.json for automatic builds on npm install
- Update package.json version to 1.0.1
- Update android/build.gradle to properly resolve Capacitor dependencies
- Update test-apps/android-test-app/settings.gradle with correct paths
- Remove android/variables.gradle (hardcode values in build.gradle)

Documentation:
- Update BUILDING.md with new structure and build process
- Update INTEGRATION_GUIDE.md to reflect standard structure
- Update README.md to remove path fix warnings
- Add test-apps/BUILD_PROCESS.md documenting test app build flows

Test App Configuration:
- Fix android-test-app to correctly reference plugin and Capacitor
- Remove capacitor-cordova-android-plugins dependency (not needed)
- Update capacitor.settings.gradle path verification in fix script

BREAKING CHANGE: Plugin now uses standard Capacitor Android structure.
Consuming apps must update their capacitor.settings.gradle to reference
android/ instead of android/plugin/. This is automatically handled by
Capacitor CLI for apps using standard plugin installation.
master
Matthew Raymer 1 day ago
parent
commit
d9bdeb6d02
  1. 143
      BUILDING.md
  2. 10
      INTEGRATION_GUIDE.md
  3. 8
      README.md
  4. 54
      android/.gitignore
  5. 69
      android/BUILDING.md
  6. 153
      android/android/plugin/src/main/java/com/timesafari/dailynotification/BootReceiver.kt
  7. 144
      android/android/plugin/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt
  8. 202
      android/android/plugin/src/main/java/com/timesafari/dailynotification/FetchWorker.kt
  9. 336
      android/android/plugin/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt
  10. 95
      android/build.gradle
  11. 3
      android/capacitor.settings.gradle
  12. 10
      android/consumer-rules.pro
  13. 41
      android/gradle.properties
  14. BIN
      android/gradle/wrapper/gradle-wrapper.jar
  15. 7
      android/gradlew
  16. 2
      android/gradlew.bat
  17. 67
      android/plugin/build.gradle
  18. 215
      android/plugin/src/test/java/com/timesafari/dailynotification/DailyNotificationDatabaseTest.java
  19. 193
      android/plugin/src/test/java/com/timesafari/dailynotification/DailyNotificationRollingWindowTest.java
  20. 217
      android/plugin/src/test/java/com/timesafari/dailynotification/DailyNotificationTTLEnforcerTest.java
  21. 11
      android/settings.gradle
  22. 9
      android/src/main/AndroidManifest.xml
  23. 0
      android/src/main/java/com/timesafari/dailynotification/BootReceiver.java
  24. 0
      android/src/main/java/com/timesafari/dailynotification/ChannelManager.java
  25. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationETagManager.java
  26. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationErrorHandler.java
  27. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationExactAlarmManager.java
  28. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationFetchWorker.java
  29. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationFetcher.java
  30. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationJWTManager.java
  31. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationMaintenanceWorker.java
  32. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationMigration.java
  33. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationPerformanceOptimizer.java
  34. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java
  35. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationRebootRecoveryManager.java
  36. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java
  37. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationRollingWindow.java
  38. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java
  39. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java
  40. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationTTLEnforcer.java
  41. 0
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java
  42. 0
      android/src/main/java/com/timesafari/dailynotification/DailyReminderInfo.java
  43. 0
      android/src/main/java/com/timesafari/dailynotification/DailyReminderManager.java
  44. 0
      android/src/main/java/com/timesafari/dailynotification/DozeFallbackWorker.java
  45. 0
      android/src/main/java/com/timesafari/dailynotification/EnhancedDailyNotificationFetcher.java
  46. 0
      android/src/main/java/com/timesafari/dailynotification/FetchContext.java
  47. 0
      android/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java
  48. 0
      android/src/main/java/com/timesafari/dailynotification/NotificationContent.java
  49. 0
      android/src/main/java/com/timesafari/dailynotification/NotificationStatusChecker.java
  50. 0
      android/src/main/java/com/timesafari/dailynotification/PendingIntentManager.java
  51. 0
      android/src/main/java/com/timesafari/dailynotification/PermissionManager.java
  52. 0
      android/src/main/java/com/timesafari/dailynotification/SchedulingPolicy.java
  53. 0
      android/src/main/java/com/timesafari/dailynotification/SoftRefetchWorker.java
  54. 0
      android/src/main/java/com/timesafari/dailynotification/TimeSafariIntegrationManager.java
  55. 0
      android/src/main/java/com/timesafari/dailynotification/dao/NotificationConfigDao.java
  56. 0
      android/src/main/java/com/timesafari/dailynotification/dao/NotificationContentDao.java
  57. 0
      android/src/main/java/com/timesafari/dailynotification/dao/NotificationDeliveryDao.java
  58. 0
      android/src/main/java/com/timesafari/dailynotification/database/DailyNotificationDatabase.java
  59. 0
      android/src/main/java/com/timesafari/dailynotification/entities/NotificationConfigEntity.java
  60. 0
      android/src/main/java/com/timesafari/dailynotification/entities/NotificationContentEntity.java
  61. 0
      android/src/main/java/com/timesafari/dailynotification/entities/NotificationDeliveryEntity.java
  62. 0
      android/src/main/java/com/timesafari/dailynotification/storage/DailyNotificationStorageRoom.java
  63. 2
      package.json
  64. 111
      scripts/fix-capacitor-plugin-path.js
  65. 235
      test-apps/BUILD_PROCESS.md
  66. 0
      test-apps/android-test-app/app/.gitignore
  67. 6
      test-apps/android-test-app/app/build.gradle
  68. 0
      test-apps/android-test-app/app/capacitor.build.gradle
  69. 0
      test-apps/android-test-app/app/proguard-rules.pro
  70. 0
      test-apps/android-test-app/app/src/androidTest/java/com/timesafari/dailynotification/ExampleInstrumentedTest.java
  71. 0
      test-apps/android-test-app/app/src/main/AndroidManifest.xml
  72. 16
      test-apps/android-test-app/app/src/main/assets/capacitor.config.json
  73. 0
      test-apps/android-test-app/app/src/main/assets/capacitor.plugins.json
  74. 0
      test-apps/android-test-app/app/src/main/assets/public/cordova.js
  75. 0
      test-apps/android-test-app/app/src/main/assets/public/cordova_plugins.js
  76. 575
      test-apps/android-test-app/app/src/main/assets/public/index.html
  77. 6
      test-apps/android-test-app/app/src/main/assets/public/plugins
  78. 0
      test-apps/android-test-app/app/src/main/java/com/timesafari/dailynotification/DemoNativeFetcher.java
  79. 0
      test-apps/android-test-app/app/src/main/java/com/timesafari/dailynotification/MainActivity.java
  80. 0
      test-apps/android-test-app/app/src/main/java/com/timesafari/dailynotification/PluginApplication.java
  81. 0
      test-apps/android-test-app/app/src/main/res/drawable-land-hdpi/splash.png
  82. 0
      test-apps/android-test-app/app/src/main/res/drawable-land-mdpi/splash.png
  83. 0
      test-apps/android-test-app/app/src/main/res/drawable-land-xhdpi/splash.png
  84. 0
      test-apps/android-test-app/app/src/main/res/drawable-land-xxhdpi/splash.png
  85. 0
      test-apps/android-test-app/app/src/main/res/drawable-land-xxxhdpi/splash.png
  86. 0
      test-apps/android-test-app/app/src/main/res/drawable-port-hdpi/splash.png
  87. 0
      test-apps/android-test-app/app/src/main/res/drawable-port-mdpi/splash.png
  88. 0
      test-apps/android-test-app/app/src/main/res/drawable-port-xhdpi/splash.png
  89. 0
      test-apps/android-test-app/app/src/main/res/drawable-port-xxhdpi/splash.png
  90. 0
      test-apps/android-test-app/app/src/main/res/drawable-port-xxxhdpi/splash.png
  91. 0
      test-apps/android-test-app/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
  92. 0
      test-apps/android-test-app/app/src/main/res/drawable/ic_launcher_background.xml
  93. 0
      test-apps/android-test-app/app/src/main/res/drawable/splash.png
  94. 0
      test-apps/android-test-app/app/src/main/res/layout/activity_main.xml
  95. 0
      test-apps/android-test-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  96. 0
      test-apps/android-test-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
  97. 0
      test-apps/android-test-app/app/src/main/res/mipmap-hdpi/ic_launcher.png
  98. 0
      test-apps/android-test-app/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
  99. 0
      test-apps/android-test-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
  100. 0
      test-apps/android-test-app/app/src/main/res/mipmap-mdpi/ic_launcher.png

143
BUILDING.md

@ -192,7 +192,7 @@ Build → Generate Signed Bundle / APK
#### Build Output
The built plugin AAR will be located at:
```
android/plugin/build/outputs/aar/plugin-release.aar
android/build/outputs/aar/android-release.aar
```
### Project Structure in Android Studio
@ -226,36 +226,14 @@ android/
### Important Distinctions
#### Plugin Module (`android/plugin/`)
- **Purpose**: Contains the actual plugin code
- **No MainActivity** - This is a library, not an app
- **No UI Components** - Plugins provide functionality to host apps
- **Output**: AAR library files
#### Test App Module (`android/app/`)
- **Purpose**: Test application for the plugin
- **Has MainActivity** - Full Capacitor app with BridgeActivity
- **Has UI Components** - HTML/JS interface for testing
- **Output**: APK files for installation
#### What You CAN Do in Android Studio
**Edit Java/Kotlin code** (both plugin and app)
**Run unit tests** (both modules)
**Debug plugin code** (plugin module)
**Build the plugin AAR** (plugin module)
**Build test app APK** (app module)
**Run the test app** (app module)
**Test notifications** (app module)
**Test background tasks** (app module)
**Debug full integration** (app module)
**Check for compilation errors**
**Use code completion and refactoring**
**View build logs and errors**
#### What You CANNOT Do
**Run plugin module directly** (it's a library)
#### Standard Capacitor Plugin Structure
The plugin now follows the standard Capacitor Android structure:
- **Plugin Code**: `android/src/main/java/...`
- **Plugin Build**: `android/build.gradle`
- **Test App**: `test-apps/android-test-app/app/` (separate from plugin)
This structure is compatible with Capacitor's auto-generated files and requires no path fixes.
**Test plugin without host app** (needs Capacitor runtime)
## Command Line Building
@ -416,12 +394,12 @@ test-apps/daily-notification-test/
#### Android Test Apps
The project includes **two separate Android test applications**:
##### 1. Main Android Test App (`/android/app`)
##### 1. Main Android Test App (`test-apps/android-test-app/app`)
A Capacitor-based Android test app with full plugin integration:
```bash
# Build main Android test app
cd android
cd test-apps/android-test-app
./gradlew :app:assembleDebug
# Install on device
@ -431,37 +409,30 @@ adb install app/build/outputs/apk/debug/app-debug.apk
./gradlew :app:test
# Run in Android Studio
# File → Open → /path/to/daily-notification-plugin/android
# File → Open → /path/to/daily-notification-plugin/test-apps/android-test-app
# Select 'app' module and run
```
**App Structure:**
```
android/app/
├── src/
│ ├── main/
│ │ ├── AndroidManifest.xml # App manifest with permissions
│ │ ├── assets/ # Capacitor web assets
│ │ │ ├── capacitor.config.json # Capacitor configuration
│ │ │ ├── capacitor.plugins.json # Plugin registry
│ │ │ └── public/ # Web app files
│ │ │ ├── index.html # Main test interface
│ │ │ ├── cordova.js # Cordova compatibility
│ │ │ └── plugins/ # Plugin JS files
│ │ ├── java/
│ │ │ └── com/timesafari/dailynotification/
│ │ │ └── MainActivity.java # Capacitor BridgeActivity
│ │ └── res/ # Android resources
│ │ ├── drawable/ # App icons and images
│ │ ├── layout/ # Android layouts
│ │ ├── mipmap/ # App launcher icons
│ │ ├── values/ # Strings, styles, colors
│ │ └── xml/ # Configuration files
│ ├── androidTest/ # Instrumented tests
│ └── test/ # Unit tests
├── build.gradle # App build configuration
├── capacitor.build.gradle # Auto-generated Capacitor config
└── proguard-rules.pro # Code obfuscation rules
**Test App Structure:**
```
test-apps/android-test-app/
├── app/
│ ├── src/
│ │ ├── main/
│ │ │ ├── AndroidManifest.xml # App manifest with permissions
│ │ │ ├── assets/ # Capacitor web assets
│ │ │ │ ├── capacitor.config.json # Capacitor configuration
│ │ │ │ ├── capacitor.plugins.json # Plugin registry
│ │ │ │ └── public/ # Web app files
│ │ │ ├── java/
│ │ │ │ └── com/timesafari/dailynotification/
│ │ │ │ └── MainActivity.java # Capacitor BridgeActivity
│ │ │ └── res/ # Android resources
│ │ ├── androidTest/ # Instrumented tests
│ │ └── test/ # Unit tests
│ ├── build.gradle # App build configuration
│ ├── capacitor.build.gradle # Auto-generated Capacitor config
│ └── proguard-rules.pro # Code obfuscation rules
```
**Key Files Explained:**
@ -504,7 +475,7 @@ public class MainActivity extends BridgeActivity {
2. **Java Compilation**: Compiles `MainActivity.java` and dependencies
3. **Resource Processing**: Processes Android resources and assets
4. **APK Generation**: Packages everything into installable APK
5. **Plugin Integration**: Links with plugin AAR from `android/plugin/`
5. **Plugin Integration**: Links with plugin from `node_modules/@timesafari/daily-notification-plugin/android`
**Editing Guidelines:**
- **HTML/JS**: Edit `assets/public/index.html` for UI changes
@ -547,7 +518,7 @@ The Vue 3 test app uses a **project reference approach** for plugin integration:
```gradle
// capacitor.settings.gradle
include ':timesafari-daily-notification-plugin'
project(':timesafari-daily-notification-plugin').projectDir = new File('../../../android/plugin')
project(':timesafari-daily-notification-plugin').projectDir = new File('../../../android')
// capacitor.build.gradle
implementation project(':timesafari-daily-notification-plugin')
@ -576,7 +547,7 @@ The Vue 3 test app uses a **project reference approach** for plugin integration:
**Troubleshooting Integration Issues:**
- **Duplicate Classes**: Use project reference instead of AAR to avoid conflicts
- **Gradle Cache**: Clear completely (`rm -rf ~/.gradle`) when switching approaches
- **Path Issues**: Ensure correct project path (`../../../android/plugin`)
- **Path Issues**: Ensure correct project path (`../../../android`)
- **Dependencies**: Include required WorkManager and Gson dependencies
### Integration Testing
@ -881,33 +852,7 @@ rm -rf ~/.gradle/caches ~/.gradle/daemon
#### Capacitor Settings Path Fix (Test App)
**Problem**: `capacitor.settings.gradle` is auto-generated with incorrect plugin path.
The plugin module is in `android/plugin/` but Capacitor generates a path to `android/`.
**Automatic Solution** (Test App Only):
```bash
# Use the wrapper script that auto-fixes after sync:
npm run cap:sync
# This automatically:
# 1. Runs npx cap sync android
# 2. Fixes capacitor.settings.gradle path (android -> android/plugin/)
# 3. Fixes capacitor.plugins.json registration
```
**Manual Fix** (if needed):
```bash
# After running npx cap sync android directly:
node scripts/fix-capacitor-plugins.js
# Or for plugin development (root project):
./scripts/fix-capacitor-build.sh
```
**Automatic Fix on Install**:
The test app has a `postinstall` hook that automatically fixes these issues after `npm install`.
**Note**: The fix script is idempotent - it only changes what's needed and won't break correct configurations.
**Note**: The plugin now uses standard Capacitor structure, so no path fixes are needed for consuming apps. The test app at `test-apps/android-test-app/` references the plugin correctly.
#### Android Studio Issues
```bash
@ -1031,19 +976,9 @@ daily-notification-plugin/
### Android Structure
```
android/
├── app/ # Main Android test app
│ ├── src/main/java/ # MainActivity.java
│ ├── src/main/assets/ # Capacitor assets
│ ├── build.gradle # App build configuration
│ └── build/outputs/apk/ # Built APK files
├── plugin/ # Plugin library module
│ ├── src/main/java/ # Plugin source code
│ ├── build.gradle # Plugin build configuration
│ └── build/outputs/aar/ # Built AAR files
├── build.gradle # Root Android build configuration
├── settings.gradle # Gradle settings
├── gradle.properties # Gradle properties
└── gradle/wrapper/ # Gradle wrapper files
├── src/main/java/ # Plugin source code
├── build.gradle # Plugin build configuration
└── variables.gradle # Gradle variables
```
### iOS Structure

10
INTEGRATION_GUIDE.md

@ -85,16 +85,16 @@ The plugin has been optimized for **native-first deployment** with the following
## Plugin Repository Structure
The TimeSafari Daily Notification Plugin follows this structure:
The TimeSafari Daily Notification Plugin follows the standard Capacitor plugin structure:
```
daily-notification-plugin/
├── android/
│ ├── build.gradle
│ ├── build.gradle # Plugin build configuration
│ ├── src/main/java/com/timesafari/dailynotification/
│ │ ├── DailyNotificationPlugin.java
│ │ ├── NotificationWorker.java
│ │ ├── DatabaseManager.java
│ │ └── CallbackRegistry.java
│ │ ├── DailyNotificationWorker.java
│ │ ├── DailyNotificationDatabase.java
│ │ └── ... (other plugin classes)
│ └── src/main/AndroidManifest.xml
├── ios/
│ ├── DailyNotificationPlugin.swift

8
README.md

@ -86,6 +86,14 @@ The plugin has been optimized for **native-first deployment** with the following
npm install @timesafari/daily-notification-plugin
```
Or install from Git repository:
```bash
npm install git+https://github.com/timesafari/daily-notification-plugin.git
```
The plugin follows the standard Capacitor Android structure - no additional path configuration needed!
## Quick Start
### Basic Usage

54
android/.gitignore

@ -16,13 +16,17 @@
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Keep gradle wrapper files - they're needed for builds
!gradle/wrapper/gradle-wrapper.jar
!gradle/wrapper/gradle-wrapper.properties
!gradlew
!gradlew.bat
# Local configuration file (sdk path, etc)
local.properties
@ -38,19 +42,9 @@ proguard/
# Android Studio captures folder
captures/
# IntelliJ
# IntelliJ / Android Studio
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
.idea/
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
@ -64,38 +58,6 @@ captures/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

69
android/BUILDING.md

@ -0,0 +1,69 @@
# Building the Daily Notification Plugin
## Important: Standalone Build Limitations
**Capacitor plugins cannot be built standalone** because Capacitor dependencies are npm packages, not Maven artifacts.
### ✅ Correct Way to Build
Build the plugin **within a Capacitor app** that uses it:
```bash
# In a consuming Capacitor app (e.g., test-apps/android-test-app or your app)
cd /path/to/capacitor-app/android
./gradlew assembleDebug
# Or use Capacitor CLI
npx cap sync android
npx cap run android
```
### ❌ What Doesn't Work
```bash
# This will fail - Capacitor dependencies aren't in Maven
cd android
./gradlew assembleDebug
```
### Why This Happens
1. **Capacitor dependencies are npm packages**, not Maven artifacts
2. **Capacitor plugins are meant to be consumed**, not built standalone
3. **The consuming app provides Capacitor** as a project dependency
4. **When you run `npx cap sync`**, Capacitor sets up the correct dependency structure
### For Development & Testing
Use the test app at `test-apps/android-test-app/`:
```bash
cd test-apps/android-test-app
npm install
npx cap sync android
cd android
./gradlew assembleDebug
```
The plugin will be built as part of the test app's build process.
### Gradle Wrapper Purpose
The gradle wrapper in `android/` is provided for:
- ✅ **Syntax checking** - Verify build.gradle syntax
- ✅ **Android Studio** - Open the plugin directory in Android Studio for editing
- ✅ **Documentation** - Show available tasks and structure
- ❌ **Not for standalone builds** - Requires a consuming app context
### Verifying Build Configuration
You can verify the build configuration is correct:
```bash
cd android
./gradlew tasks # Lists available tasks (may show dependency errors, that's OK)
./gradlew clean # Cleans build directory
```
The dependency errors are expected - they confirm the plugin needs a consuming app context.

153
android/android/plugin/src/main/java/com/timesafari/dailynotification/BootReceiver.kt

@ -1,153 +0,0 @@
package com.timesafari.dailynotification
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Boot recovery receiver to reschedule notifications after device reboot
* Implements RECEIVE_BOOT_COMPLETED functionality
*
* @author Matthew Raymer
* @version 1.1.0
*/
class BootReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "DNP-BOOT"
}
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
Log.i(TAG, "Boot completed, rescheduling notifications")
CoroutineScope(Dispatchers.IO).launch {
try {
rescheduleNotifications(context)
} catch (e: Exception) {
Log.e(TAG, "Failed to reschedule notifications after boot", e)
}
}
}
}
private suspend fun rescheduleNotifications(context: Context) {
val db = DailyNotificationDatabase.getDatabase(context)
val enabledSchedules = db.scheduleDao().getEnabled()
Log.i(TAG, "Found ${enabledSchedules.size} enabled schedules to reschedule")
enabledSchedules.forEach { schedule ->
try {
when (schedule.kind) {
"fetch" -> {
// Reschedule WorkManager fetch
val config = ContentFetchConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
url = null, // Will use mock content
timeout = 30000,
retryAttempts = 3,
retryDelay = 1000,
callbacks = CallbackConfig()
)
FetchWorker.scheduleFetch(context, config)
Log.i(TAG, "Rescheduled fetch for schedule: ${schedule.id}")
}
"notify" -> {
// Reschedule AlarmManager notification
val nextRunTime = calculateNextRunTime(schedule)
if (nextRunTime > System.currentTimeMillis()) {
val config = UserNotificationConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification",
body = "Your daily update is ready",
sound = true,
vibration = true,
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}")
}
}
else -> {
Log.w(TAG, "Unknown schedule kind: ${schedule.kind}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to reschedule ${schedule.kind} for ${schedule.id}", e)
}
}
// Record boot recovery in history
try {
db.historyDao().insert(
History(
refId = "boot_recovery_${System.currentTimeMillis()}",
kind = "boot_recovery",
occurredAt = System.currentTimeMillis(),
outcome = "success",
diagJson = "{\"schedules_rescheduled\": ${enabledSchedules.size}}"
)
)
} catch (e: Exception) {
Log.e(TAG, "Failed to record boot recovery", e)
}
}
private fun calculateNextRunTime(schedule: Schedule): Long {
val now = System.currentTimeMillis()
// Simple implementation - for production, use proper cron parsing
return when {
schedule.cron != null -> {
// Parse cron expression and calculate next run
// For now, return next day at 9 AM
now + (24 * 60 * 60 * 1000L)
}
schedule.clockTime != null -> {
// Parse HH:mm and calculate next run
// For now, return next day at specified time
now + (24 * 60 * 60 * 1000L)
}
else -> {
// Default to next day at 9 AM
now + (24 * 60 * 60 * 1000L)
}
}
}
}
/**
* Data classes for configuration (simplified versions)
*/
data class ContentFetchConfig(
val enabled: Boolean,
val schedule: String,
val url: String? = null,
val timeout: Int? = null,
val retryAttempts: Int? = null,
val retryDelay: Int? = null,
val callbacks: CallbackConfig
)
data class UserNotificationConfig(
val enabled: Boolean,
val schedule: String,
val title: String? = null,
val body: String? = null,
val sound: Boolean? = null,
val vibration: Boolean? = null,
val priority: String? = null
)
data class CallbackConfig(
val apiService: String? = null,
val database: String? = null,
val reporting: String? = null
)

144
android/android/plugin/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt

@ -1,144 +0,0 @@
package com.timesafari.dailynotification
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* SQLite schema for Daily Notification Plugin
* Implements TTL-at-fire invariant and rolling window armed design
*
* @author Matthew Raymer
* @version 1.1.0
*/
@Entity(tableName = "content_cache")
data class ContentCache(
@PrimaryKey val id: String,
val fetchedAt: Long, // epoch ms
val ttlSeconds: Int,
val payload: ByteArray, // BLOB
val meta: String? = null
)
@Entity(tableName = "schedules")
data class Schedule(
@PrimaryKey val id: String,
val kind: String, // 'fetch' or 'notify'
val cron: String? = null, // optional cron expression
val clockTime: String? = null, // optional HH:mm
val enabled: Boolean = true,
val lastRunAt: Long? = null,
val nextRunAt: Long? = null,
val jitterMs: Int = 0,
val backoffPolicy: String = "exp",
val stateJson: String? = null
)
@Entity(tableName = "callbacks")
data class Callback(
@PrimaryKey val id: String,
val kind: String, // 'http', 'local', 'queue'
val target: String, // url_or_local
val headersJson: String? = null,
val enabled: Boolean = true,
val createdAt: Long
)
@Entity(tableName = "history")
data class History(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val refId: String, // content or schedule id
val kind: String, // fetch/notify/callback
val occurredAt: Long,
val durationMs: Long? = null,
val outcome: String, // success|failure|skipped_ttl|circuit_open
val diagJson: String? = null
)
@Database(
entities = [ContentCache::class, Schedule::class, Callback::class, History::class],
version = 1,
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class DailyNotificationDatabase : RoomDatabase() {
abstract fun contentCacheDao(): ContentCacheDao
abstract fun scheduleDao(): ScheduleDao
abstract fun callbackDao(): CallbackDao
abstract fun historyDao(): HistoryDao
}
@Dao
interface ContentCacheDao {
@Query("SELECT * FROM content_cache WHERE id = :id")
suspend fun getById(id: String): ContentCache?
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1")
suspend fun getLatest(): ContentCache?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(contentCache: ContentCache)
@Query("DELETE FROM content_cache WHERE fetchedAt < :cutoffTime")
suspend fun deleteOlderThan(cutoffTime: Long)
@Query("SELECT COUNT(*) FROM content_cache")
suspend fun getCount(): Int
}
@Dao
interface ScheduleDao {
@Query("SELECT * FROM schedules WHERE enabled = 1")
suspend fun getEnabled(): List<Schedule>
@Query("SELECT * FROM schedules WHERE id = :id")
suspend fun getById(id: String): Schedule?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(schedule: Schedule)
@Query("UPDATE schedules SET enabled = :enabled WHERE id = :id")
suspend fun setEnabled(id: String, enabled: Boolean)
@Query("UPDATE schedules SET lastRunAt = :lastRunAt, nextRunAt = :nextRunAt WHERE id = :id")
suspend fun updateRunTimes(id: String, lastRunAt: Long?, nextRunAt: Long?)
}
@Dao
interface CallbackDao {
@Query("SELECT * FROM callbacks WHERE enabled = 1")
suspend fun getEnabled(): List<Callback>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(callback: Callback)
@Query("DELETE FROM callbacks WHERE id = :id")
suspend fun deleteById(id: String)
}
@Dao
interface HistoryDao {
@Insert
suspend fun insert(history: History)
@Query("SELECT * FROM history WHERE occurredAt >= :since ORDER BY occurredAt DESC")
suspend fun getSince(since: Long): List<History>
@Query("DELETE FROM history WHERE occurredAt < :cutoffTime")
suspend fun deleteOlderThan(cutoffTime: Long)
@Query("SELECT COUNT(*) FROM history")
suspend fun getCount(): Int
}
class Converters {
@TypeConverter
fun fromByteArray(value: ByteArray?): String? {
return value?.let { String(it) }
}
@TypeConverter
fun toByteArray(value: String?): ByteArray? {
return value?.toByteArray()
}
}

202
android/android/plugin/src/main/java/com/timesafari/dailynotification/FetchWorker.kt

@ -1,202 +0,0 @@
package com.timesafari.dailynotification
import android.content.Context
import android.util.Log
import androidx.work.*
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
/**
* WorkManager implementation for content fetching
* Implements exponential backoff and network constraints
*
* @author Matthew Raymer
* @version 1.1.0
*/
class FetchWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
companion object {
private const val TAG = "DNP-FETCH"
private const val WORK_NAME = "fetch_content"
fun scheduleFetch(context: Context, config: ContentFetchConfig) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.setInputData(
Data.Builder()
.putString("url", config.url)
.putString("headers", config.headers?.toString())
.putInt("timeout", config.timeout ?: 30000)
.putInt("retryAttempts", config.retryAttempts ?: 3)
.putInt("retryDelay", config.retryDelay ?: 1000)
.build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.REPLACE,
workRequest
)
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val start = SystemClock.elapsedRealtime()
val url = inputData.getString("url")
val timeout = inputData.getInt("timeout", 30000)
val retryAttempts = inputData.getInt("retryAttempts", 3)
val retryDelay = inputData.getInt("retryDelay", 1000)
try {
Log.i(TAG, "Starting content fetch from: $url")
val payload = fetchContent(url, timeout, retryAttempts, retryDelay)
val contentCache = ContentCache(
id = generateId(),
fetchedAt = System.currentTimeMillis(),
ttlSeconds = 3600, // 1 hour default TTL
payload = payload,
meta = "fetched_by_workmanager"
)
// Store in database
val db = DailyNotificationDatabase.getDatabase(applicationContext)
db.contentCacheDao().upsert(contentCache)
// Record success in history
db.historyDao().insert(
History(
refId = contentCache.id,
kind = "fetch",
occurredAt = System.currentTimeMillis(),
durationMs = SystemClock.elapsedRealtime() - start,
outcome = "success"
)
)
Log.i(TAG, "Content fetch completed successfully")
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)
Result.failure()
}
}
private suspend fun fetchContent(
url: String?,
timeout: Int,
retryAttempts: Int,
retryDelay: Int
): ByteArray {
if (url.isNullOrBlank()) {
// Generate mock content for testing
return generateMockContent()
}
var lastException: Exception? = null
repeat(retryAttempts) { attempt ->
try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = timeout
connection.readTimeout = timeout
connection.requestMethod = "GET"
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
return connection.inputStream.readBytes()
} else {
throw IOException("HTTP $responseCode: ${connection.responseMessage}")
}
} catch (e: Exception) {
lastException = e
if (attempt < retryAttempts - 1) {
Log.w(TAG, "Fetch attempt ${attempt + 1} failed, retrying in ${retryDelay}ms", e)
kotlinx.coroutines.delay(retryDelay.toLong())
}
}
}
throw lastException ?: IOException("All retry attempts failed")
}
private fun generateMockContent(): ByteArray {
val mockData = """
{
"timestamp": ${System.currentTimeMillis()},
"content": "Daily notification content",
"source": "mock_generator",
"version": "1.1.0"
}
""".trimIndent()
return mockData.toByteArray()
}
private suspend fun recordFailure(outcome: String, start: Long, error: Throwable) {
try {
val db = DailyNotificationDatabase.getDatabase(applicationContext)
db.historyDao().insert(
History(
refId = "fetch_${System.currentTimeMillis()}",
kind = "fetch",
occurredAt = System.currentTimeMillis(),
durationMs = SystemClock.elapsedRealtime() - start,
outcome = outcome,
diagJson = "{\"error\": \"${error.message}\"}"
)
)
} catch (e: Exception) {
Log.e(TAG, "Failed to record failure", e)
}
}
private fun generateId(): String {
return "fetch_${System.currentTimeMillis()}_${(1000..9999).random()}"
}
}
/**
* Database singleton for Room
*/
object DailyNotificationDatabase {
@Volatile
private var INSTANCE: DailyNotificationDatabase? = null
fun getDatabase(context: Context): DailyNotificationDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DailyNotificationDatabase::class.java,
"daily_notification_database"
).build()
INSTANCE = instance
instance
}
}
}

336
android/android/plugin/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt

@ -1,336 +0,0 @@
package com.timesafari.dailynotification
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* AlarmManager implementation for user notifications
* Implements TTL-at-fire logic and notification delivery
*
* @author Matthew Raymer
* @version 1.1.0
*/
class NotifyReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "DNP-NOTIFY"
private const val CHANNEL_ID = "daily_notifications"
private const val NOTIFICATION_ID = 1001
private const val REQUEST_CODE = 2001
fun scheduleExactNotification(
context: Context,
triggerAtMillis: Long,
config: UserNotificationConfig
) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, NotifyReceiver::class.java).apply {
putExtra("title", config.title)
putExtra("body", config.body)
putExtra("sound", config.sound ?: true)
putExtra("vibration", config.vibration ?: true)
putExtra("priority", config.priority ?: "normal")
}
val pendingIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
} else {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
}
Log.i(TAG, "Exact notification scheduled for: $triggerAtMillis")
} catch (e: SecurityException) {
Log.w(TAG, "Cannot schedule exact alarm, falling back to inexact", e)
alarmManager.set(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
}
}
fun cancelNotification(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, NotifyReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
Log.i(TAG, "Notification alarm cancelled")
}
}
override fun onReceive(context: Context, intent: Intent?) {
Log.i(TAG, "Notification receiver triggered")
CoroutineScope(Dispatchers.IO).launch {
try {
// Check if this is a static reminder (no content dependency)
val isStaticReminder = intent?.getBooleanExtra("is_static_reminder", false) ?: false
if (isStaticReminder) {
// Handle static reminder without content cache
val title = intent?.getStringExtra("title") ?: "Daily Reminder"
val body = intent?.getStringExtra("body") ?: "Don't forget your daily check-in!"
val sound = intent?.getBooleanExtra("sound", true) ?: true
val vibration = intent?.getBooleanExtra("vibration", true) ?: true
val priority = intent?.getStringExtra("priority") ?: "normal"
val reminderId = intent?.getStringExtra("reminder_id") ?: "unknown"
showStaticReminderNotification(context, title, body, sound, vibration, priority, reminderId)
// Record reminder trigger in database
recordReminderTrigger(context, reminderId)
return@launch
}
// Existing cached content logic for regular notifications
val db = DailyNotificationDatabase.getDatabase(context)
val latestCache = db.contentCacheDao().getLatest()
if (latestCache == null) {
Log.w(TAG, "No cached content available for notification")
recordHistory(db, "notify", "no_content")
return@launch
}
// TTL-at-fire check
val now = System.currentTimeMillis()
val ttlExpiry = latestCache.fetchedAt + (latestCache.ttlSeconds * 1000L)
if (now > ttlExpiry) {
Log.i(TAG, "Content TTL expired, skipping notification")
recordHistory(db, "notify", "skipped_ttl")
return@launch
}
// Show notification
val title = intent?.getStringExtra("title") ?: "Daily Notification"
val body = intent?.getStringExtra("body") ?: String(latestCache.payload)
val sound = intent?.getBooleanExtra("sound", true) ?: true
val vibration = intent?.getBooleanExtra("vibration", true) ?: true
val priority = intent?.getStringExtra("priority") ?: "normal"
showNotification(context, title, body, sound, vibration, priority)
recordHistory(db, "notify", "success")
// Fire callbacks
fireCallbacks(context, db, "onNotifyDelivered", latestCache)
} catch (e: Exception) {
Log.e(TAG, "Error in notification receiver", e)
try {
val db = DailyNotificationDatabase.getDatabase(context)
recordHistory(db, "notify", "failure", e.message)
} catch (dbError: Exception) {
Log.e(TAG, "Failed to record notification failure", dbError)
}
}
}
}
private fun showNotification(
context: Context,
title: String,
body: String,
sound: Boolean,
vibration: Boolean,
priority: String
) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create notification channel for Android 8.0+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Daily Notifications",
when (priority) {
"high" -> NotificationManager.IMPORTANCE_HIGH
"low" -> NotificationManager.IMPORTANCE_LOW
else -> NotificationManager.IMPORTANCE_DEFAULT
}
).apply {
enableVibration(vibration)
if (!sound) {
setSound(null, null)
}
}
notificationManager.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setPriority(
when (priority) {
"high" -> NotificationCompat.PRIORITY_HIGH
"low" -> NotificationCompat.PRIORITY_LOW
else -> NotificationCompat.PRIORITY_DEFAULT
}
)
.setAutoCancel(true)
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
Log.i(TAG, "Notification displayed: $title")
}
private suspend fun recordHistory(
db: DailyNotificationDatabase,
kind: String,
outcome: String,
diagJson: String? = null
) {
try {
db.historyDao().insert(
History(
refId = "notify_${System.currentTimeMillis()}",
kind = kind,
occurredAt = System.currentTimeMillis(),
outcome = outcome,
diagJson = diagJson
)
)
} catch (e: Exception) {
Log.e(TAG, "Failed to record history", e)
}
}
private suspend fun fireCallbacks(
context: Context,
db: DailyNotificationDatabase,
eventType: String,
contentCache: ContentCache
) {
try {
val callbacks = db.callbackDao().getEnabled()
callbacks.forEach { callback ->
try {
when (callback.kind) {
"http" -> fireHttpCallback(callback, eventType, contentCache)
"local" -> fireLocalCallback(context, callback, eventType, contentCache)
else -> Log.w(TAG, "Unknown callback kind: ${callback.kind}")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to fire callback ${callback.id}", e)
recordHistory(db, "callback", "failure", "{\"callback_id\": \"${callback.id}\", \"error\": \"${e.message}\"}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to fire callbacks", e)
}
}
private suspend fun fireHttpCallback(
callback: Callback,
eventType: String,
contentCache: ContentCache
) {
// HTTP callback implementation would go here
Log.i(TAG, "HTTP callback fired: ${callback.id} for event: $eventType")
}
private suspend fun fireLocalCallback(
context: Context,
callback: Callback,
eventType: String,
contentCache: ContentCache
) {
// Local callback implementation would go here
Log.i(TAG, "Local callback fired: ${callback.id} for event: $eventType")
}
// Static Reminder Helper Methods
private fun showStaticReminderNotification(
context: Context,
title: String,
body: String,
sound: Boolean,
vibration: Boolean,
priority: String,
reminderId: String
) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create notification channel for reminders
createReminderNotificationChannel(context, notificationManager)
val notification = NotificationCompat.Builder(context, "daily_reminders")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
.setPriority(
when (priority) {
"high" -> NotificationCompat.PRIORITY_HIGH
"low" -> NotificationCompat.PRIORITY_LOW
else -> NotificationCompat.PRIORITY_DEFAULT
}
)
.setSound(if (sound) null else null) // Use default sound if enabled
.setAutoCancel(true)
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.build()
notificationManager.notify(reminderId.hashCode(), notification)
Log.i(TAG, "Static reminder displayed: $title (ID: $reminderId)")
}
private fun createReminderNotificationChannel(context: Context, notificationManager: NotificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
"daily_reminders",
"Daily Reminders",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Daily reminder notifications"
enableVibration(true)
setShowBadge(true)
}
notificationManager.createNotificationChannel(channel)
}
}
private fun recordReminderTrigger(context: Context, reminderId: String) {
try {
val prefs = context.getSharedPreferences("daily_reminders", Context.MODE_PRIVATE)
val editor = prefs.edit()
editor.putLong("${reminderId}_lastTriggered", System.currentTimeMillis())
editor.apply()
Log.d(TAG, "Reminder trigger recorded: $reminderId")
} catch (e: Exception) {
Log.e(TAG, "Error recording reminder trigger", e)
}
}
}

95
android/build.gradle

@ -1,29 +1,102 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
apply plugin: 'com.android.library'
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
android {
namespace "com.timesafari.dailynotification.plugin"
compileSdk 35
allprojects {
repositories {
defaultConfig {
minSdk 23
targetSdk 35
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
// Disable test compilation - tests reference deprecated/removed code
// TODO: Rewrite tests to use modern AndroidX testing framework
testOptions {
unitTests.all {
enabled = false
}
}
// Exclude test sources from compilation
sourceSets {
test {
java {
srcDirs = [] // Disable test source compilation
}
}
}
}
repositories {
google()
mavenCentral()
// Try to find Capacitor from node_modules (for standalone builds)
// In consuming apps, Capacitor will be available as a project dependency
def capacitorPath = new File(rootProject.projectDir, '../node_modules/@capacitor/android/capacitor')
if (capacitorPath.exists()) {
flatDir {
dirs capacitorPath
}
}
}
task clean(type: Delete) {
delete rootProject.buildDir
dependencies {
// Capacitor dependency - provided by consuming app
// When included as a project dependency, use project reference
// When building standalone, this will fail (expected - plugin must be built within a Capacitor app)
def capacitorProject = project.findProject(':capacitor-android')
if (capacitorProject != null) {
implementation capacitorProject
} else {
// Try to find from node_modules (for syntax checking only)
def capacitorPath = new File(rootProject.projectDir, '../node_modules/@capacitor/android/capacitor')
if (capacitorPath.exists() && new File(capacitorPath, 'build.gradle').exists()) {
// If we're in a Capacitor app context, try to include it
throw new GradleException("Capacitor Android project not found. This plugin must be built within a Capacitor app that includes :capacitor-android.")
} else {
throw new GradleException("Capacitor Android not found. This plugin must be built within a Capacitor app context.")
}
}
// These dependencies are always available from Maven
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.room:room-runtime:2.6.1"
implementation "androidx.work:work-runtime-ktx:2.9.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.10"
implementation "com.google.code.gson:gson:2.10.1"
implementation "androidx.core:core:1.12.0"
annotationProcessor "androidx.room:room-compiler:2.6.1"
}

3
android/capacitor.settings.gradle

@ -1,3 +0,0 @@
// 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')

10
android/consumer-rules.pro

@ -0,0 +1,10 @@
# Consumer ProGuard rules for Daily Notification Plugin
# These rules are applied to consuming apps when they use this plugin
# Keep plugin classes
-keep class com.timesafari.dailynotification.** { *; }
# Keep Capacitor plugin interface
-keep class com.getcapacitor.Plugin { *; }
-keep @com.getcapacitor.Plugin class * { *; }

41
android/gradle.properties

@ -1,22 +1,29 @@
# Project-wide Gradle settings.
# Project-wide Gradle settings for Daily Notification Plugin
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# AndroidX package structure to make it clearer which packages are bundled with the
# AndroidX library
android.useAndroidX=true
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# Enable Gradle build cache
org.gradle.caching=true
# Enable parallel builds
org.gradle.parallel=true
# Increase memory for Gradle daemon
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# Enable configuration cache
org.gradle.configuration-cache=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

BIN
android/gradle/wrapper/gradle-wrapper.jar

Binary file not shown.

7
android/gradlew

@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -84,7 +86,8 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum

2
android/gradlew.bat

@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################

67
android/plugin/build.gradle

@ -1,67 +0,0 @@
apply plugin: 'com.android.library'
android {
namespace "com.timesafari.dailynotification.plugin"
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
// Disable test compilation - tests reference deprecated/removed code
// TODO: Rewrite tests to use modern AndroidX testing framework
testOptions {
unitTests.all {
enabled = false
}
}
// Exclude test sources from compilation
sourceSets {
test {
java {
srcDirs = [] // Disable test source compilation
}
}
}
}
dependencies {
implementation project(':capacitor-android')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.room:room-runtime:2.6.1"
implementation "androidx.work:work-runtime-ktx:2.9.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.10"
implementation "com.google.code.gson:gson:2.10.1"
implementation "androidx.core:core:1.12.0"
annotationProcessor "androidx.room:room-compiler:2.6.1"
annotationProcessor project(':capacitor-android')
// Temporarily disabled tests due to deprecated Android testing APIs
// TODO: Update test files to use modern AndroidX testing framework
// testImplementation "junit:junit:$junitVersion"
// androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
// androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
}

215
android/plugin/src/test/java/com/timesafari/dailynotification/DailyNotificationDatabaseTest.java

@ -1,215 +0,0 @@
/**
* DailyNotificationDatabaseTest.java
*
* Unit tests for SQLite database functionality
* Tests schema creation, WAL mode, and basic operations
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.test.AndroidTestCase;
import android.test.mock.MockContext;
import java.io.File;
/**
* Unit tests for DailyNotificationDatabase
*
* Tests the core SQLite functionality including:
* - Database creation and schema
* - WAL mode configuration
* - Table and index creation
* - Schema version management
*/
public class DailyNotificationDatabaseTest extends AndroidTestCase {
private DailyNotificationDatabase database;
private Context mockContext;
@Override
protected void setUp() throws Exception {
super.setUp();
// Create mock context
mockContext = new MockContext() {
@Override
public File getDatabasePath(String name) {
return new File(getContext().getCacheDir(), name);
}
};
// Create database instance
database = new DailyNotificationDatabase(mockContext);
}
@Override
protected void tearDown() throws Exception {
if (database != null) {
database.close();
}
super.tearDown();
}
/**
* Test database creation and schema
*/
public void testDatabaseCreation() {
assertNotNull("Database should not be null", database);
SQLiteDatabase db = database.getReadableDatabase();
assertNotNull("Readable database should not be null", db);
assertTrue("Database should be open", db.isOpen());
db.close();
}
/**
* Test WAL mode configuration
*/
public void testWALModeConfiguration() {
SQLiteDatabase db = database.getWritableDatabase();
// Check journal mode
android.database.Cursor cursor = db.rawQuery("PRAGMA journal_mode", null);
assertTrue("Should have journal mode result", cursor.moveToFirst());
String journalMode = cursor.getString(0);
assertEquals("Journal mode should be WAL", "wal", journalMode.toLowerCase());
cursor.close();
// Check synchronous mode
cursor = db.rawQuery("PRAGMA synchronous", null);
assertTrue("Should have synchronous result", cursor.moveToFirst());
int synchronous = cursor.getInt(0);
assertEquals("Synchronous mode should be NORMAL", 1, synchronous);
cursor.close();
// Check foreign keys
cursor = db.rawQuery("PRAGMA foreign_keys", null);
assertTrue("Should have foreign_keys result", cursor.moveToFirst());
int foreignKeys = cursor.getInt(0);
assertEquals("Foreign keys should be enabled", 1, foreignKeys);
cursor.close();
db.close();
}
/**
* Test table creation
*/
public void testTableCreation() {
SQLiteDatabase db = database.getWritableDatabase();
// Check if tables exist
assertTrue("notif_contents table should exist",
tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_CONTENTS));
assertTrue("notif_deliveries table should exist",
tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_DELIVERIES));
assertTrue("notif_config table should exist",
tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_CONFIG));
db.close();
}
/**
* Test index creation
*/
public void testIndexCreation() {
SQLiteDatabase db = database.getWritableDatabase();
// Check if indexes exist
assertTrue("notif_idx_contents_slot_time index should exist",
indexExists(db, "notif_idx_contents_slot_time"));
assertTrue("notif_idx_deliveries_slot index should exist",
indexExists(db, "notif_idx_deliveries_slot"));
db.close();
}
/**
* Test schema version management
*/
public void testSchemaVersion() {
SQLiteDatabase db = database.getWritableDatabase();
// Check user_version
android.database.Cursor cursor = db.rawQuery("PRAGMA user_version", null);
assertTrue("Should have user_version result", cursor.moveToFirst());
int userVersion = cursor.getInt(0);
assertEquals("User version should match database version",
DailyNotificationDatabase.DATABASE_VERSION, userVersion);
cursor.close();
db.close();
}
/**
* Test basic insert operations
*/
public void testBasicInsertOperations() {
SQLiteDatabase db = database.getWritableDatabase();
// Test inserting into notif_contents
android.content.ContentValues values = new android.content.ContentValues();
values.put(DailyNotificationDatabase.COL_CONTENTS_SLOT_ID, "test_slot_1");
values.put(DailyNotificationDatabase.COL_CONTENTS_PAYLOAD_JSON, "{\"title\":\"Test\"}");
values.put(DailyNotificationDatabase.COL_CONTENTS_FETCHED_AT, System.currentTimeMillis());
long rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONTENTS, null, values);
assertTrue("Insert should succeed", rowId > 0);
// Test inserting into notif_config
values.clear();
values.put(DailyNotificationDatabase.COL_CONFIG_K, "test_key");
values.put(DailyNotificationDatabase.COL_CONFIG_V, "test_value");
rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONFIG, null, values);
assertTrue("Config insert should succeed", rowId > 0);
db.close();
}
/**
* Test database file operations
*/
public void testDatabaseFileOperations() {
String dbPath = database.getDatabasePath();
assertNotNull("Database path should not be null", dbPath);
assertTrue("Database path should not be empty", !dbPath.isEmpty());
// Database should exist after creation
assertTrue("Database file should exist", database.databaseExists());
// Database size should be greater than 0
long size = database.getDatabaseSize();
assertTrue("Database size should be greater than 0", size > 0);
}
/**
* Helper method to check if table exists
*/
private boolean tableExists(SQLiteDatabase db, String tableName) {
android.database.Cursor cursor = db.rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
new String[]{tableName});
boolean exists = cursor.moveToFirst();
cursor.close();
return exists;
}
/**
* Helper method to check if index exists
*/
private boolean indexExists(SQLiteDatabase db, String indexName) {
android.database.Cursor cursor = db.rawQuery(
"SELECT name FROM sqlite_master WHERE type='index' AND name=?",
new String[]{indexName});
boolean exists = cursor.moveToFirst();
cursor.close();
return exists;
}
}

193
android/plugin/src/test/java/com/timesafari/dailynotification/DailyNotificationRollingWindowTest.java

@ -1,193 +0,0 @@
/**
* DailyNotificationRollingWindowTest.java
*
* Unit tests for rolling window safety functionality
* Tests window maintenance, capacity management, and platform-specific limits
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.test.AndroidTestCase;
import android.test.mock.MockContext;
import java.util.concurrent.TimeUnit;
/**
* Unit tests for DailyNotificationRollingWindow
*
* Tests the rolling window safety functionality including:
* - Window maintenance and state updates
* - Capacity limit enforcement
* - Platform-specific behavior (iOS vs Android)
* - Statistics and maintenance timing
*/
public class DailyNotificationRollingWindowTest extends AndroidTestCase {
private DailyNotificationRollingWindow rollingWindow;
private Context mockContext;
private DailyNotificationScheduler mockScheduler;
private DailyNotificationTTLEnforcer mockTTLEnforcer;
private DailyNotificationStorage mockStorage;
@Override
protected void setUp() throws Exception {
super.setUp();
// Create mock context
mockContext = new MockContext() {
@Override
public android.content.SharedPreferences getSharedPreferences(String name, int mode) {
return getContext().getSharedPreferences(name, mode);
}
};
// Create mock components
mockScheduler = new MockDailyNotificationScheduler();
mockTTLEnforcer = new MockDailyNotificationTTLEnforcer();
mockStorage = new MockDailyNotificationStorage();
// Create rolling window for Android platform
rollingWindow = new DailyNotificationRollingWindow(
mockContext,
mockScheduler,
mockTTLEnforcer,
mockStorage,
false // Android platform
);
}
@Override
protected void tearDown() throws Exception {
super.tearDown();
}
/**
* Test rolling window initialization
*/
public void testRollingWindowInitialization() {
assertNotNull("Rolling window should be initialized", rollingWindow);
// Test Android platform limits
String stats = rollingWindow.getRollingWindowStats();
assertNotNull("Stats should not be null", stats);
assertTrue("Stats should contain Android platform info", stats.contains("Android"));
}
/**
* Test rolling window maintenance
*/
public void testRollingWindowMaintenance() {
// Test that maintenance can be forced
rollingWindow.forceMaintenance();
// Test maintenance timing
assertFalse("Maintenance should not be needed immediately after forcing",
rollingWindow.isMaintenanceNeeded());
// Test time until next maintenance
long timeUntilNext = rollingWindow.getTimeUntilNextMaintenance();
assertTrue("Time until next maintenance should be positive", timeUntilNext > 0);
}
/**
* Test iOS platform behavior
*/
public void testIOSPlatformBehavior() {
// Create rolling window for iOS platform
DailyNotificationRollingWindow iosRollingWindow = new DailyNotificationRollingWindow(
mockContext,
mockScheduler,
mockTTLEnforcer,
mockStorage,
true // iOS platform
);
String stats = iosRollingWindow.getRollingWindowStats();
assertNotNull("iOS stats should not be null", stats);
assertTrue("Stats should contain iOS platform info", stats.contains("iOS"));
}
/**
* Test maintenance timing
*/
public void testMaintenanceTiming() {
// Initially, maintenance should not be needed
assertFalse("Maintenance should not be needed initially",
rollingWindow.isMaintenanceNeeded());
// Force maintenance
rollingWindow.forceMaintenance();
// Should not be needed immediately after
assertFalse("Maintenance should not be needed after forcing",
rollingWindow.isMaintenanceNeeded());
}
/**
* Test statistics retrieval
*/
public void testStatisticsRetrieval() {
String stats = rollingWindow.getRollingWindowStats();
assertNotNull("Statistics should not be null", stats);
assertTrue("Statistics should contain pending count", stats.contains("pending"));
assertTrue("Statistics should contain daily count", stats.contains("daily"));
assertTrue("Statistics should contain platform info", stats.contains("platform"));
}
/**
* Test error handling
*/
public void testErrorHandling() {
// Test with null components (should not crash)
try {
DailyNotificationRollingWindow errorWindow = new DailyNotificationRollingWindow(
null, null, null, null, false
);
// Should not crash during construction
} catch (Exception e) {
// Expected to handle gracefully
}
}
/**
* Mock DailyNotificationScheduler for testing
*/
private static class MockDailyNotificationScheduler extends DailyNotificationScheduler {
public MockDailyNotificationScheduler() {
super(null, null);
}
@Override
public boolean scheduleNotification(NotificationContent content) {
return true; // Always succeed for testing
}
}
/**
* Mock DailyNotificationTTLEnforcer for testing
*/
private static class MockDailyNotificationTTLEnforcer extends DailyNotificationTTLEnforcer {
public MockDailyNotificationTTLEnforcer() {
super(null, null, false);
}
@Override
public boolean validateBeforeArming(NotificationContent content) {
return true; // Always pass validation for testing
}
}
/**
* Mock DailyNotificationStorage for testing
*/
private static class MockDailyNotificationStorage extends DailyNotificationStorage {
public MockDailyNotificationStorage() {
super(null);
}
}
}

217
android/plugin/src/test/java/com/timesafari/dailynotification/DailyNotificationTTLEnforcerTest.java

@ -1,217 +0,0 @@
/**
* DailyNotificationTTLEnforcerTest.java
*
* Unit tests for TTL-at-fire enforcement functionality
* Tests freshness validation, TTL violation logging, and skip logic
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.test.AndroidTestCase;
import android.test.mock.MockContext;
import java.util.concurrent.TimeUnit;
/**
* Unit tests for DailyNotificationTTLEnforcer
*
* Tests the core TTL enforcement functionality including:
* - Freshness validation before arming
* - TTL violation detection and logging
* - Skip logic for stale content
* - Configuration retrieval from storage
*/
public class DailyNotificationTTLEnforcerTest extends AndroidTestCase {
private DailyNotificationTTLEnforcer ttlEnforcer;
private Context mockContext;
private DailyNotificationDatabase database;
@Override
protected void setUp() throws Exception {
super.setUp();
// Create mock context
mockContext = new MockContext() {
@Override
public android.content.SharedPreferences getSharedPreferences(String name, int mode) {
return getContext().getSharedPreferences(name, mode);
}
};
// Create database instance
database = new DailyNotificationDatabase(mockContext);
// Create TTL enforcer with SQLite storage
ttlEnforcer = new DailyNotificationTTLEnforcer(mockContext, database, true);
}
@Override
protected void tearDown() throws Exception {
if (database != null) {
database.close();
}
super.tearDown();
}
/**
* Test freshness validation with fresh content
*/
public void testFreshContentValidation() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); // 30 minutes from now
long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(5); // 5 minutes ago
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_1", scheduledTime, fetchedAt);
assertTrue("Content should be fresh (5 min old, scheduled 30 min from now)", isFresh);
}
/**
* Test freshness validation with stale content
*/
public void testStaleContentValidation() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); // 30 minutes from now
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); // 2 hours ago
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_2", scheduledTime, fetchedAt);
assertFalse("Content should be stale (2 hours old, exceeds 1 hour TTL)", isFresh);
}
/**
* Test TTL violation detection
*/
public void testTTLViolationDetection() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); // 2 hours ago
// This should trigger a TTL violation
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_3", scheduledTime, fetchedAt);
assertFalse("Should detect TTL violation", isFresh);
// Check that violation was logged (we can't easily test the actual logging,
// but we can verify the method returns false as expected)
}
/**
* Test validateBeforeArming with fresh content
*/
public void testValidateBeforeArmingFresh() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(5);
NotificationContent content = new NotificationContent();
content.setId("test_slot_4");
content.setScheduledTime(scheduledTime);
content.setFetchedAt(fetchedAt);
content.setTitle("Test Notification");
content.setBody("Test body");
boolean shouldArm = ttlEnforcer.validateBeforeArming(content);
assertTrue("Should arm fresh content", shouldArm);
}
/**
* Test validateBeforeArming with stale content
*/
public void testValidateBeforeArmingStale() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2);
NotificationContent content = new NotificationContent();
content.setId("test_slot_5");
content.setScheduledTime(scheduledTime);
content.setFetchedAt(fetchedAt);
content.setTitle("Test Notification");
content.setBody("Test body");
boolean shouldArm = ttlEnforcer.validateBeforeArming(content);
assertFalse("Should not arm stale content", shouldArm);
}
/**
* Test edge case: content fetched exactly at TTL limit
*/
public void testTTLBoundaryCase() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(1); // Exactly 1 hour ago (TTL limit)
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_6", scheduledTime, fetchedAt);
assertTrue("Content at TTL boundary should be considered fresh", isFresh);
}
/**
* Test edge case: content fetched just over TTL limit
*/
public void testTTLBoundaryCaseOver() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(1) - TimeUnit.SECONDS.toMillis(1); // 1 hour + 1 second ago
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_7", scheduledTime, fetchedAt);
assertFalse("Content just over TTL limit should be considered stale", isFresh);
}
/**
* Test TTL violation statistics
*/
public void testTTLViolationStats() {
// Generate some TTL violations
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2);
// Trigger TTL violations
ttlEnforcer.isContentFresh("test_slot_8", scheduledTime, fetchedAt);
ttlEnforcer.isContentFresh("test_slot_9", scheduledTime, fetchedAt);
String stats = ttlEnforcer.getTTLViolationStats();
assertNotNull("TTL violation stats should not be null", stats);
assertTrue("Stats should contain violation count", stats.contains("violations"));
}
/**
* Test error handling with invalid parameters
*/
public void testErrorHandling() {
// Test with null slot ID
boolean result = ttlEnforcer.isContentFresh(null, System.currentTimeMillis(), System.currentTimeMillis());
assertFalse("Should handle null slot ID gracefully", result);
// Test with invalid timestamps
result = ttlEnforcer.isContentFresh("test_slot_10", 0, 0);
assertTrue("Should handle invalid timestamps gracefully", result);
}
/**
* Test TTL configuration retrieval
*/
public void testTTLConfiguration() {
// Test that TTL enforcer can retrieve configuration
// This is indirectly tested through the freshness checks
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(30); // 30 minutes ago
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_11", scheduledTime, fetchedAt);
// Should be fresh (30 min < 1 hour TTL)
assertTrue("Should retrieve TTL configuration correctly", isFresh);
}
}

11
android/settings.gradle

@ -1,6 +1,7 @@
include ':app'
include ':plugin'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
// Settings file for Daily Notification Plugin
// This is a minimal settings.gradle for a Capacitor plugin module
// Capacitor plugins don't typically need a settings.gradle, but it's included
// for standalone builds and Android Studio compatibility
rootProject.name = 'daily-notification-plugin'
apply from: 'capacitor.settings.gradle'

9
android/src/main/AndroidManifest.xml

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.timesafari.dailynotification.plugin">
<!-- Plugin receivers are declared in consuming app's manifest -->
<!-- This manifest is optional and mainly for library metadata -->
</manifest>

0
android/plugin/src/main/java/com/timesafari/dailynotification/BootReceiver.java → android/src/main/java/com/timesafari/dailynotification/BootReceiver.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/ChannelManager.java → android/src/main/java/com/timesafari/dailynotification/ChannelManager.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationETagManager.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationETagManager.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationErrorHandler.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationErrorHandler.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationExactAlarmManager.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationExactAlarmManager.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetchWorker.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationFetchWorker.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetcher.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationFetcher.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationJWTManager.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationJWTManager.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationMaintenanceWorker.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationMaintenanceWorker.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationMigration.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationMigration.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPerformanceOptimizer.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationPerformanceOptimizer.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationRebootRecoveryManager.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationRebootRecoveryManager.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationRollingWindow.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationRollingWindow.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationTTLEnforcer.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationTTLEnforcer.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java → android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyReminderInfo.java → android/src/main/java/com/timesafari/dailynotification/DailyReminderInfo.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DailyReminderManager.java → android/src/main/java/com/timesafari/dailynotification/DailyReminderManager.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/DozeFallbackWorker.java → android/src/main/java/com/timesafari/dailynotification/DozeFallbackWorker.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/EnhancedDailyNotificationFetcher.java → android/src/main/java/com/timesafari/dailynotification/EnhancedDailyNotificationFetcher.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/FetchContext.java → android/src/main/java/com/timesafari/dailynotification/FetchContext.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java → android/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/NotificationContent.java → android/src/main/java/com/timesafari/dailynotification/NotificationContent.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/NotificationStatusChecker.java → android/src/main/java/com/timesafari/dailynotification/NotificationStatusChecker.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/PendingIntentManager.java → android/src/main/java/com/timesafari/dailynotification/PendingIntentManager.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/PermissionManager.java → android/src/main/java/com/timesafari/dailynotification/PermissionManager.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/SchedulingPolicy.java → android/src/main/java/com/timesafari/dailynotification/SchedulingPolicy.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/SoftRefetchWorker.java → android/src/main/java/com/timesafari/dailynotification/SoftRefetchWorker.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/TimeSafariIntegrationManager.java → android/src/main/java/com/timesafari/dailynotification/TimeSafariIntegrationManager.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/dao/NotificationConfigDao.java → android/src/main/java/com/timesafari/dailynotification/dao/NotificationConfigDao.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/dao/NotificationContentDao.java → android/src/main/java/com/timesafari/dailynotification/dao/NotificationContentDao.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/dao/NotificationDeliveryDao.java → android/src/main/java/com/timesafari/dailynotification/dao/NotificationDeliveryDao.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/database/DailyNotificationDatabase.java → android/src/main/java/com/timesafari/dailynotification/database/DailyNotificationDatabase.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/entities/NotificationConfigEntity.java → android/src/main/java/com/timesafari/dailynotification/entities/NotificationConfigEntity.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/entities/NotificationContentEntity.java → android/src/main/java/com/timesafari/dailynotification/entities/NotificationContentEntity.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/entities/NotificationDeliveryEntity.java → android/src/main/java/com/timesafari/dailynotification/entities/NotificationDeliveryEntity.java

0
android/plugin/src/main/java/com/timesafari/dailynotification/storage/DailyNotificationStorageRoom.java → android/src/main/java/com/timesafari/dailynotification/storage/DailyNotificationStorageRoom.java

2
package.json

@ -1,6 +1,6 @@
{
"name": "@timesafari/daily-notification-plugin",
"version": "1.0.0",
"version": "1.0.1",
"description": "TimeSafari Daily Notification Plugin - Enterprise-grade daily notification functionality with dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability across Mobile (Capacitor) and Desktop (Electron) platforms",
"main": "dist/plugin.js",
"module": "dist/esm/index.js",

111
scripts/fix-capacitor-plugin-path.js

@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* Verify Capacitor plugin Android structure (post-restructure)
*
* This script verifies that the plugin follows the standard Capacitor structure:
* - android/src/main/java/... (plugin code)
* - android/build.gradle (plugin build config)
*
* This script is now optional since the plugin uses standard structure.
* It can be used to verify the structure is correct.
*
* @author Matthew Raymer
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function findAppRoot() {
let currentDir = __dirname;
// Go up from scripts/ to plugin root
currentDir = path.dirname(currentDir);
// Verify we're in the plugin root
const pluginPackageJson = path.join(currentDir, 'package.json');
if (!fs.existsSync(pluginPackageJson)) {
throw new Error('Could not find plugin package.json - script may be in wrong location');
}
// Go up from plugin root to node_modules/@timesafari
currentDir = path.dirname(currentDir);
// Go up from node_modules/@timesafari to node_modules
currentDir = path.dirname(currentDir);
// Go up from node_modules to app root
const appRoot = path.dirname(currentDir);
// Verify we found an app root
const androidDir = path.join(appRoot, 'android');
if (!fs.existsSync(androidDir)) {
throw new Error(`Could not find app android directory. Looked in: ${appRoot}`);
}
return appRoot;
}
/**
* Verify plugin uses standard Capacitor structure
*/
function verifyPluginStructure() {
console.log('🔍 Verifying Daily Notification Plugin structure...');
try {
const APP_ROOT = findAppRoot();
const PLUGIN_PATH = path.join(APP_ROOT, 'node_modules', '@timesafari', 'daily-notification-plugin');
const ANDROID_PLUGIN_PATH = path.join(PLUGIN_PATH, 'android');
const PLUGIN_JAVA_PATH = path.join(ANDROID_PLUGIN_PATH, 'src', 'main', 'java');
if (!fs.existsSync(ANDROID_PLUGIN_PATH)) {
console.log('ℹ️ Plugin not found in node_modules (may not be installed yet)');
return;
}
// Check for standard structure
const hasStandardStructure = fs.existsSync(PLUGIN_JAVA_PATH);
const hasOldStructure = fs.existsSync(path.join(ANDROID_PLUGIN_PATH, 'plugin'));
if (hasOldStructure) {
console.log('⚠️ WARNING: Plugin still uses old structure (android/plugin/)');
console.log(' This should not happen after restructure. Please rebuild plugin.');
return;
}
if (hasStandardStructure) {
console.log('✅ Plugin uses standard Capacitor structure (android/src/main/java/)');
console.log(' No fixes needed - plugin path is correct!');
} else {
console.log('⚠️ Plugin structure not recognized');
console.log(` Expected: ${PLUGIN_JAVA_PATH}`);
}
} catch (error) {
console.error('❌ Error verifying plugin structure:', error.message);
process.exit(1);
}
}
/**
* Run verification
*/
function verifyAll() {
console.log('🔍 Daily Notification Plugin - Structure Verification');
console.log('==================================================\n');
verifyPluginStructure();
console.log('\n✅ Verification complete!');
}
// Run if called directly
if (import.meta.url === `file://${process.argv[1]}`) {
verifyAll();
}
export { verifyPluginStructure, verifyAll };

235
test-apps/BUILD_PROCESS.md

@ -0,0 +1,235 @@
# Test Apps Build Process Review
## Summary
Both test apps are configured to **automatically build the plugin** as part of their build process. The plugin is included as a Gradle project dependency, so Gradle handles building it automatically.
---
## Test App 1: `android-test-app` (Standalone Android)
**Location**: `test-apps/android-test-app/`
### Configuration
**Plugin Reference** (`settings.gradle`):
```gradle
// Reference plugin from root project
def pluginPath = new File(settingsDir.parentFile.parentFile, 'android')
include ':daily-notification-plugin'
project(':daily-notification-plugin').projectDir = pluginPath
```
**Plugin Dependency** (`app/build.gradle`):
```gradle
dependencies {
implementation project(':capacitor-android')
implementation project(':daily-notification-plugin') // ✅ Plugin included
// Plugin dependencies also included
}
```
**Capacitor Setup**:
- References Capacitor from `daily-notification-test/node_modules/` (shared dependency)
- Includes `:capacitor-android` project module
### Build Process
1. **Gradle resolves plugin project** - Finds plugin at `../../android`
2. **Gradle builds plugin module** - Compiles plugin Java code to AAR (internally)
3. **Gradle builds app module** - Compiles app code
4. **Gradle links plugin** - Includes plugin classes in app APK
5. **Final output**: `app/build/outputs/apk/debug/app-debug.apk`
### Build Commands
```bash
cd test-apps/android-test-app
# Build debug APK (builds plugin automatically)
./gradlew assembleDebug
# Build release APK
./gradlew assembleRelease
# Clean build
./gradlew clean
# List tasks
./gradlew tasks
```
### Prerequisites
- ✅ Gradle wrapper present (`gradlew`, `gradlew.bat`, `gradle/wrapper/`)
- ✅ Capacitor must be installed in `daily-notification-test/node_modules/` (shared)
- ✅ Plugin must exist at root `android/` directory
---
## Test App 2: `daily-notification-test` (Vue 3 + Capacitor)
**Location**: `test-apps/daily-notification-test/`
### Configuration
**Plugin Installation** (`package.json`):
```json
{
"dependencies": {
"@timesafari/daily-notification-plugin": "file:../../"
}
}
```
**Capacitor Auto-Configuration**:
- `npx cap sync android` automatically:
1. Installs plugin from `file:../../``node_modules/@timesafari/daily-notification-plugin/`
2. Generates `capacitor.settings.gradle` with plugin reference
3. Generates `capacitor.build.gradle` with plugin dependency
4. Generates `capacitor.plugins.json` with plugin registration
**Plugin Reference** (`capacitor.settings.gradle` - auto-generated):
```gradle
include ':timesafari-daily-notification-plugin'
project(':timesafari-daily-notification-plugin').projectDir =
new File('../node_modules/@timesafari/daily-notification-plugin/android')
```
**Plugin Dependency** (`capacitor.build.gradle` - auto-generated):
```gradle
dependencies {
implementation project(':timesafari-daily-notification-plugin')
}
```
### Build Process
1. **npm install** - Installs plugin from `file:../../` to `node_modules/`
2. **npm run build** - Builds Vue 3 web app → `dist/`
3. **npx cap sync android** - Capacitor:
- Copies web assets to `android/app/src/main/assets/`
- Configures plugin in Gradle files
- Registers plugin in `capacitor.plugins.json`
4. **Fix script runs** - Verifies plugin path is correct (post-sync hook)
5. **Gradle builds** - Plugin is built as part of app build
6. **Final output**: `android/app/build/outputs/apk/debug/app-debug.apk`
### Build Commands
```bash
cd test-apps/daily-notification-test
# Initial setup (one-time)
npm install # Installs plugin from file:../../
npx cap sync android # Configures Android build
# Development workflow
npm run build # Builds Vue 3 web app
npx cap sync android # Syncs web assets + plugin config
cd android
./gradlew assembleDebug # Builds Android app (includes plugin)
# Or use Capacitor CLI (does everything)
npx cap run android # Builds web + syncs + builds Android + runs
```
### Post-Install Hook
The `postinstall` script (`scripts/fix-capacitor-plugins.js`) automatically:
- ✅ Verifies plugin is registered in `capacitor.plugins.json`
- ✅ Verifies plugin path in `capacitor.settings.gradle` points to `android/` (standard structure)
- ✅ Fixes path if it incorrectly points to old `android/plugin/` structure
---
## Key Points
### ✅ Both Apps Build Plugin Automatically
- **No manual plugin build needed** - Gradle handles it
- **Plugin is a project dependency** - Built before the app
- **Standard Gradle behavior** - Works like any Android library module
### ✅ Plugin Structure is Standard
- **Plugin location**: `android/src/main/java/...` (standard Capacitor structure)
- **No path fixes needed** - Capacitor auto-generates correct paths
- **Works with `npx cap sync`** - No manual configuration required
### ✅ Build Dependencies
**android-test-app**:
- Requires Capacitor from `daily-notification-test/node_modules/` (shared)
- References plugin directly from root `android/` directory
**daily-notification-test**:
- Requires `npm install` to install plugin
- Requires `npx cap sync android` to configure build
- Plugin installed to `node_modules/` like any npm package
---
## Verification
### Check Plugin is Included
```bash
# For android-test-app
cd test-apps/android-test-app
./gradlew :app:dependencies | grep daily-notification
# For daily-notification-test
cd test-apps/daily-notification-test/android
./gradlew :app:dependencies | grep timesafari
```
### Check Plugin Registration
```bash
# Vue app only
cat test-apps/daily-notification-test/android/app/src/main/assets/capacitor.plugins.json
```
Should contain:
```json
[
{
"name": "DailyNotification",
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
}
]
```
---
## Troubleshooting
### android-test-app: "Capacitor not found"
**Solution**: Run `npm install` in `test-apps/daily-notification-test/` first to install Capacitor dependencies.
### android-test-app: "Plugin not found"
**Solution**: Verify `android/build.gradle` exists at the root project level.
### daily-notification-test: Plugin path wrong
**Solution**: Run `node scripts/fix-capacitor-plugins.js` after `npx cap sync android`. The script now verifies/fixes the path to use standard `android/` structure.
### Both: Build succeeds but plugin doesn't work
**Solution**:
- Check `capacitor.plugins.json` has plugin registered
- Verify plugin classes are in the APK: `unzip -l app-debug.apk | grep DailyNotification`
---
## Summary
**Both test apps handle plugin building automatically**
**Plugin uses standard Capacitor structure** (`android/src/main/java/`)
**No manual plugin builds required** - Gradle handles dependencies
**Build processes are configured correctly** - Ready to use
The test apps are properly configured to build and test the plugin!

0
android/app/.gitignore → test-apps/android-test-app/app/.gitignore

6
android/app/build.gradle → test-apps/android-test-app/app/build.gradle

@ -26,7 +26,7 @@ android {
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
dirs 'libs'
}
}
@ -36,7 +36,7 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
implementation project(':plugin')
implementation project(':daily-notification-plugin')
// Daily Notification Plugin Dependencies
implementation "androidx.room:room-runtime:2.6.1"
@ -47,7 +47,7 @@ dependencies {
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
// Note: capacitor-cordova-android-plugins not needed for standalone Android test app
}
apply from: 'capacitor.build.gradle'

0
android/app/capacitor.build.gradle → test-apps/android-test-app/app/capacitor.build.gradle

0
android/app/proguard-rules.pro → test-apps/android-test-app/app/proguard-rules.pro

0
android/app/src/androidTest/java/com/timesafari/dailynotification/ExampleInstrumentedTest.java → test-apps/android-test-app/app/src/androidTest/java/com/timesafari/dailynotification/ExampleInstrumentedTest.java

0
android/app/src/main/AndroidManifest.xml → test-apps/android-test-app/app/src/main/AndroidManifest.xml

16
test-apps/android-test-app/app/src/main/assets/capacitor.config.json

@ -0,0 +1,16 @@
{
"appId": "com.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
}
}
}

0
android/app/src/main/assets/capacitor.plugins.json → test-apps/android-test-app/app/src/main/assets/capacitor.plugins.json

0
android/Configure → test-apps/android-test-app/app/src/main/assets/public/cordova.js

0
test-apps/android-test-app/app/src/main/assets/public/cordova_plugins.js

575
test-apps/android-test-app/app/src/main/assets/public/index.html

@ -0,0 +1,575 @@
<!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">
<h1>🔔 DailyNotification Plugin Test</h1>
<p>Test the DailyNotification plugin functionality</p>
<p style="font-size: 12px; opacity: 0.8;">Build: 2025-10-14 05:00:00 UTC</p>
<button class="button" onclick="testPlugin()">Test Plugin</button>
<button class="button" onclick="configurePlugin()">Configure Plugin</button>
<button class="button" onclick="checkStatus()">Check Status</button>
<h2>🔔 Notification Tests</h2>
<button class="button" onclick="testNotification()">Test Notification</button>
<button class="button" onclick="scheduleNotification()">Schedule Notification</button>
<button class="button" onclick="showReminder()">Show Reminder</button>
<h2>🔐 Permission Management</h2>
<button class="button" onclick="checkPermissions()">Check Permissions</button>
<button class="button" onclick="requestPermissions()">Request Permissions</button>
<button class="button" onclick="openExactAlarmSettings()">Exact Alarm Settings</button>
<h2>📢 Channel Management</h2>
<button class="button" onclick="checkChannelStatus()">Check Channel Status</button>
<button class="button" onclick="openChannelSettings()">Open Channel Settings</button>
<button class="button" onclick="checkComprehensiveStatus()">Comprehensive 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 testPlugin() {
console.log('testPlugin called');
const status = document.getElementById('status');
status.innerHTML = 'Testing plugin...';
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;
}
// Plugin is loaded and ready
status.innerHTML = 'Plugin is loaded and ready!';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
} catch (error) {
status.innerHTML = `Plugin test failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function configurePlugin() {
console.log('configurePlugin called');
const status = document.getElementById('status');
status.innerHTML = 'Configuring plugin...';
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;
}
// 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...');
// 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(() => {
status.innerHTML = 'Plugin configured successfully!<br>✅ Plugin settings<br>✅ Native fetcher (optional for demo)';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.catch(error => {
status.innerHTML = `Configuration failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Configuration failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function checkStatus() {
console.log('checkStatus called');
const status = document.getElementById('status');
status.innerHTML = 'Checking plugin 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.getNotificationStatus()
.then(result => {
const nextTime = result.nextNotificationTime ? new Date(result.nextNotificationTime).toLocaleString() : 'None scheduled';
status.innerHTML = `Plugin Status:<br>
Enabled: ${result.isEnabled}<br>
Next Notification: ${nextTime}<br>
Pending: ${result.pending}<br>
Settings: ${JSON.stringify(result.settings)}`;
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.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
}
}
// 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() + 600000); // 10 minutes from now
const prefetchTime = new Date(now.getTime() + 300000); // 5 minutes from now
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 + ')';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.catch(error => {
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
}
}
function scheduleNotification() {
console.log('scheduleNotification called');
const status = document.getElementById('status');
status.innerHTML = 'Scheduling notification...';
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;
}
// Schedule notification for 10 minutes from now (allows 5 min prefetch to fire)
const now = new Date();
const notificationTime = new Date(now.getTime() + 600000); // 10 minutes from now
const prefetchTime = new Date(now.getTime() + 300000); // 5 minutes from now
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: 'Scheduled Notification',
body: 'This notification was scheduled 10 minutes ago!',
sound: true,
priority: 'default'
})
.then(() => {
const prefetchTimeReadable = prefetchTime.toLocaleTimeString();
const notificationTimeReadable = notificationTime.toLocaleTimeString();
status.innerHTML = '✅ Notification scheduled!<br>' +
'📥 Prefetch: ' + prefetchTimeReadable + ' (' + prefetchTimeString + ')<br>' +
'🔔 Notification: ' + notificationTimeReadable + ' (' + notificationTimeString + ')';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.catch(error => {
status.innerHTML = `Scheduling failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Scheduling test failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function showReminder() {
console.log('showReminder called');
const status = document.getElementById('status');
status.innerHTML = 'Showing reminder...';
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;
}
// Schedule daily reminder using scheduleDailyReminder
const now = new Date();
const reminderTime = new Date(now.getTime() + 10000); // 10 seconds from now
const timeString = reminderTime.getHours().toString().padStart(2, '0') + ':' +
reminderTime.getMinutes().toString().padStart(2, '0');
window.DailyNotification.scheduleDailyReminder({
id: 'daily-reminder-test',
title: 'Daily Reminder',
body: 'Don\'t forget to check your daily notifications!',
time: timeString,
sound: true,
vibration: true,
priority: 'default',
repeatDaily: false // Just for testing
})
.then(() => {
status.innerHTML = 'Daily reminder scheduled for ' + timeString + '!';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.catch(error => {
status.innerHTML = `Reminder failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Reminder test failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
// Permission management functions
function checkPermissions() {
console.log('checkPermissions called');
const status = document.getElementById('status');
status.innerHTML = 'Checking 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.checkPermissionStatus()
.then(result => {
status.innerHTML = `Permission Status:<br>
Notifications: ${result.notificationsEnabled ? '✅' : '❌'}<br>
Exact Alarm: ${result.exactAlarmEnabled ? '✅' : '❌'}<br>
Wake Lock: ${result.wakeLockEnabled ? '✅' : '❌'}<br>
All Granted: ${result.allPermissionsGranted ? '✅' : '❌'}`;
status.style.background = result.allPermissionsGranted ?
'rgba(0, 255, 0, 0.3)' : 'rgba(255, 165, 0, 0.3)'; // Green or orange
})
.catch(error => {
status.innerHTML = `Permission check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Permission check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
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
// Check permissions again after request
setTimeout(() => {
checkPermissions();
}, 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 openExactAlarmSettings() {
console.log('openExactAlarmSettings called');
const status = document.getElementById('status');
status.innerHTML = 'Opening exact alarm settings...';
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.openExactAlarmSettings()
.then(() => {
status.innerHTML = 'Exact alarm settings opened! Please enable "Allow exact alarms" and return to the app.';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.catch(error => {
status.innerHTML = `Failed to open exact alarm settings: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Failed to open exact alarm settings: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function checkChannelStatus() {
const status = document.getElementById('status');
status.innerHTML = 'Checking channel 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.isChannelEnabled()
.then(result => {
const importanceText = getImportanceText(result.importance);
status.innerHTML = `Channel Status: ${result.enabled ? 'Enabled' : 'Disabled'} (${importanceText})`;
status.style.background = result.enabled ? 'rgba(0, 255, 0, 0.3)' : 'rgba(255, 0, 0, 0.3)';
})
.catch(error => {
status.innerHTML = `Channel check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Channel check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function openChannelSettings() {
const status = document.getElementById('status');
status.innerHTML = 'Opening channel settings...';
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.openChannelSettings()
.then(result => {
if (result.opened) {
status.innerHTML = 'Channel settings opened! Please enable notifications and return to the app.';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
} else {
status.innerHTML = 'Could not open channel settings (may not be available on this device)';
status.style.background = 'rgba(255, 165, 0, 0.3)'; // Orange background
}
})
.catch(error => {
status.innerHTML = `Failed to open channel settings: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Failed to open channel settings: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
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.testPlugin = testPlugin;
window.configurePlugin = configurePlugin;
window.checkStatus = checkStatus;
window.testNotification = testNotification;
window.scheduleNotification = scheduleNotification;
window.showReminder = showReminder;
window.checkPermissions = checkPermissions;
window.requestPermissions = requestPermissions;
window.openExactAlarmSettings = openExactAlarmSettings;
window.checkChannelStatus = checkChannelStatus;
window.openChannelSettings = openChannelSettings;
window.checkComprehensiveStatus = checkComprehensiveStatus;
console.log('Functions attached to window:', {
testPlugin: typeof window.testPlugin,
configurePlugin: typeof window.configurePlugin,
checkStatus: typeof window.checkStatus,
testNotification: typeof window.testNotification,
scheduleNotification: typeof window.scheduleNotification,
showReminder: typeof window.showReminder
});
</script>
</body>
</html>

6
test-apps/android-test-app/app/src/main/assets/public/plugins

@ -0,0 +1,6 @@
[
{
"name": "DailyNotification",
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
}
]

0
android/app/src/main/java/com/timesafari/dailynotification/DemoNativeFetcher.java → test-apps/android-test-app/app/src/main/java/com/timesafari/dailynotification/DemoNativeFetcher.java

0
android/app/src/main/java/com/timesafari/dailynotification/MainActivity.java → test-apps/android-test-app/app/src/main/java/com/timesafari/dailynotification/MainActivity.java

0
android/app/src/main/java/com/timesafari/dailynotification/PluginApplication.java → test-apps/android-test-app/app/src/main/java/com/timesafari/dailynotification/PluginApplication.java

0
android/app/src/main/res/drawable-land-hdpi/splash.png → test-apps/android-test-app/app/src/main/res/drawable-land-hdpi/splash.png

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

0
android/app/src/main/res/drawable-land-mdpi/splash.png → test-apps/android-test-app/app/src/main/res/drawable-land-mdpi/splash.png

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

0
android/app/src/main/res/drawable-land-xhdpi/splash.png → test-apps/android-test-app/app/src/main/res/drawable-land-xhdpi/splash.png

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

0
android/app/src/main/res/drawable-land-xxhdpi/splash.png → test-apps/android-test-app/app/src/main/res/drawable-land-xxhdpi/splash.png

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

0
android/app/src/main/res/drawable-land-xxxhdpi/splash.png → test-apps/android-test-app/app/src/main/res/drawable-land-xxxhdpi/splash.png

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

0
android/app/src/main/res/drawable-port-hdpi/splash.png → test-apps/android-test-app/app/src/main/res/drawable-port-hdpi/splash.png

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

0
android/app/src/main/res/drawable-port-mdpi/splash.png → test-apps/android-test-app/app/src/main/res/drawable-port-mdpi/splash.png

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

0
android/app/src/main/res/drawable-port-xhdpi/splash.png → test-apps/android-test-app/app/src/main/res/drawable-port-xhdpi/splash.png

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

0
android/app/src/main/res/drawable-port-xxhdpi/splash.png → test-apps/android-test-app/app/src/main/res/drawable-port-xxhdpi/splash.png

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

0
android/app/src/main/res/drawable-port-xxxhdpi/splash.png → test-apps/android-test-app/app/src/main/res/drawable-port-xxxhdpi/splash.png

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

0
android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml → test-apps/android-test-app/app/src/main/res/drawable-v24/ic_launcher_foreground.xml

0
android/app/src/main/res/drawable/ic_launcher_background.xml → test-apps/android-test-app/app/src/main/res/drawable/ic_launcher_background.xml

0
android/app/src/main/res/drawable/splash.png → test-apps/android-test-app/app/src/main/res/drawable/splash.png

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

0
android/app/src/main/res/layout/activity_main.xml → test-apps/android-test-app/app/src/main/res/layout/activity_main.xml

0
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml → test-apps/android-test-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

0
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml → test-apps/android-test-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml

0
android/app/src/main/res/mipmap-hdpi/ic_launcher.png → test-apps/android-test-app/app/src/main/res/mipmap-hdpi/ic_launcher.png

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

0
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png → test-apps/android-test-app/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

0
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png → test-apps/android-test-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.png

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

0
android/app/src/main/res/mipmap-mdpi/ic_launcher.png → test-apps/android-test-app/app/src/main/res/mipmap-mdpi/ic_launcher.png

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

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

Loading…
Cancel
Save