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
This commit is contained in:
@@ -271,10 +271,17 @@ public class DailyNotificationRollingWindow {
|
|||||||
*/
|
*/
|
||||||
private int countPendingNotifications() {
|
private int countPendingNotifications() {
|
||||||
try {
|
try {
|
||||||
// This would typically query the storage for pending notifications
|
long now = System.currentTimeMillis();
|
||||||
// For now, we'll use a placeholder implementation
|
int count = 0;
|
||||||
return 0; // TODO: Implement actual counting logic
|
|
||||||
|
List<NotificationContent> all = storage.getAllNotifications();
|
||||||
|
for (NotificationContent n : all) {
|
||||||
|
if (n.getScheduledTime() >= now) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error counting pending notifications", e);
|
Log.e(TAG, "Error counting pending notifications", e);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -289,10 +296,20 @@ public class DailyNotificationRollingWindow {
|
|||||||
*/
|
*/
|
||||||
private int countNotificationsForDate(String date) {
|
private int countNotificationsForDate(String date) {
|
||||||
try {
|
try {
|
||||||
// This would typically query the storage for notifications on a specific date
|
long[] bounds = dateBoundsMillis(date);
|
||||||
// For now, we'll use a placeholder implementation
|
long start = bounds[0];
|
||||||
return 0; // TODO: Implement actual counting logic
|
long end = bounds[1];
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
List<NotificationContent> all = storage.getAllNotifications();
|
||||||
|
for (NotificationContent n : all) {
|
||||||
|
long t = n.getScheduledTime();
|
||||||
|
if (t >= start && t < end) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error counting notifications for date: " + date, e);
|
Log.e(TAG, "Error counting notifications for date: " + date, e);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -307,10 +324,20 @@ public class DailyNotificationRollingWindow {
|
|||||||
*/
|
*/
|
||||||
private List<NotificationContent> getNotificationsForDate(String date) {
|
private List<NotificationContent> getNotificationsForDate(String date) {
|
||||||
try {
|
try {
|
||||||
// This would typically query the storage for notifications on a specific date
|
long[] bounds = dateBoundsMillis(date);
|
||||||
// For now, we'll return an empty list
|
long start = bounds[0];
|
||||||
return new ArrayList<>(); // TODO: Implement actual retrieval logic
|
long end = bounds[1];
|
||||||
|
|
||||||
|
List<NotificationContent> results = new ArrayList<>();
|
||||||
|
List<NotificationContent> all = storage.getAllNotifications();
|
||||||
|
for (NotificationContent n : all) {
|
||||||
|
long t = n.getScheduledTime();
|
||||||
|
if (t >= start && t < end) {
|
||||||
|
results.add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error getting notifications for date: " + date, e);
|
Log.e(TAG, "Error getting notifications for date: " + date, e);
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
@@ -331,6 +358,34 @@ public class DailyNotificationRollingWindow {
|
|||||||
return String.format("%04d-%02d-%02d", year, month, day);
|
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
|
* Get rolling window statistics
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -215,9 +215,53 @@ class DailyNotificationDatabase {
|
|||||||
* @param content Notification content to save
|
* @param content Notification content to save
|
||||||
*/
|
*/
|
||||||
func saveNotificationContent(_ content: NotificationContent) {
|
func saveNotificationContent(_ content: NotificationContent) {
|
||||||
// TODO: Implement database persistence
|
do {
|
||||||
// For Phase 1, storage uses UserDefaults primarily
|
guard db != nil else {
|
||||||
print("\(Self.TAG): saveNotificationContent called for \(content.id)")
|
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
|
* @param id Notification ID
|
||||||
*/
|
*/
|
||||||
func deleteNotificationContent(id: String) {
|
func deleteNotificationContent(id: String) {
|
||||||
// TODO: Implement database deletion
|
do {
|
||||||
print("\(Self.TAG): deleteNotificationContent called for \(id)")
|
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
|
* Clear all notifications from database
|
||||||
*/
|
*/
|
||||||
func clearAllNotifications() {
|
func clearAllNotifications() {
|
||||||
// TODO: Implement database clearing
|
do {
|
||||||
print("\(Self.TAG): clearAllNotifications called")
|
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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,6 +287,25 @@ class DailyNotificationRollingWindow {
|
|||||||
|
|
||||||
// MARK: - Data Access
|
// 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
|
* Count pending notifications
|
||||||
*
|
*
|
||||||
@@ -294,10 +313,8 @@ class DailyNotificationRollingWindow {
|
|||||||
*/
|
*/
|
||||||
private func countPendingNotifications() -> Int {
|
private func countPendingNotifications() -> Int {
|
||||||
do {
|
do {
|
||||||
// This would typically query the storage for pending notifications
|
let requests = fetchPendingRequestsSync(timeoutSeconds: 2.0)
|
||||||
// For now, we'll use a placeholder implementation
|
return requests.count
|
||||||
return 0 // TODO: Implement actual counting logic
|
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
print("\(Self.TAG): Error counting pending notifications: \(error)")
|
print("\(Self.TAG): Error counting pending notifications: \(error)")
|
||||||
return 0
|
return 0
|
||||||
@@ -312,10 +329,18 @@ class DailyNotificationRollingWindow {
|
|||||||
*/
|
*/
|
||||||
private func countNotificationsForDate(_ date: String) -> Int {
|
private func countNotificationsForDate(_ date: String) -> Int {
|
||||||
do {
|
do {
|
||||||
// This would typically query the storage for notifications on a specific date
|
let requests = fetchPendingRequestsSync(timeoutSeconds: 2.0)
|
||||||
// For now, we'll use a placeholder implementation
|
|
||||||
return 0 // TODO: Implement actual counting logic
|
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 {
|
} catch {
|
||||||
print("\(Self.TAG): Error counting notifications for date: \(date), error: \(error)")
|
print("\(Self.TAG): Error counting notifications for date: \(date), error: \(error)")
|
||||||
return 0
|
return 0
|
||||||
@@ -330,10 +355,42 @@ class DailyNotificationRollingWindow {
|
|||||||
*/
|
*/
|
||||||
private func getNotificationsForDate(_ date: String) -> [NotificationContent] {
|
private func getNotificationsForDate(_ date: String) -> [NotificationContent] {
|
||||||
do {
|
do {
|
||||||
// This would typically query the storage for notifications on a specific date
|
let requests = fetchPendingRequestsSync(timeoutSeconds: 2.0)
|
||||||
// For now, we'll return an empty array
|
|
||||||
return [] // TODO: Implement actual retrieval logic
|
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 {
|
} catch {
|
||||||
print("\(Self.TAG): Error getting notifications for date: \(date), error: \(error)")
|
print("\(Self.TAG): Error getting notifications for date: \(date), error: \(error)")
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -145,8 +145,11 @@ class DailyNotificationScheduler {
|
|||||||
|
|
||||||
// TTL validation before arming
|
// TTL validation before arming
|
||||||
if let ttlEnforcer = ttlEnforcer {
|
if let ttlEnforcer = ttlEnforcer {
|
||||||
// TODO: Implement TTL validation
|
let okToArm = ttlEnforcer.validateBeforeArming(content)
|
||||||
// For Phase 1, skip TTL validation (deferred to Phase 2)
|
if !okToArm {
|
||||||
|
print("\(Self.TAG): TTL validation failed, skipping schedule for \(content.id)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel any existing notification for this ID
|
// Cancel any existing notification for this ID
|
||||||
|
|||||||
Reference in New Issue
Block a user