From a54ba34cb9c9fe1e096efba434428ba60046fbec Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Fri, 28 Mar 2025 03:50:54 -0700 Subject: [PATCH] feat(ios): Enhance battery optimization and notification management Description: - Add battery status and power state monitoring - Implement adaptive scheduling based on battery levels - Add maintenance worker for background tasks - Enhance logging with structured DailyNotificationLogger - Add configuration management with DailyNotificationConfig - Define constants in DailyNotificationConstants - Improve error handling and recovery mechanisms Testing: - Add comprehensive test coverage for battery optimization - Add test coverage for power state management - Add test coverage for maintenance tasks - Add test coverage for configuration management - Add test coverage for constants validation Documentation: - Add comprehensive file-level documentation - Add method-level documentation - Add test documentation - Add configuration documentation This commit improves the iOS implementation's reliability and battery efficiency by adding robust error handling, logging, and configuration management to make the plugin more maintainable and debuggable. --- ios/DailyNotificationPlugin.podspec | 13 +- ios/Plugin/DailyNotificationConfig.swift | 54 +++++++ .../DailyNotificationMaintenanceWorker.swift | 122 ++++++++++++++ ios/Plugin/DailyNotificationPlugin.swift | 40 ++++- .../DailyNotificationPowerManager.swift | 153 ++++++++++++++++++ ios/Podfile.lock | 6 +- ios/Tests/DailyNotificationTests.swift | 147 +++++++++++++++-- lib/bin/main/org/example/Library.class | Bin 0 -> 354 bytes lib/bin/test/org/example/LibraryTest.class | Bin 0 -> 713 bytes 9 files changed, 511 insertions(+), 24 deletions(-) create mode 100644 ios/Plugin/DailyNotificationMaintenanceWorker.swift create mode 100644 ios/Plugin/DailyNotificationPowerManager.swift create mode 100644 lib/bin/main/org/example/Library.class create mode 100644 lib/bin/test/org/example/LibraryTest.class diff --git a/ios/DailyNotificationPlugin.podspec b/ios/DailyNotificationPlugin.podspec index 8da466a..c598811 100644 --- a/ios/DailyNotificationPlugin.podspec +++ b/ios/DailyNotificationPlugin.podspec @@ -1,16 +1,17 @@ Pod::Spec.new do |s| s.name = 'DailyNotificationPlugin' s.version = '1.0.0' - s.summary = 'Daily notification plugin for Capacitor' + s.summary = 'Daily Notification Plugin for Capacitor' s.license = 'MIT' s.homepage = 'https://github.com/timesafari/daily-notification-plugin' - s.author = 'TimeSafari' + s.author = 'Matthew Raymer' s.source = { :git => 'https://github.com/timesafari/daily-notification-plugin.git', :tag => s.version.to_s } s.source_files = 'Plugin/**/*.{swift,h,m,c,cc,mm,cpp}' - s.ios.deployment_target = '13.0' - s.dependency 'Capacitor' - s.dependency 'CapacitorCordova' + s.ios.deployment_target = '13.0' + s.dependency 'Capacitor', '~> 5.0.0' + s.dependency 'CapacitorCordova', '~> 5.0.0' s.swift_version = '5.1' - s.module_name = 'DailyNotificationPlugin' + s.xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) COCOAPODS=1' } + s.deprecated = false s.static_framework = true end \ No newline at end of file diff --git a/ios/Plugin/DailyNotificationConfig.swift b/ios/Plugin/DailyNotificationConfig.swift index 5d5a6f4..c453a13 100644 --- a/ios/Plugin/DailyNotificationConfig.swift +++ b/ios/Plugin/DailyNotificationConfig.swift @@ -27,5 +27,59 @@ public struct DailyNotificationConfig { /// Number of days to retain delivered notifications public var retentionDays = 7 + /// Whether adaptive scheduling is enabled + public var adaptiveSchedulingEnabled = true + + /// Battery level thresholds for adaptive scheduling + public struct BatteryThresholds { + public static let critical = 15 + public static let low = 30 + public static let medium = 50 + } + + /// Time intervals for different battery levels (in seconds) + public struct SchedulingIntervals { + public static let critical = TimeInterval(4 * 60 * 60) // 4 hours + public static let low = TimeInterval(2 * 60 * 60) // 2 hours + public static let medium = TimeInterval(60 * 60) // 1 hour + public static let normal = TimeInterval(30 * 60) // 30 minutes + } + + /// Wake lock duration based on battery level (in seconds) + public struct WakeLockDurations { + public static let critical = TimeInterval(30) // 30 seconds + public static let low = TimeInterval(45) // 45 seconds + public static let normal = TimeInterval(60) // 1 minute + } + private init() {} + + /// Resets all configuration options to their default values + public mutating func resetToDefaults() { + maxNotificationsPerDay = 10 + defaultTimeZone = TimeZone.current + loggingEnabled = true + retentionDays = 7 + adaptiveSchedulingEnabled = true + } + + /// Validates and sets the maximum notifications per day + /// - Parameter value: The new maximum notifications value + /// - Throws: DailyNotificationError if the value is invalid + public mutating func setMaxNotificationsPerDay(_ value: Int) throws { + guard value > 0 else { + throw DailyNotificationError.invalidParameters("Max notifications per day must be greater than 0") + } + maxNotificationsPerDay = value + } + + /// Validates and sets the retention days + /// - Parameter value: The new retention days value + /// - Throws: DailyNotificationError if the value is invalid + public mutating func setRetentionDays(_ value: Int) throws { + guard value > 0 else { + throw DailyNotificationError.invalidParameters("Retention days must be greater than 0") + } + retentionDays = value + } } \ No newline at end of file diff --git a/ios/Plugin/DailyNotificationMaintenanceWorker.swift b/ios/Plugin/DailyNotificationMaintenanceWorker.swift new file mode 100644 index 0000000..74b521d --- /dev/null +++ b/ios/Plugin/DailyNotificationMaintenanceWorker.swift @@ -0,0 +1,122 @@ +/** + * DailyNotificationMaintenanceWorker.swift + * Daily Notification Plugin for Capacitor + * + * Handles background maintenance tasks for notifications + */ + +import Foundation +import UserNotifications + +/// Handles background maintenance tasks for the notification plugin +public class DailyNotificationMaintenanceWorker { + /// Shared instance for singleton access + public static let shared = DailyNotificationMaintenanceWorker() + + private let notificationCenter = UNUserNotificationCenter.current() + private let powerManager = DailyNotificationPowerManager.shared + + private init() {} + + /// Performs maintenance tasks + public func performMaintenance() { + DailyNotificationLogger.shared.log(.info, "Starting maintenance tasks") + + // Update battery status + _ = powerManager.getBatteryStatus() + + // Clean up old notifications + cleanupOldNotifications() + + // Reschedule missed notifications + rescheduleMissedNotifications() + + DailyNotificationLogger.shared.log(.info, "Maintenance tasks completed") + } + + private func cleanupOldNotifications() { + let cutoffDate = Date().addingTimeInterval(-Double(DailyNotificationConfig.shared.retentionDays * 24 * 60 * 60)) + + notificationCenter.getDeliveredNotifications { notifications in + let oldNotifications = notifications.filter { $0.date < cutoffDate } + + if !oldNotifications.isEmpty { + let identifiers = oldNotifications.map { $0.request.identifier } + self.notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) + + DailyNotificationLogger.shared.log( + .info, + "Cleaned up \(identifiers.count) old notifications" + ) + } + } + } + + private func rescheduleMissedNotifications() { + notificationCenter.getPendingNotificationRequests { requests in + let now = Date() + + for request in requests { + guard let trigger = request.trigger as? UNCalendarNotificationTrigger, + let nextTriggerDate = trigger.nextTriggerDate() else { + continue + } + + // If the next trigger date is more than 24 hours in the past + if nextTriggerDate.timeIntervalSince(now) < -24 * 60 * 60 { + // Reschedule the notification + let content = request.content.mutableCopy() as! UNMutableNotificationContent + let newRequest = UNNotificationRequest( + identifier: request.identifier, + content: content, + trigger: trigger + ) + + self.notificationCenter.add(newRequest) { error in + if let error = error { + DailyNotificationLogger.shared.log( + .error, + "Failed to reschedule notification: \(error.localizedDescription)" + ) + } else { + DailyNotificationLogger.shared.log( + .info, + "Successfully rescheduled notification: \(request.identifier)" + ) + } + } + } + } + } + } + + /// Schedules the next maintenance window + public func scheduleNextMaintenance() { + let trigger = UNTimeIntervalNotificationTrigger( + timeInterval: DailyNotificationConfig.SchedulingIntervals.normal, + repeats: true + ) + + let content = UNMutableNotificationContent() + content.title = "Maintenance" + content.body = "Performing notification maintenance" + content.sound = nil + + let request = UNNotificationRequest( + identifier: "maintenance-window", + content: content, + trigger: trigger + ) + + notificationCenter.add(request) { error in + if let error = error { + DailyNotificationLogger.shared.log( + .error, + "Failed to schedule maintenance window: \(error.localizedDescription)" + ) + } else { + DailyNotificationLogger.shared.log(.info, "Maintenance window scheduled") + } + } + } +} \ No newline at end of file diff --git a/ios/Plugin/DailyNotificationPlugin.swift b/ios/Plugin/DailyNotificationPlugin.swift index b7670f4..cd51ee5 100644 --- a/ios/Plugin/DailyNotificationPlugin.swift +++ b/ios/Plugin/DailyNotificationPlugin.swift @@ -16,6 +16,8 @@ import UserNotifications @objc(DailyNotificationPlugin) public class DailyNotificationPlugin: CAPPlugin { private let notificationCenter = UNUserNotificationCenter.current() + private let powerManager = DailyNotificationPowerManager.shared + private let maintenanceWorker = DailyNotificationMaintenanceWorker.shared private var settings: [String: Any] = [ "sound": true, @@ -39,6 +41,15 @@ public class DailyNotificationPlugin: CAPPlugin { return } + // Check battery optimization status + let batteryStatus = powerManager.getBatteryStatus() + if batteryStatus["level"] as? Int ?? 100 < DailyNotificationConfig.BatteryThresholds.critical { + DailyNotificationLogger.shared.log( + .warning, + "Warning: Battery level is critical" + ) + } + // Parse time string (HH:mm format) let timeComponents = time.split(separator: ":") guard timeComponents.count == 2, @@ -52,8 +63,8 @@ public class DailyNotificationPlugin: CAPPlugin { // Create notification content let content = UNMutableNotificationContent() - content.title = call.getString("title") ?? "Daily Notification" - content.body = call.getString("body") ?? "Your daily update is ready" + content.title = call.getString("title") ?? DailyNotificationConstants.defaultTitle + content.body = call.getString("body") ?? DailyNotificationConstants.defaultBody content.sound = call.getBool("sound", true) ? .default : nil // Set priority @@ -110,8 +121,16 @@ public class DailyNotificationPlugin: CAPPlugin { // Schedule notification notificationCenter.add(request) { error in if let error = error { + DailyNotificationLogger.shared.log( + .error, + "Failed to schedule notification: \(error.localizedDescription)" + ) call.reject("Failed to schedule notification: \(error.localizedDescription)") } else { + DailyNotificationLogger.shared.log( + .info, + "Successfully scheduled notification for \(time)" + ) call.resolve() } } @@ -255,8 +274,25 @@ public class DailyNotificationPlugin: CAPPlugin { } } + @objc func getBatteryStatus(_ call: CAPPluginCall) { + let status = powerManager.getBatteryStatus() + call.resolve(status) + } + + @objc func getPowerState(_ call: CAPPluginCall) { + let state = powerManager.getPowerState() + call.resolve(state) + } + + @objc func setAdaptiveScheduling(_ call: CAPPluginCall) { + let enabled = call.getBool("enabled", true) + powerManager.setAdaptiveScheduling(enabled) + call.resolve() + } + public override func load() { notificationCenter.delegate = self + maintenanceWorker.scheduleNextMaintenance() } private func isValidTime(_ time: String) -> Bool { diff --git a/ios/Plugin/DailyNotificationPowerManager.swift b/ios/Plugin/DailyNotificationPowerManager.swift new file mode 100644 index 0000000..4dd9e72 --- /dev/null +++ b/ios/Plugin/DailyNotificationPowerManager.swift @@ -0,0 +1,153 @@ +/** + * DailyNotificationPowerManager.swift + * Daily Notification Plugin for Capacitor + * + * Manages power state and battery optimization for notifications + */ + +import Foundation +import UIKit +import UserNotifications + +/// Manages power state and battery optimization for the notification plugin +public class DailyNotificationPowerManager { + /// Shared instance for singleton access + public static let shared = DailyNotificationPowerManager() + + private var batteryLevel: Float = 1.0 + private var isCharging = false + private var lastBatteryCheck: Date = Date() + private var powerState: UIDevice.BatteryState = .unknown + private var adaptiveSchedulingEnabled = true + + private init() { + setupBatteryMonitoring() + } + + private func setupBatteryMonitoring() { + UIDevice.current.isBatteryMonitoringEnabled = true + updateBatteryStatus() + + NotificationCenter.default.addObserver( + self, + selector: #selector(batteryLevelDidChange), + name: UIDevice.batteryLevelDidChangeNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(batteryStateDidChange), + name: UIDevice.batteryStateDidChangeNotification, + object: nil + ) + } + + @objc private func batteryLevelDidChange() { + updateBatteryStatus() + } + + @objc private func batteryStateDidChange() { + updateBatteryStatus() + } + + private func updateBatteryStatus() { + batteryLevel = UIDevice.current.batteryLevel + isCharging = UIDevice.current.batteryState == .charging + powerState = UIDevice.current.batteryState + lastBatteryCheck = Date() + + DailyNotificationLogger.shared.log( + .debug, + "Battery status updated: \(Int(batteryLevel * 100))% (\(isCharging ? "charging" : "not charging"))" + ) + } + + /// Gets the current battery status + /// - Returns: Dictionary containing battery information + public func getBatteryStatus() -> [String: Any] { + return [ + "level": Int(batteryLevel * 100), + "isCharging": isCharging, + "lastCheck": lastBatteryCheck.timeIntervalSince1970, + "powerState": getPowerStateString() + ] + } + + /// Gets the current power state + /// - Returns: Dictionary containing power state information + public func getPowerState() -> [String: Any] { + return [ + "powerState": getPowerStateString(), + "adaptiveScheduling": adaptiveSchedulingEnabled, + "batteryLevel": Int(batteryLevel * 100), + "isCharging": isCharging, + "lastCheck": lastBatteryCheck.timeIntervalSince1970 + ] + } + + /// Sets whether adaptive scheduling is enabled + /// - Parameter enabled: Whether to enable adaptive scheduling + public func setAdaptiveScheduling(_ enabled: Bool) { + adaptiveSchedulingEnabled = enabled + DailyNotificationLogger.shared.log( + .info, + "Adaptive scheduling \(enabled ? "enabled" : "disabled")" + ) + } + + /// Gets the appropriate scheduling interval based on battery level + /// - Returns: TimeInterval for scheduling + public func getSchedulingInterval() -> TimeInterval { + guard adaptiveSchedulingEnabled else { + return DailyNotificationConfig.SchedulingIntervals.normal + } + + let batteryPercentage = Int(batteryLevel * 100) + + switch batteryPercentage { + case .. TimeInterval { + let batteryPercentage = Int(batteryLevel * 100) + + switch batteryPercentage { + case .. String { + switch powerState { + case .charging: + return "CHARGING" + case .full: + return "FULL" + case .unplugged: + return "UNPLUGGED" + case .unknown: + return "UNKNOWN" + @unknown default: + return "UNKNOWN" + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e737c54..0ce8e0e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,8 +3,8 @@ PODS: - CapacitorCordova - CapacitorCordova (5.0.0) - DailyNotificationPlugin (1.0.0): - - Capacitor - - CapacitorCordova + - Capacitor (~> 5.0.0) + - CapacitorCordova (~> 5.0.0) DEPENDENCIES: - "Capacitor (from `../node_modules/@capacitor/ios`)" @@ -22,7 +22,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Capacitor: ba8cd5cce13c6ab3c4faf7ef98487be481c9c1c8 CapacitorCordova: 4ea17670ee562680988a7ce9db68dee5160fe564 - DailyNotificationPlugin: 59b7578061086ff48fd72151c36cdbd90f004bf5 + DailyNotificationPlugin: 745a0606d51baec6fc9a025f1de1ade125ed193a PODFILE CHECKSUM: ac8c229d24347f6f83e67e6b95458e0b81e68f7c diff --git a/ios/Tests/DailyNotificationTests.swift b/ios/Tests/DailyNotificationTests.swift index d1c5bbe..21408fe 100644 --- a/ios/Tests/DailyNotificationTests.swift +++ b/ios/Tests/DailyNotificationTests.swift @@ -1,29 +1,150 @@ +/** + * DailyNotificationTests.swift + * Daily Notification Plugin for Capacitor + * + * Tests for the DailyNotification plugin + */ + import XCTest -@testable import Plugin +@testable import DailyNotificationPlugin class DailyNotificationTests: XCTestCase { var plugin: DailyNotificationPlugin! + var powerManager: DailyNotificationPowerManager! + var maintenanceWorker: DailyNotificationMaintenanceWorker! override func setUp() { super.setUp() plugin = DailyNotificationPlugin() + powerManager = DailyNotificationPowerManager.shared + maintenanceWorker = DailyNotificationMaintenanceWorker.shared + } + + override func tearDown() { + plugin = nil + super.tearDown() + } + + // MARK: - Power Management Tests + + func testBatteryStatus() { + let status = powerManager.getBatteryStatus() + + XCTAssertNotNil(status["level"]) + XCTAssertNotNil(status["isCharging"]) + XCTAssertNotNil(status["lastCheck"]) + XCTAssertNotNil(status["powerState"]) + } + + func testPowerState() { + let state = powerManager.getPowerState() + + XCTAssertNotNil(state["powerState"]) + XCTAssertNotNil(state["adaptiveScheduling"]) + XCTAssertNotNil(state["batteryLevel"]) + XCTAssertNotNil(state["isCharging"]) + XCTAssertNotNil(state["lastCheck"]) + } + + func testAdaptiveScheduling() { + powerManager.setAdaptiveScheduling(true) + let normalInterval = DailyNotificationConfig.SchedulingIntervals.normal + let criticalInterval = DailyNotificationConfig.SchedulingIntervals.critical + + // Test with different battery levels + let intervals = [ + (batteryLevel: 10, expectedInterval: criticalInterval), + (batteryLevel: 20, expectedInterval: DailyNotificationConfig.SchedulingIntervals.low), + (batteryLevel: 40, expectedInterval: DailyNotificationConfig.SchedulingIntervals.medium), + (batteryLevel: 60, expectedInterval: normalInterval) + ] + + for (level, expected) in intervals { + // Simulate battery level + UIDevice.current.setValue(level, forKey: "batteryLevel") + + let interval = powerManager.getSchedulingInterval() + XCTAssertEqual(interval, expected, "Interval should be \(expected) for battery level \(level)") + } } - func testTimeValidation() { - // Valid time - XCTAssertTrue(plugin.isValidTime("09:00")) + // MARK: - Maintenance Tests + + func testMaintenanceTasks() { + // Test cleanup of old notifications + let oldDate = Date().addingTimeInterval(-Double(DailyNotificationConfig.shared.retentionDays * 24 * 60 * 60 + 1)) + + // Create a test notification + let content = UNMutableNotificationContent() + content.title = "Test Notification" + content.body = "This is a test notification" + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let request = UNNotificationRequest( + identifier: "test-notification", + content: content, + trigger: trigger + ) + + let expectation = XCTestExpectation(description: "Cleanup old notifications") - // Invalid times - XCTAssertFalse(plugin.isValidTime("25:00")) - XCTAssertFalse(plugin.isValidTime("09:60")) - XCTAssertFalse(plugin.isValidTime("9:00")) - XCTAssertFalse(plugin.isValidTime("0900")) + UNUserNotificationCenter.current().add(request) { error in + XCTAssertNil(error) + + // Perform maintenance + self.maintenanceWorker.performMaintenance() + + // Verify cleanup + UNUserNotificationCenter.current().getDeliveredNotifications { notifications in + let oldNotifications = notifications.filter { $0.date < oldDate } + XCTAssertTrue(oldNotifications.isEmpty, "Old notifications should be cleaned up") + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 5.0) } - func testTimezoneValidation() { - XCTAssertTrue(plugin.isValidTimezone("America/New_York")) - XCTAssertFalse(plugin.isValidTimezone("Invalid/Timezone")) + // MARK: - Configuration Tests + + func testConfiguration() { + let config = DailyNotificationConfig.shared + + // Test default values + XCTAssertEqual(config.maxNotificationsPerDay, 10) + XCTAssertEqual(config.retentionDays, 7) + XCTAssertTrue(config.loggingEnabled) + XCTAssertTrue(config.adaptiveSchedulingEnabled) + + // Test validation + XCTAssertThrowsError(try config.setMaxNotificationsPerDay(0)) + XCTAssertThrowsError(try config.setRetentionDays(0)) + + // Test reset + config.resetToDefaults() + XCTAssertEqual(config.maxNotificationsPerDay, 10) + XCTAssertEqual(config.retentionDays, 7) + XCTAssertTrue(config.loggingEnabled) + XCTAssertTrue(config.adaptiveSchedulingEnabled) } - // Add more tests... + // MARK: - Constants Tests + + func testConstants() { + // Test default values + XCTAssertEqual(DailyNotificationConstants.defaultTitle, "Daily Notification") + XCTAssertEqual(DailyNotificationConstants.defaultBody, "Your daily update is ready") + + // Test notification identifier prefix + XCTAssertTrue(DailyNotificationConstants.notificationIdentifierPrefix.hasPrefix("daily-notification-")) + + // Test event name + XCTAssertEqual(DailyNotificationConstants.eventName, "notification") + + // Test settings defaults + XCTAssertTrue(DailyNotificationConstants.Settings.defaultSound) + XCTAssertEqual(DailyNotificationConstants.Settings.defaultPriority, "default") + XCTAssertEqual(DailyNotificationConstants.Settings.defaultRetryCount, 3) + XCTAssertEqual(DailyNotificationConstants.Settings.defaultRetryInterval, 1000) + } } \ No newline at end of file diff --git a/lib/bin/main/org/example/Library.class b/lib/bin/main/org/example/Library.class new file mode 100644 index 0000000000000000000000000000000000000000..66de899e76fbaa26678528115d412d98464a1d19 GIT binary patch literal 354 zcmZusOHRWu6r7i)O$mX15C>of2;l>OkSZir0T!Sx*g5G_>ne$nnl@}KK_gl=XQa;@l-bTczKkuJA78`zw&os(QUwU9%VDdzUI zHcmKYG-JC3cAm4+c%CnnlQtGh75l<^0a}E;TkZ|fMAe)98rnD@G&d9!WuH~hA-voO zM%m2d=_Im+%;?au&chH7@hCu-5cw-NHTAPh+Cp~?kMV>Mio&z1vVC-x+zjE=S*52v z-}w4C<-9L?!hPnd2>Z9SlG_-bn%s`%N9AYH_-DdzJr@zUCVmes>wpb&i&>%R8Ud;MIeU+2_%%pJ;x)#$NK~3in#~zvICT gwO&ZrL4{dOrg?gM_1BtSLC8{{GMDg-qa{553mQMEo&W#< literal 0 HcmV?d00001