Files
daily-notification-plugin/ios/Plugin/NotificationContent.swift
Matthew Raymer 36f2c095db feat(ios): add deliveryStatus and lastDeliveryAttempt to NotificationContent
Complete final 2 Phase 2 iOS enhancements - delivery tracking properties.

Changes:
- NotificationContent: Add delivery tracking properties
  - deliveryStatus: String? (e.g., "scheduled", "delivered", "missed", "error")
  - lastDeliveryAttempt: Int64? (milliseconds since epoch)
  - Updated Codable support (CodingKeys, init, encode)
  - Updated toDictionary/fromDictionary for backward compatibility
  - Properties are optional with default nil (backward compatible)
- DailyNotificationReactivationManager: Use delivery tracking
  - detectMissedNotifications(): Filter by deliveryStatus != "delivered"
  - markMissedNotification(): Set deliveryStatus="missed" and lastDeliveryAttempt
  - Removed 2 TODOs, fully implemented

Phase 2 Progress: 8 of 8 enhancements COMPLETE 
-  Rolling window maintenance
-  TTL validation
-  Database statistics
-  Metrics recording
-  CoreData history
-  Fetcher instances clarified
-  deliveryStatus property (this commit)
-  lastDeliveryAttempt property (this commit)

Verification:
- TypeScript typecheck: PASS
- Tests: PASS (115 tests, 8 test suites)
- No linter errors
- Backward compatible (optional parameters with defaults)
2025-12-24 07:38:12 +00:00

276 lines
9.1 KiB
Swift

