From d8b29954a232dac2e1e2a2e7da66e5b1855dc7ec Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Wed, 24 Dec 2025 04:11:41 +0000 Subject: [PATCH] fix(ios,android): implement rolling window counting, TTL validation, and DB persistence - iOS: Implement rolling window counting using UNUserNotificationCenter - iOS: Enable TTL validation in scheduler before arming notifications - iOS: Implement SQLite persistence for save/delete/clear operations - Android: Implement rolling window counting using storage as source of truth - Android: Add dateBoundsMillis helper for date range calculations Removes all TODO stubs affecting capacity/rate-limiting correctness. Fixes runtime behavior to match test expectations and optimizer logic. Refs: Deep fixes directive for bottom-of-tree gaps --- .../DailyNotificationRollingWindow.java | 79 ++++++++++++--- ios/Plugin/DailyNotificationDatabase.swift | 99 +++++++++++++++++-- .../DailyNotificationRollingWindow.swift | 81 ++++++++++++--- ios/Plugin/DailyNotificationScheduler.swift | 7 +- 4 files changed, 233 insertions(+), 33 deletions(-) diff --git a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationRollingWindow.java b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationRollingWindow.java index 83cd94e..f712442 100644 --- a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationRollingWindow.java +++ b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationRollingWindow.java @@ -271,10 +271,17 @@ public class DailyNotificationRollingWindow { */ private int countPendingNotifications() { try { - // This would typically query the storage for pending notifications - // For now, we'll use a placeholder implementation - return 0; // TODO: Implement actual counting logic - + long now = System.currentTimeMillis(); + int count = 0; + + List all = storage.getAllNotifications(); + for (NotificationContent n : all) { + if (n.getScheduledTime() >= now) { + count++; + } + } + return count; + } catch (Exception e) { Log.e(TAG, "Error counting pending notifications", e); return 0; @@ -289,10 +296,20 @@ public class DailyNotificationRollingWindow { */ private int countNotificationsForDate(String date) { try { - // This would typically query the storage for notifications on a specific date - // For now, we'll use a placeholder implementation - return 0; // TODO: Implement actual counting logic - + long[] bounds = dateBoundsMillis(date); + long start = bounds[0]; + long end = bounds[1]; + + int count = 0; + List all = storage.getAllNotifications(); + for (NotificationContent n : all) { + long t = n.getScheduledTime(); + if (t >= start && t < end) { + count++; + } + } + return count; + } catch (Exception e) { Log.e(TAG, "Error counting notifications for date: " + date, e); return 0; @@ -307,10 +324,20 @@ public class DailyNotificationRollingWindow { */ private List getNotificationsForDate(String date) { try { - // This would typically query the storage for notifications on a specific date - // For now, we'll return an empty list - return new ArrayList<>(); // TODO: Implement actual retrieval logic - + long[] bounds = dateBoundsMillis(date); + long start = bounds[0]; + long end = bounds[1]; + + List results = new ArrayList<>(); + List all = storage.getAllNotifications(); + for (NotificationContent n : all) { + long t = n.getScheduledTime(); + if (t >= start && t < end) { + results.add(n); + } + } + return results; + } catch (Exception e) { Log.e(TAG, "Error getting notifications for date: " + date, e); return new ArrayList<>(); @@ -331,6 +358,34 @@ public class DailyNotificationRollingWindow { return String.format("%04d-%02d-%02d", year, month, day); } + /** + * Get date bounds in milliseconds for a given date string + * + * @param yyyyMmDd Date in YYYY-MM-DD format + * @return Array with [startMillis, endMillis] + */ + private long[] dateBoundsMillis(String yyyyMmDd) { + // yyyyMmDd: "YYYY-MM-DD" + String[] parts = yyyyMmDd.split("-"); + int year = Integer.parseInt(parts[0]); + int month = Integer.parseInt(parts[1]); // 1-12 + int day = Integer.parseInt(parts[2]); + + Calendar start = Calendar.getInstance(); + start.set(Calendar.YEAR, year); + start.set(Calendar.MONTH, month - 1); // Calendar months are 0-based + start.set(Calendar.DAY_OF_MONTH, day); + start.set(Calendar.HOUR_OF_DAY, 0); + start.set(Calendar.MINUTE, 0); + start.set(Calendar.SECOND, 0); + start.set(Calendar.MILLISECOND, 0); + + Calendar end = (Calendar) start.clone(); + end.add(Calendar.DAY_OF_MONTH, 1); + + return new long[] { start.getTimeInMillis(), end.getTimeInMillis() }; + } + /** * Get rolling window statistics * diff --git a/ios/Plugin/DailyNotificationDatabase.swift b/ios/Plugin/DailyNotificationDatabase.swift index 5384ebb..e0f2fb5 100644 --- a/ios/Plugin/DailyNotificationDatabase.swift +++ b/ios/Plugin/DailyNotificationDatabase.swift @@ -215,9 +215,53 @@ class DailyNotificationDatabase { * @param content Notification content to save */ func saveNotificationContent(_ content: NotificationContent) { - // TODO: Implement database persistence - // For Phase 1, storage uses UserDefaults primarily - print("\(Self.TAG): saveNotificationContent called for \(content.id)") + do { + guard db != nil else { + print("\(Self.TAG): DB not open; cannot saveNotificationContent for \(content.id)") + return + } + + let encoder = JSONEncoder() + let data = try encoder.encode(content) + guard let json = String(data: data, encoding: .utf8) else { + print("\(Self.TAG): Failed to encode NotificationContent to UTF-8 JSON for \(content.id)") + return + } + + let sql = """ + INSERT OR REPLACE INTO \(Self.TABLE_NOTIF_CONTENTS) + (\(Self.COL_CONTENTS_SLOT_ID), \(Self.COL_CONTENTS_PAYLOAD_JSON), \(Self.COL_CONTENTS_FETCHED_AT), \(Self.COL_CONTENTS_ETAG)) + VALUES (?, ?, ?, ?); + """ + + var stmt: OpaquePointer? + if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) != SQLITE_OK { + print("\(Self.TAG): saveNotificationContent prepare failed: \(String(cString: sqlite3_errmsg(db)))") + sqlite3_finalize(stmt) + return + } + + sqlite3_bind_text(stmt, 1, (content.id as NSString).utf8String, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 2, (json as NSString).utf8String, -1, SQLITE_TRANSIENT) + sqlite3_bind_int64(stmt, 3, sqlite3_int64(content.fetchedAt)) + + if let etag = content.etag { + sqlite3_bind_text(stmt, 4, (etag as NSString).utf8String, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(stmt, 4) + } + + if sqlite3_step(stmt) != SQLITE_DONE { + print("\(Self.TAG): saveNotificationContent step failed: \(String(cString: sqlite3_errmsg(db)))") + } else { + print("\(Self.TAG): Saved notification content: slot=\(content.id) fetched_at=\(content.fetchedAt)") + } + + sqlite3_finalize(stmt) + + } catch { + print("\(Self.TAG): saveNotificationContent error for \(content.id): \(error)") + } } /** @@ -226,15 +270,56 @@ class DailyNotificationDatabase { * @param id Notification ID */ func deleteNotificationContent(id: String) { - // TODO: Implement database deletion - print("\(Self.TAG): deleteNotificationContent called for \(id)") + do { + guard db != nil else { + print("\(Self.TAG): DB not open; cannot deleteNotificationContent for \(id)") + return + } + + let sql = """ + DELETE FROM \(Self.TABLE_NOTIF_CONTENTS) + WHERE \(Self.COL_CONTENTS_SLOT_ID) = ?; + """ + + var stmt: OpaquePointer? + if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) != SQLITE_OK { + print("\(Self.TAG): deleteNotificationContent prepare failed: \(String(cString: sqlite3_errmsg(db)))") + sqlite3_finalize(stmt) + return + } + + sqlite3_bind_text(stmt, 1, (id as NSString).utf8String, -1, SQLITE_TRANSIENT) + + if sqlite3_step(stmt) != SQLITE_DONE { + print("\(Self.TAG): deleteNotificationContent step failed: \(String(cString: sqlite3_errmsg(db)))") + } else { + print("\(Self.TAG): Deleted notification content rows for slot=\(id)") + } + + sqlite3_finalize(stmt) + + } catch { + print("\(Self.TAG): deleteNotificationContent error for \(id): \(error)") + } } /** * Clear all notifications from database */ func clearAllNotifications() { - // TODO: Implement database clearing - print("\(Self.TAG): clearAllNotifications called") + do { + guard db != nil else { + print("\(Self.TAG): DB not open; cannot clearAllNotifications") + return + } + + executeSQL("DELETE FROM \(Self.TABLE_NOTIF_CONTENTS);") + executeSQL("DELETE FROM \(Self.TABLE_NOTIF_DELIVERIES);") + + print("\(Self.TAG): Cleared all notifications (contents + deliveries)") + + } catch { + print("\(Self.TAG): clearAllNotifications error: \(error)") + } } } diff --git a/ios/Plugin/DailyNotificationRollingWindow.swift b/ios/Plugin/DailyNotificationRollingWindow.swift index dd631d8..2097bd4 100644 --- a/ios/Plugin/DailyNotificationRollingWindow.swift +++ b/ios/Plugin/DailyNotificationRollingWindow.swift @@ -287,6 +287,25 @@ class DailyNotificationRollingWindow { // MARK: - Data Access + /** + * Fetch pending notification requests synchronously + * + * @param timeoutSeconds Timeout in seconds + * @return Array of pending notification requests + */ + private func fetchPendingRequestsSync(timeoutSeconds: TimeInterval) -> [UNNotificationRequest] { + let sem = DispatchSemaphore(value: 0) + var result: [UNNotificationRequest] = [] + + UNUserNotificationCenter.current().getPendingNotificationRequests { requests in + result = requests + sem.signal() + } + + _ = sem.wait(timeout: .now() + timeoutSeconds) + return result + } + /** * Count pending notifications * @@ -294,10 +313,8 @@ class DailyNotificationRollingWindow { */ private func countPendingNotifications() -> Int { do { - // This would typically query the storage for pending notifications - // For now, we'll use a placeholder implementation - return 0 // TODO: Implement actual counting logic - + let requests = fetchPendingRequestsSync(timeoutSeconds: 2.0) + return requests.count } catch { print("\(Self.TAG): Error counting pending notifications: \(error)") return 0 @@ -312,10 +329,18 @@ class DailyNotificationRollingWindow { */ private func countNotificationsForDate(_ date: String) -> Int { do { - // This would typically query the storage for notifications on a specific date - // For now, we'll use a placeholder implementation - return 0 // TODO: Implement actual counting logic - + let requests = fetchPendingRequestsSync(timeoutSeconds: 2.0) + + var count = 0 + for req in requests { + guard let trigger = req.trigger as? UNCalendarNotificationTrigger else { continue } + guard let nextDate = trigger.nextTriggerDate() else { continue } + if formatDate(nextDate) == date { + count += 1 + } + } + + return count } catch { print("\(Self.TAG): Error counting notifications for date: \(date), error: \(error)") return 0 @@ -330,10 +355,42 @@ class DailyNotificationRollingWindow { */ private func getNotificationsForDate(_ date: String) -> [NotificationContent] { do { - // This would typically query the storage for notifications on a specific date - // For now, we'll return an empty array - return [] // TODO: Implement actual retrieval logic - + let requests = fetchPendingRequestsSync(timeoutSeconds: 2.0) + + var results: [NotificationContent] = [] + for req in requests { + guard let trigger = req.trigger as? UNCalendarNotificationTrigger else { continue } + guard let nextDate = trigger.nextTriggerDate() else { continue } + if formatDate(nextDate) != date { continue } + + // We cannot reconstruct full NotificationContent from UNNotificationRequest reliably, + // so this returns minimal stubs primarily for internal rolling-window inspection. + let id = req.identifier + let scheduledMs = Int64(nextDate.timeIntervalSince1970 * 1000.0) + + let fetchedMs: Int64 + if let fetchedAt = req.content.userInfo["fetched_at"] as? Int64 { + fetchedMs = fetchedAt + } else if let fetchedAt = req.content.userInfo["fetched_at"] as? Int { + fetchedMs = Int64(fetchedAt) + } else { + fetchedMs = scheduledMs + } + + let stub = NotificationContent( + id: id, + title: req.content.title, + body: req.content.body, + scheduledTime: scheduledMs, + fetchedAt: fetchedMs, + url: nil, + payload: nil, + etag: nil + ) + results.append(stub) + } + + return results } catch { print("\(Self.TAG): Error getting notifications for date: \(date), error: \(error)") return [] diff --git a/ios/Plugin/DailyNotificationScheduler.swift b/ios/Plugin/DailyNotificationScheduler.swift index 9438d42..3675762 100644 --- a/ios/Plugin/DailyNotificationScheduler.swift +++ b/ios/Plugin/DailyNotificationScheduler.swift @@ -145,8 +145,11 @@ class DailyNotificationScheduler { // TTL validation before arming if let ttlEnforcer = ttlEnforcer { - // TODO: Implement TTL validation - // For Phase 1, skip TTL validation (deferred to Phase 2) + let okToArm = ttlEnforcer.validateBeforeArming(content) + if !okToArm { + print("\(Self.TAG): TTL validation failed, skipping schedule for \(content.id)") + return false + } } // Cancel any existing notification for this ID