diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f91f646 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/.gitignore b/.gitignore index 3371289..34d3813 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,9 @@ Pods/ .vscode/ build/ *.tgz + +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build diff --git a/android/app/build.gradle b/android/app/build.gradle index 9b71034..0bb3814 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,13 +1,15 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'jacoco' android { namespace "com.timesafari.dailynotification" - compileSdkVersion rootProject.ext.compileSdkVersion - buildToolsVersion rootProject.ext.buildToolsVersion + compileSdkVersion 33 + buildToolsVersion "33.0.2" defaultConfig { applicationId "com.timesafari.dailynotification" minSdkVersion 22 - targetSdkVersion rootProject.ext.targetSdkVersion + targetSdkVersion 33 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -20,15 +22,20 @@ android { buildTypes { release { minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } lintOptions { abortOnError false } compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + testOptions { + unitTests.all { + useJUnitPlatform() + } } } @@ -44,15 +51,69 @@ repositories { } dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" - implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" - implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation fileTree(include: ['*.jar'], dir: 'libs') implementation project(':capacitor-android') + implementation project(':capacitor-cordova-android-plugins') + + // Android SDK + implementation 'com.android.support:support-v4:28.0.0' + implementation 'com.android.support:appcompat-v7:28.0.0' + implementation 'com.android.support:design:28.0.0' + + // AndroidX Core + implementation 'androidx.core:core:1.9.0' + implementation 'androidx.core:core-ktx:1.12.0' + + // AndroidX AppCompat + implementation 'androidx.appcompat:appcompat:1.6.1' + + // AndroidX Test + testImplementation 'androidx.test:core:1.5.0' + testImplementation 'androidx.test:runner:1.5.2' + testImplementation 'androidx.test.ext:junit:1.1.5' + testImplementation 'androidx.test.espresso:espresso-core:3.5.1' + testImplementation 'androidx.test:rules:1.5.0' + testImplementation 'androidx.test:core-ktx:1.5.0' + + // JUnit testImplementation 'junit:junit:4.13.2' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2' + testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.2' + + // Mockito + testImplementation 'org.mockito:mockito-core:4.5.1' + testImplementation 'org.mockito:mockito-inline:4.5.1' + + // AndroidX WorkManager + implementation 'androidx.work:work-runtime:2.8.1' + + // AndroidX Room (for local storage) + implementation 'androidx.room:room-runtime:2.6.1' + annotationProcessor 'androidx.room:room-compiler:2.6.1' + + // AndroidX Lifecycle + implementation 'androidx.lifecycle:lifecycle-runtime:2.7.0' + implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0' + + // AndroidX Security + implementation 'androidx.security:security-crypto:1.1.0-alpha06' + + // AndroidX Notification + implementation 'androidx.media:media:1.7.0' + + // AndroidX Broadcast + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' + + // Capacitor dependencies + implementation 'com.getcapacitor:capacitor:5.0.0' + implementation 'com.getcapacitor:capacitor-android:5.0.0' + + // Testing dependencies androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - implementation project(':capacitor-cordova-android-plugins') + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test:rules:1.5.0' + androidTestImplementation 'org.mockito:mockito-android:4.5.1' } apply from: 'capacitor.build.gradle' @@ -63,5 +124,5 @@ try { apply plugin: 'com.google.gms.google-services' } } catch(Exception e) { - logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") + logger.warn("google-services.json not found, google-services plugin not applied. Push Notifications won't work") } diff --git a/android/app/src/androidTest/java/com/timesafari/dailynotification/.LCKDailyNotificationPluginTest.java~ b/android/app/src/androidTest/java/com/timesafari/dailynotification/.LCKDailyNotificationPluginTest.java~ new file mode 100644 index 0000000..146077b --- /dev/null +++ b/android/app/src/androidTest/java/com/timesafari/dailynotification/.LCKDailyNotificationPluginTest.java~ @@ -0,0 +1 @@ +/home/matthew/projects/timesafari/daily-notification-plugin/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationPluginTest.java \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationConfigTest.java b/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationConfigTest.java new file mode 100644 index 0000000..bde1c97 --- /dev/null +++ b/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationConfigTest.java @@ -0,0 +1,104 @@ +/** + * DailyNotificationConfigTest.java + * Daily Notification Plugin for Capacitor + * + * Tests for the DailyNotificationConfig + * + * Features: + * - Unit tests + * - Singleton pattern + * - Configuration management + * - Default values + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.TimeZone; + +import static org.junit.Assert.*; + +@RunWith(MockitoJUnitRunner.class) +public class DailyNotificationConfigTest { + private DailyNotificationConfig config; + + @Before + public void setUp() { + config = DailyNotificationConfig.getInstance(); + } + + @Test + public void testSingletonPattern() { + DailyNotificationConfig config2 = DailyNotificationConfig.getInstance(); + assertSame("Config instances should be the same", config, config2); + } + + @Test + public void testDefaultValues() { + assertEquals("Default max notifications should be 10", 10, + config.getMaxNotificationsPerDay()); + assertEquals("Default timezone should be system default", + TimeZone.getDefault(), config.getDefaultTimeZone()); + assertTrue("Default logging should be enabled", config.isLoggingEnabled()); + assertEquals("Default retention days should be 7", 7, + config.getRetentionDays()); + } + + @Test + public void testMaxNotificationsPerDay() { + config.setMaxNotificationsPerDay(5); + assertEquals("Max notifications should be 5", 5, + config.getMaxNotificationsPerDay()); + } + + @Test + public void testDefaultTimeZone() { + TimeZone newTimeZone = TimeZone.getTimeZone("America/New_York"); + config.setDefaultTimeZone(newTimeZone); + assertEquals("Default timezone should be America/New_York", + newTimeZone, config.getDefaultTimeZone()); + } + + @Test + public void testLoggingEnabled() { + config.setLoggingEnabled(false); + assertFalse("Logging should be disabled", config.isLoggingEnabled()); + + config.setLoggingEnabled(true); + assertTrue("Logging should be enabled", config.isLoggingEnabled()); + } + + @Test + public void testRetentionDays() { + config.setRetentionDays(14); + assertEquals("Retention days should be 14", 14, + config.getRetentionDays()); + } + + @Test + public void testResetToDefaults() { + // Change values + config.setMaxNotificationsPerDay(5); + config.setDefaultTimeZone(TimeZone.getTimeZone("America/New_York")); + config.setLoggingEnabled(false); + config.setRetentionDays(14); + + // Reset to defaults + config.resetToDefaults(); + + // Verify defaults + assertEquals("Max notifications should be reset to 10", 10, + config.getMaxNotificationsPerDay()); + assertEquals("Default timezone should be reset to system default", + TimeZone.getDefault(), config.getDefaultTimeZone()); + assertTrue("Logging should be reset to enabled", config.isLoggingEnabled()); + assertEquals("Retention days should be reset to 7", 7, + config.getRetentionDays()); + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationConstantsTest.java b/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationConstantsTest.java new file mode 100644 index 0000000..7d91baa --- /dev/null +++ b/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationConstantsTest.java @@ -0,0 +1,126 @@ +/** + * DailyNotificationConstantsTest.java + * Daily Notification Plugin for Capacitor + * + * Tests for the DailyNotificationConstants + * + * Features: + * - Unit tests + * - Constant validation + * - Default values + * - Error messages + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.junit.Assert.*; + +@RunWith(MockitoJUnitRunner.class) +public class DailyNotificationConstantsTest { + + @Test + public void testDefaultValues() { + assertEquals("Default title should be 'Daily Notification'", + "Daily Notification", DailyNotificationConstants.DEFAULT_TITLE); + assertEquals("Default body should be 'Your daily update is ready'", + "Your daily update is ready", DailyNotificationConstants.DEFAULT_BODY); + } + + @Test + public void testNotificationIdentifiers() { + assertTrue("Notification ID prefix should start with 'daily-notification-'", + DailyNotificationConstants.NOTIFICATION_ID_PREFIX.startsWith("daily-notification-")); + assertEquals("Event name should be 'notification'", + "notification", DailyNotificationConstants.EVENT_NAME); + } + + @Test + public void testSettingsKeys() { + assertEquals("Sound setting key should be 'sound'", + "sound", DailyNotificationConstants.Settings.SOUND); + assertEquals("Priority setting key should be 'priority'", + "priority", DailyNotificationConstants.Settings.PRIORITY); + assertEquals("Timezone setting key should be 'timezone'", + "timezone", DailyNotificationConstants.Settings.TIMEZONE); + assertEquals("Retry count setting key should be 'retryCount'", + "retryCount", DailyNotificationConstants.Settings.RETRY_COUNT); + assertEquals("Retry interval setting key should be 'retryInterval'", + "retryInterval", DailyNotificationConstants.Settings.RETRY_INTERVAL); + } + + @Test + public void testSettingsDefaultValues() { + assertTrue("Default sound should be true", + DailyNotificationConstants.Settings.DEFAULT_SOUND); + assertEquals("Default priority should be 'default'", + "default", DailyNotificationConstants.Settings.DEFAULT_PRIORITY); + assertEquals("Default retry count should be 3", + 3, DailyNotificationConstants.Settings.DEFAULT_RETRY_COUNT); + assertEquals("Default retry interval should be 1000", + 1000, DailyNotificationConstants.Settings.DEFAULT_RETRY_INTERVAL); + } + + @Test + public void testPluginMethodKeys() { + assertEquals("URL key should be 'url'", + "url", DailyNotificationConstants.Keys.URL); + assertEquals("Time key should be 'time'", + "time", DailyNotificationConstants.Keys.TIME); + assertEquals("Title key should be 'title'", + "title", DailyNotificationConstants.Keys.TITLE); + assertEquals("Body key should be 'body'", + "body", DailyNotificationConstants.Keys.BODY); + assertEquals("Sound key should be 'sound'", + "sound", DailyNotificationConstants.Keys.SOUND); + assertEquals("Priority key should be 'priority'", + "priority", DailyNotificationConstants.Keys.PRIORITY); + assertEquals("Timezone key should be 'timezone'", + "timezone", DailyNotificationConstants.Keys.TIMEZONE); + } + + @Test + public void testChannelSettings() { + assertEquals("Channel ID should be 'daily_notification_channel'", + "daily_notification_channel", DailyNotificationConstants.Channel.ID); + assertEquals("Channel name should be 'Daily Notifications'", + "Daily Notifications", DailyNotificationConstants.Channel.NAME); + assertEquals("Channel description should be 'Daily notification updates'", + "Daily notification updates", DailyNotificationConstants.Channel.DESCRIPTION); + assertTrue("Channel should enable vibration", + DailyNotificationConstants.Channel.ENABLE_VIBRATION); + assertTrue("Channel should enable lights", + DailyNotificationConstants.Channel.ENABLE_LIGHTS); + } + + @Test + public void testTimeConstants() { + assertEquals("Milliseconds per day should be 86400000", + 86400000, DailyNotificationConstants.Time.MILLIS_PER_DAY); + assertEquals("Maximum hours should be 24", + 24, DailyNotificationConstants.Time.MAX_HOURS); + assertEquals("Maximum minutes should be 60", + 60, DailyNotificationConstants.Time.MAX_MINUTES); + } + + @Test + public void testErrorMessages() { + assertEquals("Missing parameters error message should be correct", + "Missing required parameters", DailyNotificationConstants.Errors.MISSING_PARAMS); + assertEquals("Invalid time error message should be correct", + "Invalid time format", DailyNotificationConstants.Errors.INVALID_TIME); + assertEquals("Invalid timezone error message should be correct", + "Invalid timezone", DailyNotificationConstants.Errors.INVALID_TIMEZONE); + assertEquals("Invalid priority error message should be correct", + "Invalid priority value", DailyNotificationConstants.Errors.INVALID_PRIORITY); + assertEquals("Scheduling failed error message should be correct", + "Failed to schedule notification", DailyNotificationConstants.Errors.SCHEDULING_FAILED); + assertEquals("Permission denied error message should be correct", + "Notification permission denied", DailyNotificationConstants.Errors.PERMISSION_DENIED); + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationLoggerTest.java b/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationLoggerTest.java new file mode 100644 index 0000000..b831caf --- /dev/null +++ b/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationLoggerTest.java @@ -0,0 +1,84 @@ +/** + * DailyNotificationLoggerTest.java + * Daily Notification Plugin for Capacitor + * + * Tests for the DailyNotificationLogger + * + * Features: + * - Unit tests + * - Log level testing + * - Logging configuration + * - Error handling + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.mockito.Mockito.*; + +@RunWith(MockitoJUnitRunner.class) +public class DailyNotificationLoggerTest { + private DailyNotificationLogger logger; + + @Before + public void setUp() { + logger = new DailyNotificationLogger(); + MockitoAnnotations.openMocks(this); + } + + @Test + public void testLogLevels() { + // Test each log level + logger.log("Debug message", DailyNotificationLogger.Level.DEBUG); + logger.log("Info message", DailyNotificationLogger.Level.INFO); + logger.log("Warning message", DailyNotificationLogger.Level.WARNING); + logger.log("Error message", DailyNotificationLogger.Level.ERROR); + + // Note: Actual verification would require capturing log output + } + + @Test + public void testLogWithThrowable() { + Exception e = new Exception("Test exception"); + logger.log("Error with exception", DailyNotificationLogger.Level.ERROR, e); + + // Note: Actual verification would require capturing log output + } + + @Test + public void testLoggingEnabled() { + // Test logging enabled + assertTrue(DailyNotificationLogger.isLoggingEnabled()); + + // Disable logging + DailyNotificationLogger.setLoggingEnabled(false); + assertFalse(DailyNotificationLogger.isLoggingEnabled()); + + // Re-enable logging + DailyNotificationLogger.setLoggingEnabled(true); + assertTrue(DailyNotificationLogger.isLoggingEnabled()); + } + + @Test + public void testLogMessageFormat() { + String message = "Test message"; + logger.log(message, DailyNotificationLogger.Level.INFO); + + // Note: Actual verification would require capturing log output + } + + @Test + public void testNullMessage() { + logger.log(null, DailyNotificationLogger.Level.INFO); + + // Note: Actual verification would require capturing log output + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationPluginTest.java b/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationPluginTest.java new file mode 100644 index 0000000..98ee38c --- /dev/null +++ b/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationPluginTest.java @@ -0,0 +1,227 @@ +/** + * DailyNotificationPluginTest.java + * Daily Notification Plugin for Capacitor + * + * Tests for the DailyNotification plugin + * + * Features: + * - Unit tests + * - Integration tests + * - Edge cases + * - Error scenarios + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.PowerManager; +import android.os.BatteryManager; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.getcapacitor.JSObject; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +@RunWith(AndroidJUnit4.class) +public class DailyNotificationPluginTest { + private DailyNotificationPlugin plugin; + private Context context; + private SharedPreferences settings; + + @Mock + private PluginCall mockCall; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + settings = context.getSharedPreferences("test_settings", Context.MODE_PRIVATE); + plugin = new DailyNotificationPlugin(); + MockitoAnnotations.openMocks(this); + } + + @Test + public void testTimeValidation() { + // Valid time + assertTrue(plugin.isValidTime("09:00")); + assertTrue(plugin.isValidTime("00:00")); + assertTrue(plugin.isValidTime("23:59")); + + // Invalid times + assertFalse(plugin.isValidTime("24:00")); + assertFalse(plugin.isValidTime("12:60")); + assertFalse(plugin.isValidTime("9:00")); + assertFalse(plugin.isValidTime("13:5")); + } + + @Test + public void testTimezoneValidation() { + assertTrue(plugin.isValidTimezone("America/New_York")); + assertTrue(plugin.isValidTimezone("Europe/London")); + assertTrue(plugin.isValidTimezone("Asia/Kolkata")); + + assertFalse(plugin.isValidTimezone("Invalid/Timezone")); + assertFalse(plugin.isValidTimezone("America/Invalid")); + } + + @Test + public void testNotificationScheduling() { + when(mockCall.getString("url")).thenReturn("https://example.com"); + when(mockCall.getString("time")).thenReturn("09:00"); + when(mockCall.getString("title")).thenReturn("Test Notification"); + when(mockCall.getString("body")).thenReturn("Test Body"); + + plugin.scheduleDailyNotification(mockCall); + + // Verify notification was scheduled + // Note: Actual verification would require mocking AlarmManager + } + + @Test + public void testSettingsManagement() { + when(mockCall.hasOption("sound")).thenReturn(true); + when(mockCall.getBoolean("sound")).thenReturn(false); + when(mockCall.hasOption("priority")).thenReturn(true); + when(mockCall.getString("priority")).thenReturn("high"); + + plugin.updateSettings(mockCall); + + // Verify settings were updated + assertEquals(false, settings.getBoolean("sound", true)); + assertEquals("high", settings.getString("priority", "default")); + } + + @Test + public void testNotificationCancellation() { + plugin.cancelAllNotifications(mockCall); + + // Verify notifications were cancelled + // Note: Actual verification would require mocking NotificationManager + } + + @Test + public void testNotificationStatus() { + plugin.getNotificationStatus(mockCall); + + // Verify status was retrieved + // Note: Actual verification would require mocking NotificationManager + } + + @Test + public void testErrorHandling() { + when(mockCall.getString("url")).thenReturn(null); + when(mockCall.getString("time")).thenReturn(null); + + plugin.scheduleDailyNotification(mockCall); + + // Verify error was handled + verify(mockCall).reject(contains("Missing required parameters")); + } + + @Test + public void testBackgroundTaskHandling() { + // Test background task scheduling + // Note: Actual verification would require mocking WorkManager + } + + @Test + public void testBatteryOptimization() { + // Mock PowerManager + PowerManager mockPowerManager = mock(PowerManager.class); + when(context.getSystemService(Context.POWER_SERVICE)).thenReturn(mockPowerManager); + + // Test battery optimization exemption check + when(mockPowerManager.isIgnoringBatteryOptimizations(anyString())).thenReturn(true); + plugin.checkBatteryOptimization(); + + // Test Doze mode detection + when(mockPowerManager.isDeviceIdleMode()).thenReturn(true); + assertTrue(plugin.isDeviceInDozeMode()); + + // Test wake lock acquisition and release + PowerManager.WakeLock mockWakeLock = mock(PowerManager.WakeLock.class); + when(mockPowerManager.newWakeLock(anyInt(), anyString())).thenReturn(mockWakeLock); + when(mockWakeLock.isHeld()).thenReturn(false); + + plugin.acquireWakeLock(); + verify(mockWakeLock).acquire(anyLong()); + + when(mockWakeLock.isHeld()).thenReturn(true); + plugin.releaseWakeLock(); + verify(mockWakeLock).release(); + + // Test battery optimization exemption request + PluginCall mockCall = mock(PluginCall.class); + plugin.requestBatteryOptimizationExemption(mockCall); + verify(context).startActivity(any(Intent.class)); + } + + @Test + public void testNotificationChannelCreation() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Verify notification channel was created + // Note: Actual verification would require mocking NotificationManager + } + } + + @Test + public void testRetryLogic() { + // Test retry mechanism + // Note: Actual verification would require mocking network calls + } + + @Test + public void testEventHandling() { + // Test notification event handling + // Note: Actual verification would require mocking event system + } + + @Test + public void testResourceCleanup() { + // Test resource cleanup + // Note: Actual verification would require monitoring system resources + } + + @Test + public void testBatteryMonitoring() { + // Mock battery status intent + Intent batteryStatus = new Intent(Intent.ACTION_BATTERY_CHANGED); + batteryStatus.putExtra(BatteryManager.EXTRA_LEVEL, 75); + batteryStatus.putExtra(BatteryManager.EXTRA_SCALE, 100); + batteryStatus.putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_HEALTH_CHARGING); + + // Send battery status broadcast + context.sendBroadcast(batteryStatus); + + // Verify battery status was updated + assertEquals(75, plugin.getBatteryLevel()); + assertTrue(plugin.isCharging()); + + // Test battery status retrieval + PluginCall mockCall = mock(PluginCall.class); + plugin.getBatteryStatus(mockCall); + verify(mockCall).resolve(any(JSObject.class)); + + // Verify battery stats were stored + SharedPreferences prefs = context.getSharedPreferences(SETTINGS_KEY, Context.MODE_PRIVATE); + assertEquals(75, prefs.getInt("last_battery_level", -1)); + assertTrue(prefs.getBoolean("last_charging_state", false)); + assertTrue(prefs.getLong("last_battery_check", 0) > 0); + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationReceiverTest.java b/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationReceiverTest.java new file mode 100644 index 0000000..0fe1193 --- /dev/null +++ b/android/app/src/androidTest/java/com/timesafari/dailynotification/DailyNotificationReceiverTest.java @@ -0,0 +1,102 @@ +/** + * DailyNotificationReceiverTest.java + * Daily Notification Plugin for Capacitor + * + * Tests for the DailyNotificationReceiver + * + * Features: + * - Unit tests + * - Integration tests + * - Edge cases + * - Error scenarios + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +@RunWith(AndroidJUnit4.class) +public class DailyNotificationReceiverTest { + private DailyNotificationReceiver receiver; + private Context context; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + receiver = new DailyNotificationReceiver(); + } + + @Test + public void testValidNotificationData() { + Intent intent = new Intent(); + intent.putExtra("url", "https://example.com"); + intent.putExtra("title", "Test Notification"); + intent.putExtra("body", "Test Body"); + intent.putExtra("sound", true); + intent.putExtra("priority", "high"); + + receiver.onReceive(context, intent); + + // Note: Actual verification would require mocking NotificationManager + } + + @Test + public void testMissingNotificationData() { + Intent intent = new Intent(); + intent.putExtra("url", "https://example.com"); + // Missing title and body + + receiver.onReceive(context, intent); + + // Note: Actual verification would require checking logs + } + + @Test + public void testInvalidPriority() { + Intent intent = new Intent(); + intent.putExtra("url", "https://example.com"); + intent.putExtra("title", "Test Notification"); + intent.putExtra("body", "Test Body"); + intent.putExtra("priority", "invalid"); + + receiver.onReceive(context, intent); + + // Note: Actual verification would require checking logs + } + + @Test + public void testNotificationTap() { + Intent intent = new Intent(); + intent.putExtra("url", "https://example.com"); + intent.putExtra("title", "Test Notification"); + intent.putExtra("body", "Test Body"); + + // Note: Actual verification would require mocking PendingIntent + } + + @Test + public void testSoundSettings() { + Intent intent = new Intent(); + intent.putExtra("url", "https://example.com"); + intent.putExtra("title", "Test Notification"); + intent.putExtra("body", "Test Body"); + intent.putExtra("sound", false); + + receiver.onReceive(context, intent); + + // Note: Actual verification would require mocking NotificationManager + } +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6379e55..13ff14c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools"> + xmlns:tools="http://schemas.android.com/tools" + package="com.timesafari.dailynotification"> <uses-sdk android:minSdkVersion="22" android:targetSdkVersion="33" /> @@ -8,6 +9,14 @@ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> + <!-- Battery optimization permissions --> + <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> + <uses-permission android:name="android.permission.WAKE_LOCK" /> + + <!-- Background task permissions --> + <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> + <uses-permission android:name="android.permission.USE_EXACT_ALARM" /> + <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" diff --git a/android/app/src/main/java/com/timesafari/dailynotification/BatteryOptimizationSettings.java b/android/app/src/main/java/com/timesafari/dailynotification/BatteryOptimizationSettings.java new file mode 100644 index 0000000..41c9afc --- /dev/null +++ b/android/app/src/main/java/com/timesafari/dailynotification/BatteryOptimizationSettings.java @@ -0,0 +1,225 @@ +/** + * BatteryOptimizationSettings.java + * Daily Notification Plugin for Capacitor + * + * Manages battery optimization settings and power state monitoring + * + * Features: + * - Battery optimization exemption management + * - Power state monitoring + * - Adaptive scheduling based on power state + * - Battery level monitoring + * - Doze mode handling + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.BatteryManager; +import android.os.Build; +import android.os.PowerManager; +import android.provider.Settings; +import android.content.SharedPreferences; + +import androidx.work.Constraints; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; + +import java.util.concurrent.TimeUnit; + +public class BatteryOptimizationSettings { + private static final String TAG = "BatteryOptimizationSettings"; + private static final String BATTERY_OPTIMIZATION_KEY = "battery_optimization_exempt"; + private static final String POWER_STATE_KEY = "power_state"; + private static final String BATTERY_CHECK_INTERVAL = "battery_check_interval"; + private static final long DEFAULT_CHECK_INTERVAL = TimeUnit.HOURS.toMillis(1); + + private final Context context; + private final PowerManager powerManager; + private final SharedPreferences settings; + private final DailyNotificationLogger logger; + + private boolean isOptimizationExempt = false; + private int powerState = PowerManager.POWER_STATE_NORMAL; + private int batteryLevel = 0; + private boolean isCharging = false; + private long lastBatteryCheck = 0; + + public BatteryOptimizationSettings(Context context, SharedPreferences settings) { + this.context = context; + this.settings = settings; + this.powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + this.logger = new DailyNotificationLogger(); + + // Load saved state + isOptimizationExempt = settings.getBoolean(BATTERY_OPTIMIZATION_KEY, false); + powerState = settings.getInt(POWER_STATE_KEY, PowerManager.POWER_STATE_NORMAL); + lastBatteryCheck = settings.getLong(BATTERY_CHECK_INTERVAL, 0); + } + + public boolean requestBatteryOptimizationExemption() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + // Check if already exempt + if (isOptimizationExempt) { + logger.log("App already exempt from battery optimization", + DailyNotificationLogger.Level.INFO); + return true; + } + + // Create intent for battery optimization settings + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(Uri.parse("package:" + context.getPackageName())); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + // Store request time + long requestTime = System.currentTimeMillis(); + SharedPreferences.Editor editor = settings.edit(); + editor.putLong("last_optimization_request", requestTime); + editor.apply(); + + // Start settings activity + context.startActivity(intent); + + // Schedule a check for the optimization status + scheduleOptimizationCheck(); + + logger.log("Battery optimization exemption requested", + DailyNotificationLogger.Level.INFO); + return true; + } catch (Exception e) { + logger.log("Error requesting battery optimization exemption: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + return false; + } + } + return false; + } + + private void scheduleOptimizationCheck() { + OneTimeWorkRequest checkWork = new OneTimeWorkRequest.Builder(BatteryCheckWorker.class) + .setInitialDelay(5, TimeUnit.SECONDS) + .setConstraints(new Constraints.Builder() + .setRequiresBatteryNotLow(true) + .build()) + .build(); + + WorkManager.getInstance(context).enqueue(checkWork); + } + + public void updatePowerState() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + int newPowerState = powerManager.getPowerState(); + if (newPowerState != powerState) { + powerState = newPowerState; + SharedPreferences.Editor editor = settings.edit(); + editor.putInt(POWER_STATE_KEY, powerState); + editor.apply(); + + logger.log("Power state changed to: " + getPowerStateString(powerState), + DailyNotificationLogger.Level.INFO); + + adjustSchedulingForPowerState(); + } + } catch (Exception e) { + logger.log("Error updating power state: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + } + } + } + + public void updateBatteryStatus() { + try { + Intent batteryStatus = context.registerReceiver(null, + new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + + if (batteryStatus != null) { + int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + float batteryPct = level * 100 / (float)scale; + + int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); + boolean charging = status == BatteryManager.BATTERY_HEALTH_CHARGING || + status == BatteryManager.BATTERY_HEALTH_FULL; + + batteryLevel = (int)batteryPct; + isCharging = charging; + lastBatteryCheck = System.currentTimeMillis(); + + // Store battery stats + SharedPreferences.Editor editor = settings.edit(); + editor.putInt("last_battery_level", batteryLevel); + editor.putBoolean("last_charging_state", isCharging); + editor.putLong("last_battery_check", lastBatteryCheck); + editor.apply(); + + logger.log(String.format("Battery status updated: %d%% (%s)", + batteryLevel, + isCharging ? "charging" : "not charging"), + DailyNotificationLogger.Level.DEBUG); + } + } catch (Exception e) { + logger.log("Error updating battery status: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + } + } + + private void adjustSchedulingForPowerState() { + switch (powerState) { + case PowerManager.POWER_STATE_SAVE: + // In power save mode, increase the interval between checks + lastBatteryCheck = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2); + break; + case PowerManager.POWER_STATE_NORMAL: + // In normal mode, use standard intervals + lastBatteryCheck = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(30); + break; + case PowerManager.POWER_STATE_OPTIMIZED: + // In optimized mode, use longer intervals + lastBatteryCheck = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(4); + break; + } + } + + private String getPowerStateString(int state) { + switch (state) { + case PowerManager.POWER_STATE_SAVE: + return "POWER_SAVE"; + case PowerManager.POWER_STATE_NORMAL: + return "NORMAL"; + case PowerManager.POWER_STATE_OPTIMIZED: + return "OPTIMIZED"; + default: + return "UNKNOWN"; + } + } + + public boolean isOptimizationExempt() { + return isOptimizationExempt; + } + + public int getPowerState() { + return powerState; + } + + public int getBatteryLevel() { + return batteryLevel; + } + + public boolean isCharging() { + return isCharging; + } + + public long getLastBatteryCheck() { + return lastBatteryCheck; + } + + public boolean shouldCheckBattery() { + return System.currentTimeMillis() - lastBatteryCheck > DEFAULT_CHECK_INTERVAL; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationConfig.java b/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationConfig.java new file mode 100644 index 0000000..c881f90 --- /dev/null +++ b/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationConfig.java @@ -0,0 +1,87 @@ +/** + * DailyNotificationConfig.java + * Daily Notification Plugin for Capacitor + * + * Configuration manager for the Daily Notification plugin + * + * Features: + * - Singleton pattern + * - Configuration options + * - Default values + * - Settings persistence + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +import java.util.TimeZone; + +public class DailyNotificationConfig { + private static DailyNotificationConfig instance; + + private int maxNotificationsPerDay; + private TimeZone defaultTimeZone; + private boolean loggingEnabled; + private int retentionDays; + + private DailyNotificationConfig() { + resetToDefaults(); + } + + public static synchronized DailyNotificationConfig getInstance() { + if (instance == null) { + instance = new DailyNotificationConfig(); + } + return instance; + } + + public void resetToDefaults() { + maxNotificationsPerDay = 10; + defaultTimeZone = TimeZone.getDefault(); + loggingEnabled = true; + retentionDays = 7; + } + + public int getMaxNotificationsPerDay() { + return maxNotificationsPerDay; + } + + public void setMaxNotificationsPerDay(int maxNotificationsPerDay) { + if (maxNotificationsPerDay < 1) { + throw new IllegalArgumentException("Max notifications per day must be greater than 0"); + } + this.maxNotificationsPerDay = maxNotificationsPerDay; + } + + public TimeZone getDefaultTimeZone() { + return defaultTimeZone; + } + + public void setDefaultTimeZone(TimeZone defaultTimeZone) { + if (defaultTimeZone == null) { + throw new IllegalArgumentException("Default timezone cannot be null"); + } + this.defaultTimeZone = defaultTimeZone; + } + + public boolean isLoggingEnabled() { + return loggingEnabled; + } + + public void setLoggingEnabled(boolean loggingEnabled) { + this.loggingEnabled = loggingEnabled; + DailyNotificationLogger.setLoggingEnabled(loggingEnabled); + } + + public int getRetentionDays() { + return retentionDays; + } + + public void setRetentionDays(int retentionDays) { + if (retentionDays < 1) { + throw new IllegalArgumentException("Retention days must be greater than 0"); + } + this.retentionDays = retentionDays; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationConstants.java b/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationConstants.java new file mode 100644 index 0000000..1389ef9 --- /dev/null +++ b/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationConstants.java @@ -0,0 +1,79 @@ +/** + * DailyNotificationConstants.java + * Daily Notification Plugin for Capacitor + * + * Constants used throughout the Daily Notification plugin + * + * Features: + * - Default values + * - Notification identifiers + * - Settings keys + * - Channel settings + * - Time constants + * - Error messages + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +public class DailyNotificationConstants { + // Default values + public static final String DEFAULT_TITLE = "Daily Notification"; + public static final String DEFAULT_BODY = "Your daily update is ready"; + + // Notification identifiers + public static final String NOTIFICATION_ID_PREFIX = "daily-notification-"; + public static final String EVENT_NAME = "notification"; + + // Settings class + public static class Settings { + public static final String SOUND = "sound"; + public static final String PRIORITY = "priority"; + public static final String TIMEZONE = "timezone"; + public static final String RETRY_COUNT = "retryCount"; + public static final String RETRY_INTERVAL = "retryInterval"; + + public static final boolean DEFAULT_SOUND = true; + public static final String DEFAULT_PRIORITY = "default"; + public static final int DEFAULT_RETRY_COUNT = 3; + public static final int DEFAULT_RETRY_INTERVAL = 1000; + } + + // Plugin method keys + public static class Keys { + public static final String URL = "url"; + public static final String TIME = "time"; + public static final String TITLE = "title"; + public static final String BODY = "body"; + public static final String SOUND = "sound"; + public static final String PRIORITY = "priority"; + public static final String TIMEZONE = "timezone"; + } + + // Channel settings + public static class Channel { + public static final String ID = "daily_notification_channel"; + public static final String NAME = "Daily Notifications"; + public static final String DESCRIPTION = "Daily notification updates"; + public static final boolean ENABLE_VIBRATION = true; + public static final boolean ENABLE_LIGHTS = true; + } + + // Time constants + public static class Time { + public static final long MILLIS_PER_DAY = 86400000; + public static final int MAX_HOURS = 24; + public static final int MAX_MINUTES = 60; + } + + // Error messages + public static class Errors { + public static final String MISSING_PARAMS = "Missing required parameters"; + public static final String INVALID_TIME = "Invalid time format"; + public static final String INVALID_TIMEZONE = "Invalid timezone"; + public static final String INVALID_PRIORITY = "Invalid priority value"; + public static final String SCHEDULING_FAILED = "Failed to schedule notification"; + public static final String PERMISSION_DENIED = "Notification permission denied"; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationLogger.java b/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationLogger.java new file mode 100644 index 0000000..76d7bcd --- /dev/null +++ b/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationLogger.java @@ -0,0 +1,92 @@ +/** + * DailyNotificationLogger.java + * Daily Notification Plugin for Capacitor + * + * Logging utility for the Daily Notification plugin + * + * Features: + * - Log levels (DEBUG, INFO, WARNING, ERROR) + * - Throwable support + * - Enable/disable logging + * - Tag-based logging + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +import android.util.Log; + +public class DailyNotificationLogger { + private static final String TAG = "DailyNotification"; + private static boolean loggingEnabled = true; + + public enum Level { + DEBUG, + INFO, + WARNING, + ERROR + } + + public static void setLoggingEnabled(boolean enabled) { + loggingEnabled = enabled; + } + + public static boolean isLoggingEnabled() { + return loggingEnabled; + } + + public void log(String message, Level level) { + log(message, level, null); + } + + public void log(String message, Level level, Throwable throwable) { + if (!loggingEnabled) { + return; + } + + String formattedMessage = formatMessage(message); + + switch (level) { + case DEBUG: + if (throwable != null) { + Log.d(TAG, formattedMessage, throwable); + } else { + Log.d(TAG, formattedMessage); + } + break; + + case INFO: + if (throwable != null) { + Log.i(TAG, formattedMessage, throwable); + } else { + Log.i(TAG, formattedMessage); + } + break; + + case WARNING: + if (throwable != null) { + Log.w(TAG, formattedMessage, throwable); + } else { + Log.w(TAG, formattedMessage); + } + break; + + case ERROR: + if (throwable != null) { + Log.e(TAG, formattedMessage, throwable); + } else { + Log.e(TAG, formattedMessage); + } + break; + } + } + + private String formatMessage(String message) { + if (message == null) { + return "null"; + } + + return String.format("[%s] %s", TAG, message); + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java b/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java index 954a478..7bd6bca 100644 --- a/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java +++ b/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java @@ -3,6 +3,16 @@ * Daily Notification Plugin for Capacitor * * Handles daily notification scheduling and management on Android + * + * Features: + * - Daily notification scheduling with precise timing + * - Notification channel management + * - Settings persistence + * - Background task handling + * - Battery optimization support + * - Error handling and logging + * + * @author Matthew Raymer */ package com.timesafari.dailynotification; @@ -11,12 +21,20 @@ 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.content.IntentFilter; import android.content.SharedPreferences; +import android.os.BatteryManager; import android.os.Build; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.PowerManager; import android.os.SystemClock; +import android.provider.Settings; +import android.util.Log; import com.getcapacitor.JSObject; import com.getcapacitor.Plugin; @@ -26,26 +44,64 @@ import com.getcapacitor.annotation.CapacitorPlugin; import java.util.Calendar; import java.util.TimeZone; +import java.util.concurrent.TimeUnit; + +import androidx.work.Constraints; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; @CapacitorPlugin(name = "DailyNotification") public class DailyNotificationPlugin extends Plugin { + private static final String TAG = "DailyNotificationPlugin"; private static final String CHANNEL_ID = "daily_notification_channel"; private static final String CHANNEL_NAME = "Daily Notifications"; private static final String CHANNEL_DESCRIPTION = "Daily notification updates"; + private static final String SETTINGS_KEY = "daily_notification_settings"; + private static final String BATTERY_STATS_KEY = "battery_stats"; + private static final String BATTERY_OPTIMIZATION_KEY = "battery_optimization_status"; + private static final String BATTERY_CHECK_INTERVAL = "battery_check_interval"; + private static final String LAST_NOTIFICATION_KEY = "last_notification_time"; + private static final String POWER_STATE_KEY = "power_state"; + private static final String ADAPTIVE_SCHEDULING_KEY = "adaptive_scheduling"; private NotificationManager notificationManager; private AlarmManager alarmManager; + private PowerManager powerManager; + private PowerManager.WakeLock wakeLock; private Context context; private SharedPreferences settings; - private static final String SETTINGS_KEY = "daily_notification_settings"; + private DailyNotificationLogger logger; + private boolean isOptimizationExempt = false; + private long lastBatteryCheck; + private int batteryLevel; + private boolean isCharging; + private long lastOptimizationCheck; + private boolean adaptiveSchedulingEnabled = true; + private int powerState = PowerManager.POWER_STATE_NORMAL; @Override public void load() { context = getContext(); notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - createNotificationChannel(); + powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); settings = context.getSharedPreferences(SETTINGS_KEY, Context.MODE_PRIVATE); + logger = new DailyNotificationLogger(); + + createNotificationChannel(); + initializeSettings(); + checkBatteryOptimization(); + initializeBatteryMonitoring(); + logger.log("Plugin loaded successfully", DailyNotificationLogger.Level.INFO); + } + + private void initializeSettings() { + SharedPreferences.Editor editor = settings.edit(); + if (!settings.contains("sound")) editor.putBoolean("sound", true); + if (!settings.contains("priority")) editor.putString("priority", "default"); + if (!settings.contains("retryCount")) editor.putInt("retryCount", 3); + if (!settings.contains("retryInterval")) editor.putInt("retryInterval", 1000); + editor.apply(); } private void createNotificationChannel() { @@ -56,34 +112,204 @@ public class DailyNotificationPlugin extends Plugin { NotificationManager.IMPORTANCE_DEFAULT ); channel.setDescription(CHANNEL_DESCRIPTION); + channel.enableVibration(true); + channel.enableLights(true); notificationManager.createNotificationChannel(channel); + logger.log("Notification channel created", DailyNotificationLogger.Level.INFO); + } + } + + private void checkBatteryOptimization() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + String packageName = context.getPackageName(); + isOptimizationExempt = powerManager.isIgnoringBatteryOptimizations(packageName); + lastOptimizationCheck = System.currentTimeMillis(); + + // Store optimization status + SharedPreferences.Editor editor = settings.edit(); + editor.putBoolean(BATTERY_OPTIMIZATION_KEY, isOptimizationExempt); + editor.putLong("last_optimization_check", lastOptimizationCheck); + editor.apply(); + + logger.log("Battery optimization status: " + (isOptimizationExempt ? "exempt" : "not exempt"), + DailyNotificationLogger.Level.INFO); + + // If not exempt and haven't checked recently, request exemption + if (!isOptimizationExempt && + System.currentTimeMillis() - lastOptimizationCheck > BATTERY_CHECK_INTERVAL) { + requestBatteryOptimizationExemption(null); + } } } @PluginMethod - public void scheduleDailyNotification(PluginCall call) { - String url = call.getString("url"); - String time = call.getString("time"); - - if (url == null || time == null) { - call.reject("Missing required parameters"); - return; + public void requestBatteryOptimizationExemption(PluginCall call) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // Check if we already have exemption + if (isOptimizationExempt) { + logger.log("App already exempt from battery optimization", + DailyNotificationLogger.Level.INFO); + if (call != null) { + call.resolve(); + } + return; + } + + // Create intent for battery optimization settings + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(android.net.Uri.parse("package:" + context.getPackageName())); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + // Store current time for tracking + long requestTime = System.currentTimeMillis(); + SharedPreferences.Editor editor = settings.edit(); + editor.putLong("last_optimization_request", requestTime); + editor.apply(); + + // Start the activity + context.startActivity(intent); + + // Schedule a check for the optimization status after a delay + new Handler(Looper.getMainLooper()).postDelayed(() -> { + checkBatteryOptimization(); + if (call != null) { + JSObject result = new JSObject(); + result.put("isExempt", isOptimizationExempt); + result.put("requestTime", requestTime); + result.put("checkTime", System.currentTimeMillis()); + call.resolve(result); + } + }, 5000); // Check after 5 seconds + + logger.log("Battery optimization exemption requested", + DailyNotificationLogger.Level.INFO); + } else { + if (call != null) { + call.reject("Battery optimization exemption not supported on this Android version"); + } + } + } catch (Exception e) { + logger.log("Error requesting battery optimization exemption: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + if (call != null) { + call.reject("Failed to request battery optimization exemption: " + e.getMessage()); + } } - - // Parse time string (HH:mm format) - String[] timeComponents = time.split(":"); - if (timeComponents.length != 2) { - call.reject("Invalid time format"); - return; + } + + private void acquireWakeLock() { + if (wakeLock == null) { + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "DailyNotification:WakeLock" + ); } - + + if (!wakeLock.isHeld()) { + // Adjust wake lock duration based on battery level + long duration = TimeUnit.MINUTES.toMillis(1); + if (batteryLevel < 15) { + duration = TimeUnit.SECONDS.toMillis(30); + } else if (batteryLevel < 30) { + duration = TimeUnit.SECONDS.toMillis(45); + } + + wakeLock.acquire(duration); + logger.log("Wake lock acquired for " + duration + "ms", DailyNotificationLogger.Level.DEBUG); + } + } + + private void releaseWakeLock() { + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + logger.log("Wake lock released", DailyNotificationLogger.Level.DEBUG); + } + } + + private boolean isDeviceInDozeMode() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return powerManager.isDeviceIdleMode(); + } + return false; + } + + private void handleDozeMode() { + if (isDeviceInDozeMode()) { + logger.log("Device is in Doze mode", DailyNotificationLogger.Level.WARNING); + + // If not exempt from battery optimization, try to request exemption + if (!isOptimizationExempt) { + requestBatteryOptimizationExemption(null); + } + + // Schedule a maintenance window for the next time the device exits Doze mode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // Use WorkManager for more reliable scheduling during Doze + OneTimeWorkRequest maintenanceWork = new OneTimeWorkRequest.Builder(MaintenanceWorker.class) + .setInitialDelay(15, TimeUnit.MINUTES) + .setConstraints(new Constraints.Builder() + .setRequiresDeviceIdle(false) + .setRequiresBatteryNotLow(true) + .build()) + .build(); + + WorkManager.getInstance(context).enqueue(maintenanceWork); + + // Also set an alarm as backup + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(15), + createMaintenancePendingIntent() + ); + } + } + } + + private PendingIntent createMaintenancePendingIntent() { + Intent intent = new Intent(context, DailyNotificationReceiver.class); + intent.setAction("MAINTENANCE_WINDOW"); + return PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + } + + @PluginMethod + public void scheduleDailyNotification(PluginCall call) { try { + // Check battery optimization status + if (!isOptimizationExempt) { + logger.log("Warning: App is not exempt from battery optimization", + DailyNotificationLogger.Level.WARNING); + } + + // Check Doze mode + handleDozeMode(); + + // Acquire wake lock for scheduling + acquireWakeLock(); + + String url = call.getString("url"); + String time = call.getString("time"); + + if (url == null || time == null) { + throw new IllegalArgumentException("Missing required parameters"); + } + + // Parse time string (HH:mm format) + String[] timeComponents = time.split(":"); + if (timeComponents.length != 2) { + throw new IllegalArgumentException("Invalid time format"); + } + int hour = Integer.parseInt(timeComponents[0]); int minute = Integer.parseInt(timeComponents[1]); if (hour < 0 || hour >= 24 || minute < 0 || minute >= 60) { - call.reject("Invalid time values"); - return; + throw new IllegalArgumentException("Invalid time values"); } // Create calendar instance for the specified time @@ -102,6 +328,8 @@ public class DailyNotificationPlugin extends Plugin { intent.putExtra("url", url); intent.putExtra("title", call.getString("title", "Daily Notification")); intent.putExtra("body", call.getString("body", "Your daily update is ready")); + intent.putExtra("sound", call.getBoolean("sound", settings.getBoolean("sound", true))); + intent.putExtra("priority", call.getString("priority", settings.getString("priority", "default"))); PendingIntent pendingIntent = PendingIntent.getBroadcast( context, @@ -110,66 +338,399 @@ public class DailyNotificationPlugin extends Plugin { PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE ); - // Schedule the alarm + // Schedule the alarm with exact timing + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (alarmManager.canScheduleExactAlarms()) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.getTimeInMillis(), + pendingIntent + ); + } else { + throw new SecurityException("Cannot schedule exact alarms"); + } + } else { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.getTimeInMillis(), + pendingIntent + ); + } + + // Set repeating alarm for subsequent days alarmManager.setRepeating( AlarmManager.RTC_WAKEUP, - calendar.getTimeInMillis(), - AlarmManager.INTERVAL_DAY, + calendar.getTimeInMillis() + TimeUnit.DAYS.toMillis(1), + TimeUnit.DAYS.toMillis(1), pendingIntent ); + // Release wake lock after scheduling + releaseWakeLock(); + + // Store the scheduled time and URL for potential rescheduling + SharedPreferences.Editor editor = settings.edit(); + editor.putString("scheduled_time", time); + editor.putString("notification_url", url); + editor.putString("notification_title", call.getString("title", "Daily Notification")); + editor.putString("notification_body", call.getString("body", "Your daily update is ready")); + editor.apply(); + + logger.log("Notification scheduled for " + time, DailyNotificationLogger.Level.INFO); call.resolve(); - } catch (NumberFormatException e) { - call.reject("Invalid time format"); + + } catch (Exception e) { + releaseWakeLock(); + logger.log("Error scheduling notification: " + e.getMessage(), DailyNotificationLogger.Level.ERROR); + call.reject("Failed to schedule notification: " + e.getMessage()); } } @PluginMethod public void getLastNotification(PluginCall call) { - // TODO: Implement last notification retrieval - JSObject result = new JSObject(); - result.put("id", ""); - result.put("title", ""); - result.put("body", ""); - result.put("timestamp", 0); - call.resolve(result); + try { + // TODO: Implement last notification retrieval from local storage + JSObject result = new JSObject(); + result.put("id", ""); + result.put("title", ""); + result.put("body", ""); + result.put("timestamp", 0); + call.resolve(result); + } catch (Exception e) { + logger.log("Error getting last notification: " + e.getMessage(), DailyNotificationLogger.Level.ERROR); + call.reject("Failed to get last notification: " + e.getMessage()); + } } @PluginMethod public void cancelAllNotifications(PluginCall call) { - Intent intent = new Intent(context, DailyNotificationReceiver.class); - PendingIntent pendingIntent = PendingIntent.getBroadcast( - context, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE - ); - alarmManager.cancel(pendingIntent); - call.resolve(); + try { + Intent intent = new Intent(context, DailyNotificationReceiver.class); + PendingIntent pendingIntent = PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + alarmManager.cancel(pendingIntent); + notificationManager.cancelAll(); + logger.log("All notifications cancelled", DailyNotificationLogger.Level.INFO); + call.resolve(); + } catch (Exception e) { + logger.log("Error cancelling notifications: " + e.getMessage(), DailyNotificationLogger.Level.ERROR); + call.reject("Failed to cancel notifications: " + e.getMessage()); + } } @PluginMethod public void getNotificationStatus(PluginCall call) { - JSObject result = new JSObject(); - result.put("nextNotificationTime", 0); // TODO: Implement next notification time - result.put("isEnabled", true); // TODO: Check system notification settings - call.resolve(result); + try { + JSObject result = new JSObject(); + result.put("nextNotificationTime", getNextNotificationTime()); + result.put("isEnabled", isNotificationEnabled()); + result.put("settings", getCurrentSettings()); + call.resolve(result); + } catch (Exception e) { + logger.log("Error getting notification status: " + e.getMessage(), DailyNotificationLogger.Level.ERROR); + call.reject("Failed to get notification status: " + e.getMessage()); + } } @PluginMethod public void updateSettings(PluginCall call) { - SharedPreferences.Editor editor = settings.edit(); - if (call.hasOption("timezone")) { - String timezone = call.getString("timezone"); - if (TimeZone.getTimeZone(timezone) != null) { - editor.putString("timezone", timezone); - } else { - call.reject("Invalid timezone"); - return; + try { + SharedPreferences.Editor editor = settings.edit(); + + if (call.hasOption("sound")) { + editor.putBoolean("sound", call.getBoolean("sound")); + } + + if (call.hasOption("priority")) { + String priority = call.getString("priority"); + if (isValidPriority(priority)) { + editor.putString("priority", priority); + } else { + throw new IllegalArgumentException("Invalid priority value"); + } } + + if (call.hasOption("timezone")) { + String timezone = call.getString("timezone"); + if (TimeZone.getTimeZone(timezone) != null) { + editor.putString("timezone", timezone); + } else { + throw new IllegalArgumentException("Invalid timezone"); + } + } + + editor.apply(); + logger.log("Settings updated successfully", DailyNotificationLogger.Level.INFO); + call.resolve(getCurrentSettings()); + } catch (Exception e) { + logger.log("Error updating settings: " + e.getMessage(), DailyNotificationLogger.Level.ERROR); + call.reject("Failed to update settings: " + e.getMessage()); + } + } + + private long getNextNotificationTime() { + // TODO: Implement next notification time retrieval + return 0; + } + + private boolean isNotificationEnabled() { + return notificationManager.areNotificationsEnabled(); + } + + private JSObject getCurrentSettings() { + JSObject settings = new JSObject(); + settings.put("sound", this.settings.getBoolean("sound", true)); + settings.put("priority", this.settings.getString("priority", "default")); + settings.put("timezone", this.settings.getString("timezone", TimeZone.getDefault().getID())); + return settings; + } + + private boolean isValidPriority(String priority) { + return priority != null && + (priority.equals("high") || + priority.equals("default") || + priority.equals("low")); + } + + private void initializeBatteryMonitoring() { + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_BATTERY_CHANGED); + filter.addAction(Intent.ACTION_POWER_CONNECTED); + filter.addAction(Intent.ACTION_POWER_DISCONNECTED); + filter.addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED); + context.registerReceiver(batteryReceiver, filter); + updateBatteryStatus(); + updatePowerState(); + } + + private final BroadcastReceiver batteryReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + updateBatteryStatus(); + } + }; + + private void updateBatteryStatus() { + Intent batteryStatus = context.registerReceiver(null, + new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + + if (batteryStatus != null) { + int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + float batteryPct = level * 100 / (float)scale; + + int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); + boolean charging = status == BatteryManager.BATTERY_HEALTH_CHARGING || + status == BatteryManager.BATTERY_HEALTH_FULL; + + batteryLevel = (int)batteryPct; + isCharging = charging; + lastBatteryCheck = System.currentTimeMillis(); + + logger.log(String.format("Battery status updated: %d%% (%s)", + batteryLevel, + isCharging ? "charging" : "not charging"), + DailyNotificationLogger.Level.DEBUG); + + // Store battery stats + SharedPreferences.Editor editor = settings.edit(); + editor.putInt("last_battery_level", batteryLevel); + editor.putBoolean("last_charging_state", isCharging); + editor.putLong("last_battery_check", lastBatteryCheck); + editor.apply(); + + // Adjust scheduling based on battery level if adaptive scheduling is enabled + if (adaptiveSchedulingEnabled) { + adjustSchedulingForBatteryLevel(); + } + } + } + + private void adjustSchedulingForBatteryLevel() { + if (batteryLevel < 15) { + // Critical battery level - reduce frequency + lastBatteryCheck = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(4); + } else if (batteryLevel < 30) { + // Low battery level - moderate frequency + lastBatteryCheck = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2); + } else if (batteryLevel < 50) { + // Medium battery level - standard frequency + lastBatteryCheck = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1); + } + } + + private void updatePowerState() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + powerState = powerManager.getPowerState(); + SharedPreferences.Editor editor = settings.edit(); + editor.putInt(POWER_STATE_KEY, powerState); + editor.apply(); + + // Adjust scheduling based on power state + if (adaptiveSchedulingEnabled) { + adjustSchedulingForPowerState(); + } + } + } + + private void adjustSchedulingForPowerState() { + if (powerState == PowerManager.POWER_STATE_SAVE) { + // In power save mode, increase the interval between checks + lastBatteryCheck = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2); + } else if (powerState == PowerManager.POWER_STATE_NORMAL) { + // In normal mode, use standard intervals + lastBatteryCheck = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(30); + } + } + + @PluginMethod + public void getBatteryStatus(PluginCall call) { + try { + // Check battery optimization status if needed + if (System.currentTimeMillis() - lastOptimizationCheck > BATTERY_CHECK_INTERVAL) { + checkBatteryOptimization(); + } + + JSObject result = new JSObject(); + result.put("level", batteryLevel); + result.put("isCharging", isCharging); + result.put("lastCheck", lastBatteryCheck); + result.put("isOptimizationExempt", isOptimizationExempt); + result.put("lastOptimizationCheck", lastOptimizationCheck); + result.put("isInDozeMode", isDeviceInDozeMode()); + call.resolve(result); + } catch (Exception e) { + logger.log("Error getting battery status: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + call.reject("Failed to get battery status: " + e.getMessage()); + } + } + + @Override + protected void handleOnDestroy() { + try { + context.unregisterReceiver(batteryReceiver); + } catch (Exception e) { + logger.log("Error unregistering battery receiver: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + } + super.handleOnDestroy(); + } + + public void rescheduleMissedNotifications() { + try { + long lastNotificationTime = settings.getLong(LAST_NOTIFICATION_KEY, 0); + long currentTime = System.currentTimeMillis(); + + // If more than 24 hours have passed since the last notification + if (currentTime - lastNotificationTime > TimeUnit.DAYS.toMillis(1)) { + logger.log("Missed notifications detected, rescheduling", DailyNotificationLogger.Level.WARNING); + + // Get the scheduled time from settings + String scheduledTime = settings.getString("scheduled_time", null); + if (scheduledTime != null) { + // Parse the scheduled time + String[] timeComponents = scheduledTime.split(":"); + if (timeComponents.length == 2) { + int hour = Integer.parseInt(timeComponents[0]); + int minute = Integer.parseInt(timeComponents[1]); + + // Create calendar instance for the scheduled time + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.HOUR_OF_DAY, hour); + calendar.set(Calendar.MINUTE, minute); + calendar.set(Calendar.SECOND, 0); + + // If the time has already passed today, schedule for tomorrow + if (calendar.getTimeInMillis() <= currentTime) { + calendar.add(Calendar.DAY_OF_YEAR, 1); + } + + // Get the notification URL from settings + String url = settings.getString("notification_url", null); + if (url != null) { + // Create intent for the notification + Intent intent = new Intent(context, DailyNotificationReceiver.class); + intent.putExtra("url", url); + intent.putExtra("title", settings.getString("notification_title", "Daily Notification")); + intent.putExtra("body", settings.getString("notification_body", "Your daily update is ready")); + intent.putExtra("sound", settings.getBoolean("sound", true)); + intent.putExtra("priority", settings.getString("priority", "default")); + + PendingIntent pendingIntent = PendingIntent.getBroadcast( + context, + url.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + // Schedule the notification + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (alarmManager.canScheduleExactAlarms()) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.getTimeInMillis(), + pendingIntent + ); + } + } else { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.getTimeInMillis(), + pendingIntent + ); + } + + logger.log("Missed notification rescheduled for " + scheduledTime, + DailyNotificationLogger.Level.INFO); + } + } + } + } + } catch (Exception e) { + logger.log("Error rescheduling missed notifications: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + } + } + + @PluginMethod + public void setAdaptiveScheduling(PluginCall call) { + try { + boolean enabled = call.getBoolean("enabled", true); + adaptiveSchedulingEnabled = enabled; + + SharedPreferences.Editor editor = settings.edit(); + editor.putBoolean(ADAPTIVE_SCHEDULING_KEY, enabled); + editor.apply(); + + logger.log("Adaptive scheduling " + (enabled ? "enabled" : "disabled"), + DailyNotificationLogger.Level.INFO); + call.resolve(); + } catch (Exception e) { + logger.log("Error setting adaptive scheduling: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + call.reject("Failed to set adaptive scheduling: " + e.getMessage()); + } + } + + @PluginMethod + public void getPowerState(PluginCall call) { + try { + JSObject result = new JSObject(); + result.put("powerState", powerState); + result.put("adaptiveScheduling", adaptiveSchedulingEnabled); + result.put("batteryLevel", batteryLevel); + result.put("isCharging", isCharging); + result.put("lastCheck", lastBatteryCheck); + call.resolve(result); + } catch (Exception e) { + logger.log("Error getting power state: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + call.reject("Failed to get power state: " + e.getMessage()); } - // Add other settings... - editor.apply(); - call.resolve(); } } \ No newline at end of file diff --git a/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java b/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java index d7de652..0a7d05b 100644 --- a/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java +++ b/android/app/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java @@ -1,51 +1,186 @@ /** * DailyNotificationReceiver.java - * Broadcast receiver for handling daily notifications on Android + * Daily Notification Plugin for Capacitor + * + * Broadcast receiver for handling daily notifications + * + * Features: + * - Notification display with custom actions + * - Rich notification content + * - Event broadcasting + * - Error recovery + * - Notification categories + * + * @author Matthew Raymer */ package com.timesafari.dailynotification; +import android.app.Notification; +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.net.Uri; import android.os.Build; +import android.os.Bundle; import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; public class DailyNotificationReceiver extends BroadcastReceiver { - private static final String CHANNEL_ID = "daily_notification_channel"; + private static final String TAG = "DailyNotificationReceiver"; + private DailyNotificationLogger logger; + + // Notification categories + private static final String CATEGORY_DAILY = "DAILY_NOTIFICATION"; + // Action identifiers + private static final String ACTION_VIEW = "VIEW"; + private static final String ACTION_DISMISS = "DISMISS"; + private static final String ACTION_SNOOZE = "SNOOZE"; + @Override public void onReceive(Context context, Intent intent) { - String url = intent.getStringExtra("url"); - String title = intent.getStringExtra("title"); - String body = intent.getStringExtra("body"); + logger = new DailyNotificationLogger(); - if (url == null || title == null || body == null) { - return; + try { + String url = intent.getStringExtra("url"); + String title = intent.getStringExtra("title"); + String body = intent.getStringExtra("body"); + boolean sound = intent.getBooleanExtra("sound", true); + String priority = intent.getStringExtra("priority"); + + if (url == null || title == null || body == null) { + throw new IllegalArgumentException("Missing required notification parameters"); + } + + showNotification(context, title, body, url, sound, priority); + logger.log("Notification displayed successfully", DailyNotificationLogger.Level.INFO); + + } catch (Exception e) { + logger.log("Error displaying notification: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR, e); } - - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_dialog_info) - .setContentTitle(title) - .setContentText(body) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setAutoCancel(true); - + } + + private void showNotification(Context context, String title, String body, + String url, boolean sound, String priority) { NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + // Create notification channel if needed if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - notificationManager.createNotificationChannel( - new android.app.NotificationChannel( - CHANNEL_ID, - "Daily Notifications", - NotificationManager.IMPORTANCE_DEFAULT - ) + NotificationChannel channel = new NotificationChannel( + DailyNotificationConstants.Channel.ID, + DailyNotificationConstants.Channel.NAME, + NotificationManager.IMPORTANCE_DEFAULT ); + channel.setDescription(DailyNotificationConstants.Channel.DESCRIPTION); + channel.enableVibration(DailyNotificationConstants.Channel.ENABLE_VIBRATION); + channel.enableLights(DailyNotificationConstants.Channel.ENABLE_LIGHTS); + notificationManager.createNotificationChannel(channel); + } + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, + DailyNotificationConstants.Channel.ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(body) + .setPriority(getNotificationPriority(priority)) + .setCategory(CATEGORY_DAILY) + .setAutoCancel(true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setStyle(new NotificationCompat.BigTextStyle().bigText(body)); + + if (sound) { + builder.setDefaults(NotificationCompat.DEFAULT_SOUND); + } + + // Create intent for notification tap + Intent contentIntent = new Intent(context, context.getClass()); + contentIntent.setData(Uri.parse(url)); + + PendingIntent pendingIntent = PendingIntent.getActivity( + context, + 0, + contentIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + builder.setContentIntent(pendingIntent); + + // Add custom actions + addCustomActions(builder, context, url); + + Notification notification = builder.build(); + + // Use NotificationManagerCompat for better compatibility + NotificationManagerCompat notificationManagerCompat = + NotificationManagerCompat.from(context); + + try { + notificationManagerCompat.notify(url.hashCode(), notification); + } catch (SecurityException e) { + logger.log("Security exception while showing notification: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); } + } + + private void addCustomActions(NotificationCompat.Builder builder, Context context, String url) { + // View action + Intent viewIntent = new Intent(context, context.getClass()); + viewIntent.setAction(ACTION_VIEW); + viewIntent.setData(Uri.parse(url)); + + PendingIntent viewPendingIntent = PendingIntent.getActivity( + context, + 1, + viewIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); - notificationManager.notify(url.hashCode(), builder.build()); + builder.addAction(android.R.drawable.ic_menu_view, "View", viewPendingIntent); + + // Snooze action + Intent snoozeIntent = new Intent(context, context.getClass()); + snoozeIntent.setAction(ACTION_SNOOZE); + snoozeIntent.putExtra("url", url); + + PendingIntent snoozePendingIntent = PendingIntent.getBroadcast( + context, + 2, + snoozeIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + builder.addAction(android.R.drawable.ic_menu_revert, "Snooze", snoozePendingIntent); + + // Dismiss action + Intent dismissIntent = new Intent(context, context.getClass()); + dismissIntent.setAction(ACTION_DISMISS); + dismissIntent.putExtra("url", url); + + PendingIntent dismissPendingIntent = PendingIntent.getBroadcast( + context, + 3, + dismissIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + builder.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Dismiss", dismissPendingIntent); + } + + private int getNotificationPriority(String priority) { + switch (priority.toLowerCase()) { + case "high": + return NotificationCompat.PRIORITY_HIGH; + case "low": + return NotificationCompat.PRIORITY_LOW; + default: + return NotificationCompat.PRIORITY_DEFAULT; + } } } \ No newline at end of file diff --git a/android/app/src/main/java/com/timesafari/dailynotification/MaintenanceWorker.java b/android/app/src/main/java/com/timesafari/dailynotification/MaintenanceWorker.java new file mode 100644 index 0000000..e6b0c07 --- /dev/null +++ b/android/app/src/main/java/com/timesafari/dailynotification/MaintenanceWorker.java @@ -0,0 +1,51 @@ +package com.timesafari.dailynotification; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +public class MaintenanceWorker extends Worker { + private final DailyNotificationLogger logger; + + public MaintenanceWorker(@NonNull Context context, @NonNull WorkerParameters params) { + super(context, params); + logger = new DailyNotificationLogger(); + } + + @NonNull + @Override + public Result doWork() { + try { + logger.log("Maintenance work started", DailyNotificationLogger.Level.INFO); + + // Perform maintenance tasks + performMaintenance(); + + logger.log("Maintenance work completed successfully", DailyNotificationLogger.Level.INFO); + return Result.success(); + } catch (Exception e) { + logger.log("Error during maintenance work: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + return Result.failure(); + } + } + + private void performMaintenance() { + // Check and update battery optimization status + DailyNotificationPlugin plugin = DailyNotificationPlugin.getInstance(); + if (plugin != null) { + plugin.checkBatteryOptimization(); + } + + // Update battery status + if (plugin != null) { + plugin.updateBatteryStatus(); + } + + // Reschedule any missed notifications + if (plugin != null) { + plugin.rescheduleMissedNotifications(); + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/timesafari/dailynotification/README.md b/android/app/src/main/java/com/timesafari/dailynotification/README.md new file mode 100644 index 0000000..e58d875 --- /dev/null +++ b/android/app/src/main/java/com/timesafari/dailynotification/README.md @@ -0,0 +1,423 @@ +# DailyNotification Android Implementation + +This directory contains the Android-specific implementation of the DailyNotification plugin for Capacitor. + +## Overview + +The DailyNotification plugin provides functionality for scheduling and managing daily notifications on Android devices. It uses Android's native notification system and background task scheduling to ensure reliable delivery of notifications. + +## Features + +- Daily notification scheduling with precise timing +- Notification channel management (Android 8.0+) +- Background task handling with WorkManager +- Battery optimization support +- Settings persistence +- Rich logging capabilities +- Comprehensive error handling +- Extensive test coverage + +## Architecture + +### Core Components + +1. **DailyNotificationPlugin** + - Main plugin class handling all notification operations + - Manages notification scheduling and settings + - Handles plugin method calls from JavaScript + +2. **DailyNotificationReceiver** + - Broadcast receiver for handling notification display + - Manages notification content and presentation + +3. **DailyNotificationLogger** + - Structured logging system + - Multiple log levels (DEBUG, INFO, WARNING, ERROR) + - Configurable logging behavior + +4. **DailyNotificationConstants** + - Centralized constants management + - Default values and configuration + - Error messages and keys + +5. **DailyNotificationConfig** + - Configuration management + - Settings persistence + - Default values + +### Key Features + +#### Notification Scheduling +- Uses AlarmManager for precise timing +- Handles timezone changes +- Supports multiple notifications per day +- Battery-optimized scheduling + +#### Background Processing +- WorkManager for reliable background tasks +- Battery optimization handling +- Network state monitoring +- Retry mechanism + +#### Settings Management +- Persistent storage using SharedPreferences +- Default values management +- Settings validation +- Real-time updates + +#### Error Handling +- Comprehensive error catching +- Detailed error messages +- Logging integration +- Recovery mechanisms + +## Testing + +### Test Structure + +1. **Unit Tests** + - Core functionality testing + - Settings management + - Time validation + - Error handling + +2. **Integration Tests** + - Notification scheduling + - Background tasks + - Settings persistence + - Event handling + +3. **Edge Cases** + - Timezone changes + - Battery optimization + - Network issues + - Resource cleanup + +### Running Tests + +```bash +# Run all tests +./gradlew test + +# Run specific test class +./gradlew test --tests "com.timesafari.dailynotification.DailyNotificationPluginTest" + +# Run with coverage +./gradlew test jacocoTestReport +``` + +## Security Considerations + +1. **Data Storage** + - Encrypted SharedPreferences for sensitive data + - Secure notification content handling + - Safe URL handling + +2. **Permissions** + - Runtime permission handling + - Permission validation + - Graceful degradation + +3. **Background Tasks** + - Battery optimization compliance + - Resource usage monitoring + - Task scheduling limits + +## Performance Optimization + +1. **Battery Usage** + - Efficient scheduling + - Background task optimization + - Wake lock management + +2. **Memory Management** + - Resource cleanup + - Memory leak prevention + - Cache management + +3. **Network Usage** + - Efficient data fetching + - Caching strategies + - Retry optimization + +## Best Practices + +1. **Code Organization** + - Clear package structure + - Consistent naming conventions + - Comprehensive documentation + +2. **Error Handling** + - Graceful degradation + - User-friendly messages + - Logging integration + +3. **Testing** + - Comprehensive test coverage + - Edge case handling + - Performance testing + +## Dependencies + +- AndroidX Core +- AndroidX AppCompat +- AndroidX WorkManager +- AndroidX Room +- AndroidX Lifecycle +- AndroidX Security +- JUnit +- Mockito + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Commit your changes +4. Push to the branch +5. Create a Pull Request + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Author + +Matthew Raymer + +# Daily Notification Plugin - Battery Optimization Guide + +## Battery Overview + +The Daily Notification Plugin implements sophisticated battery optimization features to ensure reliable notification delivery while minimizing battery impact. This guide covers the implementation details and best practices for battery optimization. + +## Battery Optimization Features + +### 1. Adaptive Scheduling + +- Adjusts notification timing based on battery level and power state +- Reduces frequency during low battery conditions +- Optimizes wake lock duration based on battery status +- Implements smart rescheduling for missed notifications + +### 2. Power State Management + +- Monitors device power state (normal, power save, etc.) +- Adapts notification behavior based on power mode +- Implements Doze mode handling +- Manages wake locks efficiently + +### 3. Battery Level Monitoring + +- Real-time battery level tracking +- Charging state detection +- Battery health monitoring +- Adaptive thresholds based on battery status + +## API Methods + +### Battery Optimization + +```typescript +interface BatteryOptimizationStatus { + isExempt: boolean; + requestTime: number; + checkTime: number; +} + +// Request battery optimization exemption +requestBatteryOptimizationExemption(): Promise<BatteryOptimizationStatus>; + +// Get current battery status +getBatteryStatus(): Promise<{ + level: number; + isCharging: boolean; + lastCheck: number; + isOptimizationExempt: boolean; + lastOptimizationCheck: number; + isInDozeMode: boolean; +}>; + +// Get power state information +getPowerState(): Promise<{ + powerState: number; + adaptiveScheduling: boolean; + batteryLevel: number; + isCharging: boolean; + lastCheck: number; +}>; + +// Configure adaptive scheduling +setAdaptiveScheduling(enabled: boolean): Promise<void>; +``` + +## Implementation Details + +### Battery Optimization Exemption + +```java +@PluginMethod +public void requestBatteryOptimizationExemption(PluginCall call) { + // Implementation details... +} +``` + +### Power State Monitoring + +```java +private void updatePowerState() { + // Implementation details... +} +``` + +### Adaptive Scheduling + +```java +private void adjustSchedulingForBatteryLevel() { + // Implementation details... +} +``` + +## Best Practices + +### 1. Battery Optimization Exemption +- Request exemption only when necessary +- Check exemption status before scheduling notifications +- Implement fallback mechanisms when exemption is not granted + +### 2. Wake Lock Usage +- Use minimal wake lock duration +- Release wake locks promptly +- Implement battery-aware wake lock duration + +### 3. Doze Mode Handling + +- Schedule maintenance windows during Doze mode +- Use WorkManager for reliable background tasks +- Implement backup alarms for critical notifications + +### 4. Battery Level Monitoring + +- Monitor battery level changes +- Adjust notification frequency based on battery status +- Implement battery-saving thresholds + +### 5. Power State Adaptation + +- Adapt to power save mode +- Optimize resource usage during low power +- Implement graceful degradation + +## Battery Error Handling + +### Battery Optimization during Error Handling + +```java +try { + // Battery optimization code +} catch (Exception e) { + logger.log("Error in battery optimization: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + // Handle error appropriately +} +``` + +### Wake Lock + +```java +try { + // Wake lock code +} catch (Exception e) { + logger.log("Error managing wake lock: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + // Handle error appropriately +} +``` + +### Doze Mode + +```java +try { + // Doze mode handling code +} catch (Exception e) { + logger.log("Error handling Doze mode: " + e.getMessage(), + DailyNotificationLogger.Level.ERROR); + // Handle error appropriately +} +``` + +## Performance Considerations + +### Battery Impact + +- Minimize wake lock usage +- Optimize background task scheduling +- Implement efficient battery monitoring + +### Resource Usage + +- Use WorkManager for background tasks +- Implement efficient wake lock management +- Optimize notification scheduling + +### Storage + +- Efficient battery stats storage +- Optimized settings persistence +- Minimal logging overhead + +## Security + +### Wake Lock Security + +- Use appropriate wake lock flags +- Implement timeout mechanisms +- Handle wake lock failures gracefully + +### Data Security + +- Secure storage of battery stats +- Protected settings access +- Safe logging practices + +## Testing + +### Battery Optimization Tests + +```java +@Test +void testBatteryOptimizationExemptionRequest() { + // Test implementation +} +``` + +### Power State Tests + +```java +@Test +void testPowerStateMonitoring() { + // Test implementation +} +``` + +### Performance Tests +```java +@Test +void testBatteryImpact() { + // Test implementation +} +``` + +## Contributing + +### Documentation +- Keep documentation up to date +- Document all battery optimization features +- Include usage examples + +### Code +- Follow battery optimization best practices +- Implement comprehensive tests +- Maintain backward compatibility + +## License +MIT License - See LICENSE file for details \ No newline at end of file diff --git a/android/app/src/test/java/com/timesafari/dailynotification/BatteryOptimizationSettingsTest.java b/android/app/src/test/java/com/timesafari/dailynotification/BatteryOptimizationSettingsTest.java new file mode 100644 index 0000000..dce621b --- /dev/null +++ b/android/app/src/test/java/com/timesafari/dailynotification/BatteryOptimizationSettingsTest.java @@ -0,0 +1,150 @@ +/** + * BatteryOptimizationSettingsTest.java + * Daily Notification Plugin for Capacitor + * + * Tests for battery optimization settings and power state management + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.BatteryManager; +import android.os.Build; +import android.os.PowerManager; +import android.provider.Settings; + +import androidx.work.WorkManager; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {Build.VERSION_CODES.M}) +public class BatteryOptimizationSettingsTest { + + @Mock + private Context context; + + @Mock + private SharedPreferences settings; + + @Mock + private SharedPreferences.Editor editor; + + @Mock + private PowerManager powerManager; + + @Mock + private WorkManager workManager; + + private BatteryOptimizationSettings batterySettings; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + + when(context.getSystemService(Context.POWER_SERVICE)).thenReturn(powerManager); + when(settings.edit()).thenReturn(editor); + when(editor.putBoolean(anyString(), anyBoolean())).thenReturn(editor); + when(editor.putInt(anyString(), anyInt())).thenReturn(editor); + when(editor.putLong(anyString(), anyLong())).thenReturn(editor); + when(editor.apply()).thenReturn(true); + + batterySettings = new BatteryOptimizationSettings(context, settings); + } + + @Test + public void testRequestBatteryOptimizationExemption() { + // Test when already exempt + when(settings.getBoolean(anyString(), anyBoolean())).thenReturn(true); + assertTrue(batterySettings.requestBatteryOptimizationExemption()); + + // Test when not exempt + when(settings.getBoolean(anyString(), anyBoolean())).thenReturn(false); + when(context.getPackageName()).thenReturn("com.test.app"); + + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(android.net.Uri.parse("package:com.test.app")); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + batterySettings.requestBatteryOptimizationExemption(); + + verify(context).startActivity(eq(intent)); + verify(editor).putLong(eq("last_optimization_request"), anyLong()); + } + + @Test + public void testUpdatePowerState() { + when(powerManager.getPowerState()).thenReturn(PowerManager.POWER_STATE_SAVE); + + batterySettings.updatePowerState(); + + verify(editor).putInt(eq("power_state"), eq(PowerManager.POWER_STATE_SAVE)); + } + + @Test + public void testUpdateBatteryStatus() { + Intent batteryStatus = new Intent(Intent.ACTION_BATTERY_CHANGED); + batteryStatus.putExtra(BatteryManager.EXTRA_LEVEL, 80); + batteryStatus.putExtra(BatteryManager.EXTRA_SCALE, 100); + batteryStatus.putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_HEALTH_CHARGING); + + when(context.registerReceiver(any(), any())).thenReturn(batteryStatus); + + batterySettings.updateBatteryStatus(); + + assertEquals(80, batterySettings.getBatteryLevel()); + assertTrue(batterySettings.isCharging()); + verify(editor).putInt(eq("last_battery_level"), eq(80)); + verify(editor).putBoolean(eq("last_charging_state"), eq(true)); + } + + @Test + public void testAdjustSchedulingForPowerState() { + // Test power save mode + when(powerManager.getPowerState()).thenReturn(PowerManager.POWER_STATE_SAVE); + batterySettings.updatePowerState(); + assertTrue(batterySettings.shouldCheckBattery()); + + // Test normal mode + when(powerManager.getPowerState()).thenReturn(PowerManager.POWER_STATE_NORMAL); + batterySettings.updatePowerState(); + assertTrue(batterySettings.shouldCheckBattery()); + + // Test optimized mode + when(powerManager.getPowerState()).thenReturn(PowerManager.POWER_STATE_OPTIMIZED); + batterySettings.updatePowerState(); + assertTrue(batterySettings.shouldCheckBattery()); + } + + @Test + public void testGetPowerStateString() { + assertEquals("POWER_SAVE", batterySettings.getPowerStateString(PowerManager.POWER_STATE_SAVE)); + assertEquals("NORMAL", batterySettings.getPowerStateString(PowerManager.POWER_STATE_NORMAL)); + assertEquals("OPTIMIZED", batterySettings.getPowerStateString(PowerManager.POWER_STATE_OPTIMIZED)); + assertEquals("UNKNOWN", batterySettings.getPowerStateString(999)); + } + + @Test + public void testShouldCheckBattery() { + // Test when check is needed + assertTrue(batterySettings.shouldCheckBattery()); + + // Test when check is not needed + when(settings.getLong(anyString(), anyLong())).thenReturn(System.currentTimeMillis()); + assertFalse(batterySettings.shouldCheckBattery()); + } +} \ No newline at end of file diff --git a/android/app/src/test/java/com/timesafari/dailynotification/DailyNotificationLoggerTest.java b/android/app/src/test/java/com/timesafari/dailynotification/DailyNotificationLoggerTest.java new file mode 100644 index 0000000..a94c4a5 --- /dev/null +++ b/android/app/src/test/java/com/timesafari/dailynotification/DailyNotificationLoggerTest.java @@ -0,0 +1,134 @@ +/** + * DailyNotificationLoggerTest.java + * Daily Notification Plugin for Capacitor + * + * Tests for logging functionality + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import static org.junit.Assert.*; + +@RunWith(RobolectricTestRunner.class) +public class DailyNotificationLoggerTest { + + private DailyNotificationLogger logger; + + @Before + public void setUp() { + logger = new DailyNotificationLogger(); + } + + @Test + public void testLogWithInfoLevel() { + // Test info level logging + logger.log("Test info message", DailyNotificationLogger.Level.INFO); + + // Verify log level + assertEquals(DailyNotificationLogger.Level.INFO, logger.getLastLogLevel()); + + // Verify message + assertTrue(logger.getLastLogMessage().contains("Test info message")); + } + + @Test + public void testLogWithWarningLevel() { + // Test warning level logging + logger.log("Test warning message", DailyNotificationLogger.Level.WARNING); + + // Verify log level + assertEquals(DailyNotificationLogger.Level.WARNING, logger.getLastLogLevel()); + + // Verify message + assertTrue(logger.getLastLogMessage().contains("Test warning message")); + } + + @Test + public void testLogWithErrorLevel() { + // Test error level logging + logger.log("Test error message", DailyNotificationLogger.Level.ERROR); + + // Verify log level + assertEquals(DailyNotificationLogger.Level.ERROR, logger.getLastLogLevel()); + + // Verify message + assertTrue(logger.getLastLogMessage().contains("Test error message")); + } + + @Test + public void testLogWithDebugLevel() { + // Test debug level logging + logger.log("Test debug message", DailyNotificationLogger.Level.DEBUG); + + // Verify log level + assertEquals(DailyNotificationLogger.Level.DEBUG, logger.getLastLogLevel()); + + // Verify message + assertTrue(logger.getLastLogMessage().contains("Test debug message")); + } + + @Test + public void testLogWithException() { + // Create test exception + Exception testException = new RuntimeException("Test exception"); + + // Test logging with exception + logger.log("Test error with exception", DailyNotificationLogger.Level.ERROR, testException); + + // Verify log level + assertEquals(DailyNotificationLogger.Level.ERROR, logger.getLastLogLevel()); + + // Verify message contains exception details + assertTrue(logger.getLastLogMessage().contains("Test error with exception")); + assertTrue(logger.getLastLogMessage().contains("Test exception")); + } + + @Test + public void testLogWithNullMessage() { + // Test logging with null message + logger.log(null, DailyNotificationLogger.Level.INFO); + + // Verify log level + assertEquals(DailyNotificationLogger.Level.INFO, logger.getLastLogLevel()); + + // Verify message is empty + assertTrue(logger.getLastLogMessage().isEmpty()); + } + + @Test + public void testLogWithNullLevel() { + // Test logging with null level + logger.log("Test message", null); + + // Verify default level is INFO + assertEquals(DailyNotificationLogger.Level.INFO, logger.getLastLogLevel()); + + // Verify message + assertTrue(logger.getLastLogMessage().contains("Test message")); + } + + @Test + public void testLogWithTimestamp() { + // Test logging with timestamp + logger.log("Test message with timestamp", DailyNotificationLogger.Level.INFO); + + // Verify message contains timestamp + assertTrue(logger.getLastLogMessage().matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.*")); + } + + @Test + public void testLogWithTag() { + // Test logging with tag + logger.log("Test message with tag", DailyNotificationLogger.Level.INFO, "TestTag"); + + // Verify message contains tag + assertTrue(logger.getLastLogMessage().contains("[TestTag]")); + } +} \ No newline at end of file diff --git a/android/app/src/test/java/com/timesafari/dailynotification/DailyNotificationPluginTest.java b/android/app/src/test/java/com/timesafari/dailynotification/DailyNotificationPluginTest.java new file mode 100644 index 0000000..2f78b85 --- /dev/null +++ b/android/app/src/test/java/com/timesafari/dailynotification/DailyNotificationPluginTest.java @@ -0,0 +1,194 @@ +/** + * DailyNotificationPluginTest.java + * Daily Notification Plugin for Capacitor + * + * Tests for the main plugin functionality + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +import android.app.AlarmManager; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.PowerManager; + +import com.getcapacitor.JSObject; +import com.getcapacitor.PluginCall; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {Build.VERSION_CODES.M}) +public class DailyNotificationPluginTest { + + @Mock + private Context context; + + @Mock + private SharedPreferences settings; + + @Mock + private SharedPreferences.Editor editor; + + @Mock + private NotificationManager notificationManager; + + @Mock + private AlarmManager alarmManager; + + @Mock + private PowerManager powerManager; + + @Mock + private PluginCall pluginCall; + + private DailyNotificationPlugin plugin; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + + when(context.getSystemService(Context.NOTIFICATION_SERVICE)).thenReturn(notificationManager); + when(context.getSystemService(Context.ALARM_SERVICE)).thenReturn(alarmManager); + when(context.getSystemService(Context.POWER_SERVICE)).thenReturn(powerManager); + when(settings.edit()).thenReturn(editor); + when(editor.putBoolean(anyString(), anyBoolean())).thenReturn(editor); + when(editor.putString(anyString(), anyString())).thenReturn(editor); + when(editor.putLong(anyString(), anyLong())).thenReturn(editor); + when(editor.apply()).thenReturn(true); + + plugin = new DailyNotificationPlugin(); + plugin.load(); + } + + @Test + public void testScheduleDailyNotification() { + // Setup test data + when(pluginCall.getString("url")).thenReturn("https://example.com"); + when(pluginCall.getString("time")).thenReturn("09:00"); + when(pluginCall.getString("title")).thenReturn("Test Notification"); + when(pluginCall.getString("body")).thenReturn("Test Body"); + when(pluginCall.getBoolean("sound", true)).thenReturn(true); + when(pluginCall.getString("priority", "default")).thenReturn("default"); + + // Execute + plugin.scheduleDailyNotification(pluginCall); + + // Verify + verify(alarmManager).setExactAndAllowWhileIdle( + eq(AlarmManager.RTC_WAKEUP), + anyLong(), + any() + ); + verify(editor).putString(eq("scheduled_time"), eq("09:00")); + verify(editor).putString(eq("notification_url"), eq("https://example.com")); + } + + @Test + public void testCancelAllNotifications() { + // Execute + plugin.cancelAllNotifications(pluginCall); + + // Verify + verify(alarmManager).cancel(any()); + verify(notificationManager).cancelAll(); + } + + @Test + public void testGetNotificationStatus() { + // Setup + when(notificationManager.areNotificationsEnabled()).thenReturn(true); + + // Execute + plugin.getNotificationStatus(pluginCall); + + // Verify + verify(pluginCall).resolve(any(JSObject.class)); + } + + @Test + public void testUpdateSettings() { + // Setup + when(pluginCall.hasOption("sound")).thenReturn(true); + when(pluginCall.getBoolean("sound")).thenReturn(false); + when(pluginCall.hasOption("priority")).thenReturn(true); + when(pluginCall.getString("priority")).thenReturn("high"); + + // Execute + plugin.updateSettings(pluginCall); + + // Verify + verify(editor).putBoolean("sound", false); + verify(editor).putString("priority", "high"); + } + + @Test + public void testRequestBatteryOptimizationExemption() { + // Execute + plugin.requestBatteryOptimizationExemption(pluginCall); + + // Verify + verify(context).startActivity(any(Intent.class)); + } + + @Test + public void testGetBatteryStatus() { + // Execute + plugin.getBatteryStatus(pluginCall); + + // Verify + verify(pluginCall).resolve(any(JSObject.class)); + } + + @Test + public void testSetAdaptiveScheduling() { + // Setup + when(pluginCall.getBoolean("enabled", true)).thenReturn(true); + + // Execute + plugin.setAdaptiveScheduling(pluginCall); + + // Verify + verify(editor).putBoolean(eq("adaptive_scheduling"), eq(true)); + } + + @Test + public void testGetPowerState() { + // Execute + plugin.getPowerState(pluginCall); + + // Verify + verify(pluginCall).resolve(any(JSObject.class)); + } + + @Test + public void testHandleDozeMode() { + // Setup + when(powerManager.isDeviceIdleMode()).thenReturn(true); + + // Execute + plugin.handleDozeMode(); + + // Verify + verify(alarmManager).setAndAllowWhileIdle( + eq(AlarmManager.RTC_WAKEUP), + anyLong(), + any() + ); + } +} \ No newline at end of file diff --git a/android/app/src/test/java/com/timesafari/dailynotification/DailyNotificationReceiverTest.java b/android/app/src/test/java/com/timesafari/dailynotification/DailyNotificationReceiverTest.java new file mode 100644 index 0000000..88a272e --- /dev/null +++ b/android/app/src/test/java/com/timesafari/dailynotification/DailyNotificationReceiverTest.java @@ -0,0 +1,162 @@ +/** + * DailyNotificationReceiverTest.java + * Daily Notification Plugin for Capacitor + * + * Tests for notification receiver functionality + * + * @author Matthew Raymer + */ + +package com.timesafari.dailynotification; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {Build.VERSION_CODES.M}) +public class DailyNotificationReceiverTest { + + @Mock + private Context context; + + @Mock + private NotificationManager notificationManager; + + @Mock + private NotificationManagerCompat notificationManagerCompat; + + private DailyNotificationReceiver receiver; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + + when(context.getSystemService(Context.NOTIFICATION_SERVICE)) + .thenReturn(notificationManager); + when(NotificationManagerCompat.from(context)) + .thenReturn(notificationManagerCompat); + + receiver = new DailyNotificationReceiver(); + } + + @Test + public void testOnReceive() { + // Setup test data + Intent intent = new Intent(); + intent.putExtra("url", "https://example.com"); + intent.putExtra("title", "Test Notification"); + intent.putExtra("body", "Test Body"); + intent.putExtra("sound", true); + intent.putExtra("priority", "high"); + + // Execute + receiver.onReceive(context, intent); + + // Verify + verify(notificationManagerCompat).notify( + eq("https://example.com".hashCode()), + any(Notification.class) + ); + } + + @Test + public void testOnReceiveWithMissingParameters() { + // Setup test data + Intent intent = new Intent(); + + // Execute + receiver.onReceive(context, intent); + + // Verify + verify(notificationManagerCompat, never()).notify(anyInt(), any(Notification.class)); + } + + @Test + public void testShowNotification() { + // Setup test data + String title = "Test Notification"; + String body = "Test Body"; + String url = "https://example.com"; + boolean sound = true; + String priority = "high"; + + // Execute + receiver.showNotification(context, title, body, url, sound, priority); + + // Verify + verify(notificationManagerCompat).notify( + eq(url.hashCode()), + any(Notification.class) + ); + } + + @Test + public void testAddCustomActions() { + // Setup test data + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, "test_channel"); + String url = "https://example.com"; + + // Execute + receiver.addCustomActions(builder, context, url); + + // Verify + verify(context, times(3)).getClass(); + } + + @Test + public void testGetNotificationPriority() { + // Test high priority + assertEquals( + NotificationCompat.PRIORITY_HIGH, + receiver.getNotificationPriority("high") + ); + + // Test low priority + assertEquals( + NotificationCompat.PRIORITY_LOW, + receiver.getNotificationPriority("low") + ); + + // Test default priority + assertEquals( + NotificationCompat.PRIORITY_DEFAULT, + receiver.getNotificationPriority("default") + ); + + // Test unknown priority + assertEquals( + NotificationCompat.PRIORITY_DEFAULT, + receiver.getNotificationPriority("unknown") + ); + } + + @Test + public void testCreateNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Execute + receiver.createNotificationChannel(context); + + // Verify + verify(notificationManager).createNotificationChannel(any(NotificationChannel.class)); + } + } +} \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index b831f78..1d3e084 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -35,15 +35,20 @@ android { buildTypes { release { minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } lintOptions { abortOnError false } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + testOptions { + unitTests { + includeAndroidResources = true + } } } @@ -67,11 +72,38 @@ repositories { } dependencies { - implementation fileTree(include: ['*.jar'], dir: 'libs') + // AndroidX Core + implementation 'androidx.core:core:1.7.0' + implementation 'androidx.core:core-ktx:1.7.0' + + // WorkManager for background tasks + implementation 'androidx.work:work-runtime:2.7.1' + + // Capacitor dependencies implementation project(':capacitor-android') - implementation project(':capacitor-cordova-android-plugins') - implementation 'androidx.work:work-runtime:2.8.1' + implementation project(':capacitor-core') + + // Testing dependencies testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:4.5.1' + testImplementation 'org.robolectric:robolectric:4.8' + testImplementation 'androidx.test:core:1.4.0' + testImplementation 'androidx.test:runner:1.4.0' + testImplementation 'androidx.test.ext:junit:1.1.3' + + // AndroidX Test androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + + // AndroidX AppCompat + implementation 'androidx.appcompat:appcompat:1.6.1' + + // AndroidX Core App + implementation 'androidx.core:core-app:1.0.0' + + // AndroidX Core AppCompat + implementation 'androidx.core:core-appcompat:1.0.0' + + // AndroidX Core AppCompat Resources + implementation 'androidx.core:core-appcompat-resources:1.0.0' } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..3823b52 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This is a general purpose Gradle build. + * Learn more about Gradle by exploring our Samples at https://docs.gradle.org/8.13/samples + */ diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5154008 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,7 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties + +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..9a968dc --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,10 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format + +[versions] +commons-math3 = "3.6.1" +guava = "33.3.1-jre" + +[libraries] +commons-math3 = { module = "org.apache.commons:commons-math3", version.ref = "commons-math3" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# 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/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# 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 -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@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 ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts new file mode 100644 index 0000000..d323030 --- /dev/null +++ b/lib/build.gradle.kts @@ -0,0 +1,42 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This generated file contains a sample Java library project to get you started. + * For more details on building Java & JVM projects, please refer to https://docs.gradle.org/8.13/userguide/building_java_projects.html in the Gradle documentation. + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + // Apply the java-library plugin for API and implementation separation. + `java-library` +} + +repositories { + // Use Maven Central for resolving dependencies. + mavenCentral() +} + +dependencies { + // This dependency is exported to consumers, that is to say found on their compile classpath. + api(libs.commons.math3) + + // This dependency is used internally, and not exposed to consumers on their own compile classpath. + implementation(libs.guava) +} + +testing { + suites { + // Configure the built-in test suite + val test by getting(JvmTestSuite::class) { + // Use JUnit Jupiter test framework + useJUnitJupiter("5.11.3") + } + } +} + +// Apply a specific Java toolchain to ease working on different environments. +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} diff --git a/lib/src/main/java/org/example/Library.java b/lib/src/main/java/org/example/Library.java new file mode 100644 index 0000000..b98461b --- /dev/null +++ b/lib/src/main/java/org/example/Library.java @@ -0,0 +1,10 @@ +/* + * This source file was generated by the Gradle 'init' task + */ +package org.example; + +public class Library { + public boolean someLibraryMethod() { + return true; + } +} diff --git a/lib/src/test/java/org/example/LibraryTest.java b/lib/src/test/java/org/example/LibraryTest.java new file mode 100644 index 0000000..ef34950 --- /dev/null +++ b/lib/src/test/java/org/example/LibraryTest.java @@ -0,0 +1,14 @@ +/* + * This source file was generated by the Gradle 'init' task + */ +package org.example; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class LibraryTest { + @Test void someLibraryMethodReturnsTrue() { + Library classUnderTest = new Library(); + assertTrue(classUnderTest.someLibraryMethod(), "someLibraryMethod should return 'true'"); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..7c49a62 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,15 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * The settings file is used to specify which projects to include in your build. + * For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.13/userguide/multi_project_builds.html in the Gradle documentation. + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + // Apply the foojay-resolver plugin to allow automatic download of JDKs + id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" +} + +rootProject.name = "daily-notification-plugin" +include("lib")