/**
* NotificationContent.swift
*
* Data structure for notification content
*
* @author Matthew Raymer
* @version 1.0.0
*/
import Foundation
/**
* Data structure representing notification content
*
* This class encapsulates all the information needed for a notification
* including scheduling, content, and metadata.
*/
class NotificationContent: Codable {
// MARK: - Properties
let id: String
let title: String?
let body: String?
let scheduledTime: Int64 // milliseconds since epoch (matches Android long)
let fetchedAt: Int64 // milliseconds since epoch (matches Android long)
let url: String?
let payload: [String: Any]?
let etag: String?
// Phase 2: Delivery tracking properties
var deliveryStatus: String? // e.g., "scheduled", "delivered", "missed", "error"
var lastDeliveryAttempt: Int64? // milliseconds since epoch (matches Android long)
// MARK: - Codable Support
enum CodingKeys: String, CodingKey {
case id
case title
case body
case scheduledTime
case fetchedAt
case url
case payload
case etag
case deliveryStatus
case lastDeliveryAttempt
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title)
body = try container.decodeIfPresent(String.self, forKey: .body)
scheduledTime = try container.decode(Int64.self, forKey: .scheduledTime)
fetchedAt = try container.decode(Int64.self, forKey: .fetchedAt)
url = try container.decodeIfPresent(String.self, forKey: .url)
// payload is encoded as JSON string
if let payloadString = try? container.decodeIfPresent(String.self, forKey: .payload),
let payloadData = payloadString.data(using: .utf8),
let payloadDict = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any] {
payload = payloadDict
} else {
payload = nil
}
etag = try container.decodeIfPresent(String.self, forKey: .etag)
deliveryStatus = try container.decodeIfPresent(String.self, forKey: .deliveryStatus)
lastDeliveryAttempt = try container.decodeIfPresent(Int64.self, forKey: .lastDeliveryAttempt)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(title, forKey: .title)
try container.encodeIfPresent(body, forKey: .body)
try container.encode(scheduledTime, forKey: .scheduledTime)
try container.encode(fetchedAt, forKey: .fetchedAt)
try container.encodeIfPresent(url, forKey: .url)
// Encode payload as JSON string
if let payload = payload,
let payloadData = try? JSONSerialization.data(withJSONObject: payload),
let payloadString = String(data: payloadData, encoding: .utf8) {
try container.encode(payloadString, forKey: .payload)
}
try container.encodeIfPresent(etag, forKey: .etag)
try container.encodeIfPresent(deliveryStatus, forKey: .deliveryStatus)
try container.encodeIfPresent(lastDeliveryAttempt, forKey: .lastDeliveryAttempt)
}
// MARK: - Initialization
/**
* Initialize notification content
*
* @param id Unique notification identifier
* @param title Notification title
* @param body Notification body text
* @param scheduledTime When notification should fire (milliseconds since epoch)
* @param fetchedAt When content was fetched (milliseconds since epoch)
* @param url URL for content fetching
* @param payload Additional payload data
* @param etag ETag for HTTP caching
* @param deliveryStatus Delivery status (optional, Phase 2)
* @param lastDeliveryAttempt Last delivery attempt timestamp (optional, Phase 2)
*/
init(id: String,
title: String?,
body: String?,
scheduledTime: Int64,
fetchedAt: Int64,
url: String?,
payload: [String: Any]?,
etag: String?,
deliveryStatus: String? = nil,
lastDeliveryAttempt: Int64? = nil) {
self.id = id
self.title = title
self.body = body
self.scheduledTime = scheduledTime
self.fetchedAt = fetchedAt
self.url = url
self.payload = payload
self.etag = etag
self.deliveryStatus = deliveryStatus
self.lastDeliveryAttempt = lastDeliveryAttempt
}
// MARK: - Convenience Methods
/**
* Get scheduled time as Date
*
* @return Scheduled time as Date object
*/
func getScheduledTimeAsDate() -> Date {
return Date(timeIntervalSince1970: Double(scheduledTime) / 1000.0)
}
/**
* Get fetched time as Date
*
* @return Fetched time as Date object
*/
func getFetchedTimeAsDate() -> Date {
return Date(timeIntervalSince1970: Double(fetchedAt) / 1000.0)
}
/**
* Check if notification is scheduled for today
*
* @return true if scheduled for today
*/
func isScheduledForToday() -> Bool {
let scheduledDate = getScheduledTimeAsDate()
let today = Date()
let calendar = Calendar.current
return calendar.isDate(scheduledDate, inSameDayAs: today)
}
/**
* Check if notification is scheduled for tomorrow
*
* @return true if scheduled for tomorrow
*/
func isScheduledForTomorrow() -> Bool {
let scheduledDate = getScheduledTimeAsDate()
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date()
let calendar = Calendar.current
return calendar.isDate(scheduledDate, inSameDayAs: tomorrow)
}
/**
* Check if notification is in the future
*
* @return true if scheduled time is in the future
*/
func isInTheFuture() -> Bool {
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
return scheduledTime > currentTime
}
/**
* Get age of content at scheduled time
*
* @return Age in seconds at scheduled time
*/
func getAgeAtScheduledTime() -> TimeInterval {
return Double(scheduledTime - fetchedAt) / 1000.0
}
/**
* Convert to dictionary representation
*
* @return Dictionary representation of notification content
*/
func toDictionary() -> [String: Any] {
var dict: [String: Any] = [
"id": id,
"title": title ?? "",
"body": body ?? "",
"scheduledTime": scheduledTime,
"fetchedAt": fetchedAt,
"url": url ?? "",
"payload": payload ?? [:],
"etag": etag ?? ""
]
// Phase 2: Add delivery tracking properties if present
if let deliveryStatus = deliveryStatus {
dict["deliveryStatus"] = deliveryStatus
}
if let lastDeliveryAttempt = lastDeliveryAttempt {
dict["lastDeliveryAttempt"] = lastDeliveryAttempt
}
return dict
}
/**
* Create from dictionary representation
*
* @param dict Dictionary representation
* @return NotificationContent instance
*/
static func fromDictionary(_ dict: [String: Any]) -> NotificationContent? {
guard let id = dict["id"] as? String else {
return nil
}
// Handle both Int64 and TimeInterval (Double) for backward compatibility
let scheduledTime: Int64
if let time = dict["scheduledTime"] as? Int64 {
scheduledTime = time
} else if let time = dict["scheduledTime"] as? Double {
scheduledTime = Int64(time)
} else {
return nil
}
let fetchedAt: Int64
if let time = dict["fetchedAt"] as? Int64 {
fetchedAt = time
} else if let time = dict["fetchedAt"] as? Double {
fetchedAt = Int64(time)
} else {
return nil
}
// Handle lastDeliveryAttempt (can be Int64 or Double/TimeInterval)
let lastDeliveryAttempt: Int64?
if let attempt = dict["lastDeliveryAttempt"] as? Int64 {
lastDeliveryAttempt = attempt
} else if let attempt = dict["lastDeliveryAttempt"] as? Double {
lastDeliveryAttempt = Int64(attempt)
} else {
lastDeliveryAttempt = nil
}
return NotificationContent(
id: id,
title: dict["title"] as? String,
body: dict["body"] as? String,
scheduledTime: scheduledTime,
fetchedAt: fetchedAt,
url: dict["url"] as? String,
payload: dict["payload"] as? [String: Any],
etag: dict["etag"] as? String,
deliveryStatus: dict["deliveryStatus"] as? String,
lastDeliveryAttempt: lastDeliveryAttempt
)
}
}