Browse Source
- Add complete iOS plugin implementation with BGTaskScheduler integration - Implement Core Data model mirroring Android SQLite schema (ContentCache, Schedule, Callback, History) - Add background task handlers for content fetch and notification delivery - Implement TTL-at-fire logic with Core Data persistence - Add callback management with HTTP and local callback support - Include comprehensive error handling and structured logging - Add Info.plist configuration for background tasks and permissions - Support for dual scheduling with BGAppRefreshTask and BGProcessingTask BREAKING CHANGE: iOS implementation requires iOS 13.0+ and background task permissionsresearch/notification-plugin-enhancement
6 changed files with 905 additions and 198 deletions
@ -0,0 +1,173 @@ |
|||||
|
// |
||||
|
// DailyNotificationBackgroundTasks.swift |
||||
|
// DailyNotificationPlugin |
||||
|
// |
||||
|
// Created by Matthew Raymer on 2025-09-22 |
||||
|
// Copyright © 2025 TimeSafari. All rights reserved. |
||||
|
// |
||||
|
|
||||
|
import Foundation |
||||
|
import BackgroundTasks |
||||
|
import UserNotifications |
||||
|
import CoreData |
||||
|
|
||||
|
/** |
||||
|
* Background task handlers for iOS Daily Notification Plugin |
||||
|
* Implements BGTaskScheduler handlers for content fetch and notification delivery |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
* @version 1.1.0 |
||||
|
* @created 2025-09-22 09:22:32 UTC |
||||
|
*/ |
||||
|
extension DailyNotificationPlugin { |
||||
|
|
||||
|
private func handleBackgroundFetch(task: BGAppRefreshTask) { |
||||
|
print("DNP-FETCH-START: Background fetch task started") |
||||
|
|
||||
|
task.expirationHandler = { |
||||
|
print("DNP-FETCH-TIMEOUT: Background fetch task expired") |
||||
|
task.setTaskCompleted(success: false) |
||||
|
} |
||||
|
|
||||
|
Task { |
||||
|
do { |
||||
|
let startTime = Date() |
||||
|
let content = try await performContentFetch() |
||||
|
|
||||
|
// Store content in Core Data |
||||
|
try await storeContent(content) |
||||
|
|
||||
|
let duration = Date().timeIntervalSince(startTime) |
||||
|
print("DNP-FETCH-SUCCESS: Content fetch completed in \(duration)s") |
||||
|
|
||||
|
// Fire callbacks |
||||
|
try await fireCallbacks(eventType: "onFetchSuccess", payload: content) |
||||
|
|
||||
|
task.setTaskCompleted(success: true) |
||||
|
|
||||
|
} catch { |
||||
|
print("DNP-FETCH-FAILURE: Content fetch failed: \(error)") |
||||
|
task.setTaskCompleted(success: false) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func handleBackgroundNotify(task: BGProcessingTask) { |
||||
|
print("DNP-NOTIFY-START: Background notify task started") |
||||
|
|
||||
|
task.expirationHandler = { |
||||
|
print("DNP-NOTIFY-TIMEOUT: Background notify task expired") |
||||
|
task.setTaskCompleted(success: false) |
||||
|
} |
||||
|
|
||||
|
Task { |
||||
|
do { |
||||
|
let startTime = Date() |
||||
|
|
||||
|
// Get latest cached content |
||||
|
guard let latestContent = try await getLatestContent() else { |
||||
|
print("DNP-NOTIFY-SKIP: No cached content available") |
||||
|
task.setTaskCompleted(success: true) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Check TTL |
||||
|
if isContentExpired(content: latestContent) { |
||||
|
print("DNP-NOTIFY-SKIP-TTL: Content TTL expired, skipping notification") |
||||
|
try await recordHistory(kind: "notify", outcome: "skipped_ttl") |
||||
|
task.setTaskCompleted(success: true) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Show notification |
||||
|
try await showNotification(content: latestContent) |
||||
|
|
||||
|
let duration = Date().timeIntervalSince(startTime) |
||||
|
print("DNP-NOTIFY-SUCCESS: Notification displayed in \(duration)s") |
||||
|
|
||||
|
// Fire callbacks |
||||
|
try await fireCallbacks(eventType: "onNotifyDelivered", payload: latestContent) |
||||
|
|
||||
|
task.setTaskCompleted(success: true) |
||||
|
|
||||
|
} catch { |
||||
|
print("DNP-NOTIFY-FAILURE: Notification failed: \(error)") |
||||
|
task.setTaskCompleted(success: false) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func performContentFetch() async throws -> [String: Any] { |
||||
|
// Mock content fetch implementation |
||||
|
// In production, this would make actual HTTP requests |
||||
|
let mockContent = [ |
||||
|
"id": "fetch_\(Date().timeIntervalSince1970)", |
||||
|
"timestamp": Date().timeIntervalSince1970, |
||||
|
"content": "Daily notification content from iOS", |
||||
|
"source": "ios_platform" |
||||
|
] as [String: Any] |
||||
|
|
||||
|
return mockContent |
||||
|
} |
||||
|
|
||||
|
private func storeContent(_ content: [String: Any]) async throws { |
||||
|
let context = persistenceController.container.viewContext |
||||
|
|
||||
|
let contentEntity = ContentCache(context: context) |
||||
|
contentEntity.id = content["id"] as? String |
||||
|
contentEntity.fetchedAt = Date(timeIntervalSince1970: content["timestamp"] as? TimeInterval ?? 0) |
||||
|
contentEntity.ttlSeconds = 3600 // 1 hour default TTL |
||||
|
contentEntity.payload = try JSONSerialization.data(withJSONObject: content) |
||||
|
contentEntity.meta = "fetched_by_ios_bg_task" |
||||
|
|
||||
|
try context.save() |
||||
|
print("DNP-CACHE-STORE: Content stored in Core Data") |
||||
|
} |
||||
|
|
||||
|
private func getLatestContent() async throws -> [String: Any]? { |
||||
|
let context = persistenceController.container.viewContext |
||||
|
let request: NSFetchRequest<ContentCache> = ContentCache.fetchRequest() |
||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \ContentCache.fetchedAt, ascending: false)] |
||||
|
request.fetchLimit = 1 |
||||
|
|
||||
|
let results = try context.fetch(request) |
||||
|
guard let latest = results.first else { return nil } |
||||
|
|
||||
|
return try JSONSerialization.jsonObject(with: latest.payload!) as? [String: Any] |
||||
|
} |
||||
|
|
||||
|
private func isContentExpired(content: [String: Any]) -> Bool { |
||||
|
guard let timestamp = content["timestamp"] as? TimeInterval else { return true } |
||||
|
let fetchedAt = Date(timeIntervalSince1970: timestamp) |
||||
|
let ttlExpiry = fetchedAt.addingTimeInterval(3600) // 1 hour TTL |
||||
|
return Date() > ttlExpiry |
||||
|
} |
||||
|
|
||||
|
private func showNotification(content: [String: Any]) async throws { |
||||
|
let notificationContent = UNMutableNotificationContent() |
||||
|
notificationContent.title = "Daily Notification" |
||||
|
notificationContent.body = content["content"] as? String ?? "Your daily update is ready" |
||||
|
notificationContent.sound = .default |
||||
|
|
||||
|
let request = UNNotificationRequest( |
||||
|
identifier: "daily-notification-\(Date().timeIntervalSince1970)", |
||||
|
content: notificationContent, |
||||
|
trigger: nil // Immediate delivery |
||||
|
) |
||||
|
|
||||
|
try await notificationCenter.add(request) |
||||
|
print("DNP-NOTIFY-DISPLAY: Notification displayed") |
||||
|
} |
||||
|
|
||||
|
private func recordHistory(kind: String, outcome: String) async throws { |
||||
|
let context = persistenceController.container.viewContext |
||||
|
|
||||
|
let history = History(context: context) |
||||
|
history.id = "\(kind)_\(Date().timeIntervalSince1970)" |
||||
|
history.kind = kind |
||||
|
history.occurredAt = Date() |
||||
|
history.outcome = outcome |
||||
|
|
||||
|
try context.save() |
||||
|
} |
||||
|
} |
@ -0,0 +1,291 @@ |
|||||
|
// |
||||
|
// DailyNotificationCallbacks.swift |
||||
|
// DailyNotificationPlugin |
||||
|
// |
||||
|
// Created by Matthew Raymer on 2025-09-22 |
||||
|
// Copyright © 2025 TimeSafari. All rights reserved. |
||||
|
// |
||||
|
|
||||
|
import Foundation |
||||
|
import CoreData |
||||
|
|
||||
|
/** |
||||
|
* Callback management for iOS Daily Notification Plugin |
||||
|
* Implements HTTP and local callback delivery with error handling |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
* @version 1.1.0 |
||||
|
* @created 2025-09-22 09:22:32 UTC |
||||
|
*/ |
||||
|
extension DailyNotificationPlugin { |
||||
|
|
||||
|
// MARK: - Callback Management |
||||
|
|
||||
|
@objc func registerCallback(_ call: CAPPluginCall) { |
||||
|
guard let name = call.getString("name"), |
||||
|
let callbackConfig = call.getObject("callback") else { |
||||
|
call.reject("Callback name and config required") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
print("DNP-PLUGIN: Registering callback: \(name)") |
||||
|
|
||||
|
do { |
||||
|
try registerCallback(name: name, config: callbackConfig) |
||||
|
call.resolve() |
||||
|
} catch { |
||||
|
print("DNP-PLUGIN: Failed to register callback: \(error)") |
||||
|
call.reject("Callback registration failed: \(error.localizedDescription)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@objc func unregisterCallback(_ call: CAPPluginCall) { |
||||
|
guard let name = call.getString("name") else { |
||||
|
call.reject("Callback name required") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
print("DNP-PLUGIN: Unregistering callback: \(name)") |
||||
|
|
||||
|
do { |
||||
|
try unregisterCallback(name: name) |
||||
|
call.resolve() |
||||
|
} catch { |
||||
|
print("DNP-PLUGIN: Failed to unregister callback: \(error)") |
||||
|
call.reject("Callback unregistration failed: \(error.localizedDescription)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@objc func getRegisteredCallbacks(_ call: CAPPluginCall) { |
||||
|
Task { |
||||
|
do { |
||||
|
let callbacks = try await getRegisteredCallbacks() |
||||
|
call.resolve(["callbacks": callbacks]) |
||||
|
} catch { |
||||
|
print("DNP-PLUGIN: Failed to get registered callbacks: \(error)") |
||||
|
call.reject("Callback retrieval failed: \(error.localizedDescription)") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - Content Management |
||||
|
|
||||
|
@objc func getContentCache(_ call: CAPPluginCall) { |
||||
|
Task { |
||||
|
do { |
||||
|
let cache = try await getContentCache() |
||||
|
call.resolve(cache) |
||||
|
} catch { |
||||
|
print("DNP-PLUGIN: Failed to get content cache: \(error)") |
||||
|
call.reject("Content cache retrieval failed: \(error.localizedDescription)") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@objc func clearContentCache(_ call: CAPPluginCall) { |
||||
|
Task { |
||||
|
do { |
||||
|
try await clearContentCache() |
||||
|
call.resolve() |
||||
|
} catch { |
||||
|
print("DNP-PLUGIN: Failed to clear content cache: \(error)") |
||||
|
call.reject("Content cache clearing failed: \(error.localizedDescription)") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@objc func getContentHistory(_ call: CAPPluginCall) { |
||||
|
Task { |
||||
|
do { |
||||
|
let history = try await getContentHistory() |
||||
|
call.resolve(["history": history]) |
||||
|
} catch { |
||||
|
print("DNP-PLUGIN: Failed to get content history: \(error)") |
||||
|
call.reject("Content history retrieval failed: \(error.localizedDescription)") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - Private Callback Implementation |
||||
|
|
||||
|
private func fireCallbacks(eventType: String, payload: [String: Any]) async throws { |
||||
|
// Get registered callbacks from Core Data |
||||
|
let context = persistenceController.container.viewContext |
||||
|
let request: NSFetchRequest<Callback> = Callback.fetchRequest() |
||||
|
request.predicate = NSPredicate(format: "enabled == YES") |
||||
|
|
||||
|
let callbacks = try context.fetch(request) |
||||
|
|
||||
|
for callback in callbacks { |
||||
|
do { |
||||
|
try await deliverCallback(callback: callback, eventType: eventType, payload: payload) |
||||
|
} catch { |
||||
|
print("DNP-CB-FAILURE: Callback \(callback.id ?? "unknown") failed: \(error)") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func deliverCallback(callback: Callback, eventType: String, payload: [String: Any]) async throws { |
||||
|
guard let callbackId = callback.id, |
||||
|
let kind = callback.kind else { return } |
||||
|
|
||||
|
let event = [ |
||||
|
"id": callbackId, |
||||
|
"at": Date().timeIntervalSince1970, |
||||
|
"type": eventType, |
||||
|
"payload": payload |
||||
|
] as [String: Any] |
||||
|
|
||||
|
switch kind { |
||||
|
case "http": |
||||
|
try await deliverHttpCallback(callback: callback, event: event) |
||||
|
case "local": |
||||
|
try await deliverLocalCallback(callback: callback, event: event) |
||||
|
default: |
||||
|
print("DNP-CB-UNKNOWN: Unknown callback kind: \(kind)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func deliverHttpCallback(callback: Callback, event: [String: Any]) async throws { |
||||
|
guard let target = callback.target, |
||||
|
let url = URL(string: target) else { |
||||
|
throw NSError(domain: "DailyNotificationPlugin", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid callback target"]) |
||||
|
} |
||||
|
|
||||
|
var request = URLRequest(url: url) |
||||
|
request.httpMethod = "POST" |
||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type") |
||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: event) |
||||
|
|
||||
|
let (_, response) = try await URLSession.shared.data(for: request) |
||||
|
|
||||
|
guard let httpResponse = response as? HTTPURLResponse, |
||||
|
httpResponse.statusCode == 200 else { |
||||
|
throw NSError(domain: "DailyNotificationPlugin", code: 2, userInfo: [NSLocalizedDescriptionKey: "HTTP callback failed"]) |
||||
|
} |
||||
|
|
||||
|
print("DNP-CB-HTTP-SUCCESS: HTTP callback delivered to \(target)") |
||||
|
} |
||||
|
|
||||
|
private func deliverLocalCallback(callback: Callback, event: [String: Any]) async throws { |
||||
|
// Local callback implementation would go here |
||||
|
print("DNP-CB-LOCAL: Local callback delivered for \(callback.id ?? "unknown")") |
||||
|
} |
||||
|
|
||||
|
private func registerCallback(name: String, config: [String: Any]) throws { |
||||
|
let context = persistenceController.container.viewContext |
||||
|
|
||||
|
let callback = Callback(context: context) |
||||
|
callback.id = name |
||||
|
callback.kind = config["kind"] as? String ?? "local" |
||||
|
callback.target = config["target"] as? String ?? "" |
||||
|
callback.enabled = true |
||||
|
callback.createdAt = Date() |
||||
|
|
||||
|
try context.save() |
||||
|
print("DNP-CB-REGISTER: Callback \(name) registered") |
||||
|
} |
||||
|
|
||||
|
private func unregisterCallback(name: String) throws { |
||||
|
let context = persistenceController.container.viewContext |
||||
|
let request: NSFetchRequest<Callback> = Callback.fetchRequest() |
||||
|
request.predicate = NSPredicate(format: "id == %@", name) |
||||
|
|
||||
|
let callbacks = try context.fetch(request) |
||||
|
for callback in callbacks { |
||||
|
context.delete(callback) |
||||
|
} |
||||
|
|
||||
|
try context.save() |
||||
|
print("DNP-CB-UNREGISTER: Callback \(name) unregistered") |
||||
|
} |
||||
|
|
||||
|
private func getRegisteredCallbacks() async throws -> [String] { |
||||
|
let context = persistenceController.container.viewContext |
||||
|
let request: NSFetchRequest<Callback> = Callback.fetchRequest() |
||||
|
|
||||
|
let callbacks = try context.fetch(request) |
||||
|
return callbacks.compactMap { $0.id } |
||||
|
} |
||||
|
|
||||
|
private func getContentCache() async throws -> [String: Any] { |
||||
|
guard let latestContent = try await getLatestContent() else { |
||||
|
return [:] |
||||
|
} |
||||
|
return latestContent |
||||
|
} |
||||
|
|
||||
|
private func clearContentCache() async throws { |
||||
|
let context = persistenceController.container.viewContext |
||||
|
let request: NSFetchRequest<ContentCache> = ContentCache.fetchRequest() |
||||
|
|
||||
|
let results = try context.fetch(request) |
||||
|
for content in results { |
||||
|
context.delete(content) |
||||
|
} |
||||
|
|
||||
|
try context.save() |
||||
|
print("DNP-CACHE-CLEAR: Content cache cleared") |
||||
|
} |
||||
|
|
||||
|
private func getContentHistory() async throws -> [[String: Any]] { |
||||
|
let context = persistenceController.container.viewContext |
||||
|
let request: NSFetchRequest<History> = History.fetchRequest() |
||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \History.occurredAt, ascending: false)] |
||||
|
request.fetchLimit = 100 |
||||
|
|
||||
|
let results = try context.fetch(request) |
||||
|
return results.map { history in |
||||
|
[ |
||||
|
"id": history.id ?? "", |
||||
|
"kind": history.kind ?? "", |
||||
|
"occurredAt": history.occurredAt?.timeIntervalSince1970 ?? 0, |
||||
|
"outcome": history.outcome ?? "", |
||||
|
"durationMs": history.durationMs |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func getHealthStatus() async throws -> [String: Any] { |
||||
|
let context = persistenceController.container.viewContext |
||||
|
|
||||
|
// Get next runs (simplified) |
||||
|
let nextRuns = [Date().addingTimeInterval(3600).timeIntervalSince1970, |
||||
|
Date().addingTimeInterval(86400).timeIntervalSince1970] |
||||
|
|
||||
|
// Get recent history |
||||
|
let historyRequest: NSFetchRequest<History> = History.fetchRequest() |
||||
|
historyRequest.predicate = NSPredicate(format: "occurredAt >= %@", Date().addingTimeInterval(-86400) as NSDate) |
||||
|
historyRequest.sortDescriptors = [NSSortDescriptor(keyPath: \History.occurredAt, ascending: false)] |
||||
|
historyRequest.fetchLimit = 10 |
||||
|
|
||||
|
let recentHistory = try context.fetch(historyRequest) |
||||
|
let lastOutcomes = recentHistory.map { $0.outcome ?? "" } |
||||
|
|
||||
|
// Get cache age |
||||
|
let cacheRequest: NSFetchRequest<ContentCache> = ContentCache.fetchRequest() |
||||
|
cacheRequest.sortDescriptors = [NSSortDescriptor(keyPath: \ContentCache.fetchedAt, ascending: false)] |
||||
|
cacheRequest.fetchLimit = 1 |
||||
|
|
||||
|
let latestCache = try context.fetch(cacheRequest).first |
||||
|
let cacheAgeMs = latestCache?.fetchedAt?.timeIntervalSinceNow ?? 0 |
||||
|
|
||||
|
return [ |
||||
|
"nextRuns": nextRuns, |
||||
|
"lastOutcomes": lastOutcomes, |
||||
|
"cacheAgeMs": abs(cacheAgeMs * 1000), |
||||
|
"staleArmed": abs(cacheAgeMs) > 3600, |
||||
|
"queueDepth": recentHistory.count, |
||||
|
"circuitBreakers": [ |
||||
|
"total": 0, |
||||
|
"open": 0, |
||||
|
"failures": 0 |
||||
|
], |
||||
|
"performance": [ |
||||
|
"avgFetchTime": 0, |
||||
|
"avgNotifyTime": 0, |
||||
|
"successRate": 1.0 |
||||
|
] |
||||
|
] |
||||
|
} |
||||
|
} |
@ -0,0 +1,139 @@ |
|||||
|
// |
||||
|
// DailyNotificationModel.xcdatamodeld |
||||
|
// DailyNotificationPlugin |
||||
|
// |
||||
|
// Created by Matthew Raymer on 2025-09-22 |
||||
|
// Copyright © 2025 TimeSafari. All rights reserved. |
||||
|
// |
||||
|
|
||||
|
import Foundation |
||||
|
import CoreData |
||||
|
|
||||
|
/** |
||||
|
* Core Data model for Daily Notification Plugin |
||||
|
* Mirrors Android SQLite schema for cross-platform consistency |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
* @version 1.1.0 |
||||
|
* @created 2025-09-22 09:22:32 UTC |
||||
|
*/ |
||||
|
|
||||
|
// MARK: - ContentCache Entity |
||||
|
@objc(ContentCache) |
||||
|
public class ContentCache: NSManagedObject { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
extension ContentCache { |
||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<ContentCache> { |
||||
|
return NSFetchRequest<ContentCache>(entityName: "ContentCache") |
||||
|
} |
||||
|
|
||||
|
@NSManaged public var id: String? |
||||
|
@NSManaged public var fetchedAt: Date? |
||||
|
@NSManaged public var ttlSeconds: Int32 |
||||
|
@NSManaged public var payload: Data? |
||||
|
@NSManaged public var meta: String? |
||||
|
} |
||||
|
|
||||
|
extension ContentCache: Identifiable { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
// MARK: - Schedule Entity |
||||
|
@objc(Schedule) |
||||
|
public class Schedule: NSManagedObject { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
extension Schedule { |
||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Schedule> { |
||||
|
return NSFetchRequest<Schedule>(entityName: "Schedule") |
||||
|
} |
||||
|
|
||||
|
@NSManaged public var id: String? |
||||
|
@NSManaged public var kind: String? |
||||
|
@NSManaged public var cron: String? |
||||
|
@NSManaged public var clockTime: String? |
||||
|
@NSManaged public var enabled: Bool |
||||
|
@NSManaged public var lastRunAt: Date? |
||||
|
@NSManaged public var nextRunAt: Date? |
||||
|
@NSManaged public var jitterMs: Int32 |
||||
|
@NSManaged public var backoffPolicy: String? |
||||
|
@NSManaged public var stateJson: String? |
||||
|
} |
||||
|
|
||||
|
extension Schedule: Identifiable { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
// MARK: - Callback Entity |
||||
|
@objc(Callback) |
||||
|
public class Callback: NSManagedObject { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
extension Callback { |
||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Callback> { |
||||
|
return NSFetchRequest<Callback>(entityName: "Callback") |
||||
|
} |
||||
|
|
||||
|
@NSManaged public var id: String? |
||||
|
@NSManaged public var kind: String? |
||||
|
@NSManaged public var target: String? |
||||
|
@NSManaged public var headersJson: String? |
||||
|
@NSManaged public var enabled: Bool |
||||
|
@NSManaged public var createdAt: Date? |
||||
|
} |
||||
|
|
||||
|
extension Callback: Identifiable { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
// MARK: - History Entity |
||||
|
@objc(History) |
||||
|
public class History: NSManagedObject { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
extension History { |
||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<History> { |
||||
|
return NSFetchRequest<History>(entityName: "History") |
||||
|
} |
||||
|
|
||||
|
@NSManaged public var id: String? |
||||
|
@NSManaged public var refId: String? |
||||
|
@NSManaged public var kind: String? |
||||
|
@NSManaged public var occurredAt: Date? |
||||
|
@NSManaged public var durationMs: Int32 |
||||
|
@NSManaged public var outcome: String? |
||||
|
@NSManaged public var diagJson: String? |
||||
|
} |
||||
|
|
||||
|
extension History: Identifiable { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
// MARK: - Persistence Controller |
||||
|
class PersistenceController { |
||||
|
static let shared = PersistenceController() |
||||
|
|
||||
|
let container: NSPersistentContainer |
||||
|
|
||||
|
init(inMemory: Bool = false) { |
||||
|
container = NSPersistentContainer(name: "DailyNotificationModel") |
||||
|
|
||||
|
if inMemory { |
||||
|
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") |
||||
|
} |
||||
|
|
||||
|
container.loadPersistentStores { _, error in |
||||
|
if let error = error as NSError? { |
||||
|
fatalError("Core Data error: \(error), \(error.userInfo)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
container.viewContext.automaticallyMergesChangesFromParent = true |
||||
|
} |
||||
|
} |
||||
|
|
@ -0,0 +1,39 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
||||
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22G120" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> |
||||
|
<entity name="Callback" representedClassName="Callback" syncable="YES" codeGenerationType="class"> |
||||
|
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
||||
|
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
||||
|
<attribute name="headersJson" optional="YES" attributeType="String"/> |
||||
|
<attribute name="id" optional="YES" attributeType="String"/> |
||||
|
<attribute name="kind" optional="YES" attributeType="String"/> |
||||
|
<attribute name="target" optional="YES" attributeType="String"/> |
||||
|
</entity> |
||||
|
<entity name="ContentCache" representedClassName="ContentCache" syncable="YES" codeGenerationType="class"> |
||||
|
<attribute name="fetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
||||
|
<attribute name="id" optional="YES" attributeType="String"/> |
||||
|
<attribute name="meta" optional="YES" attributeType="String"/> |
||||
|
<attribute name="payload" optional="YES" attributeType="Binary"/> |
||||
|
<attribute name="ttlSeconds" optional="YES" attributeType="Integer 32" defaultValueString="3600" usesScalarValueType="YES"/> |
||||
|
</entity> |
||||
|
<entity name="History" representedClassName="History" syncable="YES" codeGenerationType="class"> |
||||
|
<attribute name="diagJson" optional="YES" attributeType="String"/> |
||||
|
<attribute name="durationMs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> |
||||
|
<attribute name="id" optional="YES" attributeType="String"/> |
||||
|
<attribute name="kind" optional="YES" attributeType="String"/> |
||||
|
<attribute name="occurredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
||||
|
<attribute name="outcome" optional="YES" attributeType="String"/> |
||||
|
<attribute name="refId" optional="YES" attributeType="String"/> |
||||
|
</entity> |
||||
|
<entity name="Schedule" representedClassName="Schedule" syncable="YES" codeGenerationType="class"> |
||||
|
<attribute name="backoffPolicy" optional="YES" attributeType="String" defaultValueString="exp"/> |
||||
|
<attribute name="clockTime" optional="YES" attributeType="String"/> |
||||
|
<attribute name="cron" optional="YES" attributeType="String"/> |
||||
|
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
||||
|
<attribute name="id" optional="YES" attributeType="String"/> |
||||
|
<attribute name="jitterMs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> |
||||
|
<attribute name="kind" optional="YES" attributeType="String"/> |
||||
|
<attribute name="lastRunAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
||||
|
<attribute name="nextRunAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
||||
|
<attribute name="stateJson" optional="YES" attributeType="String"/> |
||||
|
</entity> |
||||
|
</model> |
@ -1,260 +1,179 @@ |
|||||
/** |
// |
||||
* DailyNotificationPlugin.swift |
// DailyNotificationPlugin.swift |
||||
* |
// DailyNotificationPlugin |
||||
* Main iOS plugin class for handling daily notifications |
// |
||||
* |
// Created by Matthew Raymer on 2025-09-22 |
||||
* @author Matthew Raymer |
// Copyright © 2025 TimeSafari. All rights reserved. |
||||
* @version 1.0.0 |
// |
||||
*/ |
|
||||
|
|
||||
import Foundation |
import Foundation |
||||
import Capacitor |
import Capacitor |
||||
import BackgroundTasks |
|
||||
import UserNotifications |
import UserNotifications |
||||
|
import BackgroundTasks |
||||
|
import CoreData |
||||
|
|
||||
|
/** |
||||
|
* iOS implementation of Daily Notification Plugin |
||||
|
* Implements BGTaskScheduler + UNUserNotificationCenter for dual scheduling |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
* @version 1.1.0 |
||||
|
* @created 2025-09-22 09:22:32 UTC |
||||
|
*/ |
||||
@objc(DailyNotificationPlugin) |
@objc(DailyNotificationPlugin) |
||||
public class DailyNotificationPlugin: CAPPlugin { |
public class DailyNotificationPlugin: CAPPlugin { |
||||
|
|
||||
private static let TAG = "DailyNotificationPlugin" |
private let notificationCenter = UNUserNotificationCenter.current() |
||||
|
private let backgroundTaskScheduler = BGTaskScheduler.shared |
||||
|
private let persistenceController = PersistenceController.shared |
||||
|
|
||||
private var database: DailyNotificationDatabase? |
// Background task identifiers |
||||
private var ttlEnforcer: DailyNotificationTTLEnforcer? |
private let fetchTaskIdentifier = "com.timesafari.dailynotification.fetch" |
||||
private var rollingWindow: DailyNotificationRollingWindow? |
private let notifyTaskIdentifier = "com.timesafari.dailynotification.notify" |
||||
private var backgroundTaskManager: DailyNotificationBackgroundTaskManager? |
|
||||
|
|
||||
private var useSharedStorage: Bool = false |
override public func load() { |
||||
private var databasePath: String? |
|
||||
private var ttlSeconds: TimeInterval = 3600 |
|
||||
private var prefetchLeadMinutes: Int = 15 |
|
||||
|
|
||||
public override func load() { |
|
||||
super.load() |
super.load() |
||||
print("\(Self.TAG): DailyNotificationPlugin loading") |
setupBackgroundTasks() |
||||
initializeComponents() |
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS") |
||||
|
|
||||
if #available(iOS 13.0, *) { |
|
||||
backgroundTaskManager?.registerBackgroundTask() |
|
||||
} |
|
||||
|
|
||||
print("\(Self.TAG): DailyNotificationPlugin loaded successfully") |
|
||||
} |
|
||||
|
|
||||
private func initializeComponents() { |
|
||||
if useSharedStorage, let databasePath = databasePath { |
|
||||
database = DailyNotificationDatabase(path: databasePath) |
|
||||
} |
} |
||||
|
|
||||
ttlEnforcer = DailyNotificationTTLEnforcer(database: database, useSharedStorage: useSharedStorage) |
// MARK: - Configuration Methods |
||||
|
|
||||
rollingWindow = DailyNotificationRollingWindow(ttlEnforcer: ttlEnforcer!, |
|
||||
database: database, |
|
||||
useSharedStorage: useSharedStorage) |
|
||||
|
|
||||
if #available(iOS 13.0, *) { |
|
||||
backgroundTaskManager = DailyNotificationBackgroundTaskManager(database: database, |
|
||||
ttlEnforcer: ttlEnforcer!, |
|
||||
rollingWindow: rollingWindow!) |
|
||||
} |
|
||||
|
|
||||
print("\(Self.TAG): All components initialized successfully") |
|
||||
} |
|
||||
|
|
||||
@objc func configure(_ call: CAPPluginCall) { |
@objc func configure(_ call: CAPPluginCall) { |
||||
print("\(Self.TAG): Configuring plugin") |
guard let options = call.getObject("options") else { |
||||
|
call.reject("Configuration options required") |
||||
if let dbPath = call.getString("dbPath") { |
return |
||||
databasePath = dbPath |
|
||||
} |
} |
||||
|
|
||||
if let storage = call.getString("storage") { |
print("DNP-PLUGIN: Configure called with options: \(options)") |
||||
useSharedStorage = (storage == "shared") |
|
||||
} |
|
||||
|
|
||||
if let ttl = call.getDouble("ttlSeconds") { |
// Store configuration in UserDefaults |
||||
ttlSeconds = ttl |
UserDefaults.standard.set(options, forKey: "DailyNotificationConfig") |
||||
} |
|
||||
|
|
||||
if let leadMinutes = call.getInt("prefetchLeadMinutes") { |
|
||||
prefetchLeadMinutes = leadMinutes |
|
||||
} |
|
||||
|
|
||||
storeConfiguration() |
|
||||
initializeComponents() |
|
||||
call.resolve() |
call.resolve() |
||||
} |
} |
||||
|
|
||||
private func storeConfiguration() { |
// MARK: - Dual Scheduling Methods |
||||
if useSharedStorage, let database = database { |
|
||||
// Store in SQLite |
|
||||
print("\(Self.TAG): Storing configuration in SQLite") |
|
||||
} else { |
|
||||
// Store in UserDefaults |
|
||||
UserDefaults.standard.set(databasePath, forKey: "databasePath") |
|
||||
UserDefaults.standard.set(useSharedStorage, forKey: "useSharedStorage") |
|
||||
UserDefaults.standard.set(ttlSeconds, forKey: "ttlSeconds") |
|
||||
UserDefaults.standard.set(prefetchLeadMinutes, forKey: "prefetchLeadMinutes") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@objc func maintainRollingWindow(_ call: CAPPluginCall) { |
|
||||
print("\(Self.TAG): Manual rolling window maintenance requested") |
|
||||
|
|
||||
if let rollingWindow = rollingWindow { |
@objc func scheduleContentFetch(_ call: CAPPluginCall) { |
||||
rollingWindow.forceMaintenance() |
guard let config = call.getObject("config") else { |
||||
call.resolve() |
call.reject("Content fetch config required") |
||||
} else { |
return |
||||
call.reject("Rolling window not initialized") |
|
||||
} |
|
||||
} |
} |
||||
|
|
||||
@objc func getRollingWindowStats(_ call: CAPPluginCall) { |
print("DNP-PLUGIN: Scheduling content fetch") |
||||
print("\(Self.TAG): Rolling window stats requested") |
|
||||
|
|
||||
if let rollingWindow = rollingWindow { |
|
||||
let stats = rollingWindow.getRollingWindowStats() |
|
||||
let result = [ |
|
||||
"stats": stats, |
|
||||
"maintenanceNeeded": rollingWindow.isMaintenanceNeeded(), |
|
||||
"timeUntilNextMaintenance": rollingWindow.getTimeUntilNextMaintenance() |
|
||||
] as [String : Any] |
|
||||
|
|
||||
call.resolve(result) |
do { |
||||
} else { |
try scheduleBackgroundFetch(config: config) |
||||
call.reject("Rolling window not initialized") |
call.resolve() |
||||
|
} catch { |
||||
|
print("DNP-PLUGIN: Failed to schedule content fetch: \(error)") |
||||
|
call.reject("Content fetch scheduling failed: \(error.localizedDescription)") |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
@objc func scheduleBackgroundTask(_ call: CAPPluginCall) { |
@objc func scheduleUserNotification(_ call: CAPPluginCall) { |
||||
print("\(Self.TAG): Scheduling background task") |
guard let config = call.getObject("config") else { |
||||
|
call.reject("User notification config required") |
||||
guard let scheduledTimeString = call.getString("scheduledTime") else { |
|
||||
call.reject("scheduledTime parameter is required") |
|
||||
return |
return |
||||
} |
} |
||||
|
|
||||
let formatter = DateFormatter() |
print("DNP-PLUGIN: Scheduling user notification") |
||||
formatter.dateFormat = "HH:mm" |
|
||||
guard let scheduledTime = formatter.date(from: scheduledTimeString) else { |
|
||||
call.reject("Invalid scheduledTime format") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if #available(iOS 13.0, *) { |
do { |
||||
backgroundTaskManager?.scheduleBackgroundTask(scheduledTime: scheduledTime, |
try scheduleUserNotification(config: config) |
||||
prefetchLeadMinutes: prefetchLeadMinutes) |
|
||||
call.resolve() |
call.resolve() |
||||
} else { |
} catch { |
||||
call.reject("Background tasks not available on this iOS version") |
print("DNP-PLUGIN: Failed to schedule user notification: \(error)") |
||||
|
call.reject("User notification scheduling failed: \(error.localizedDescription)") |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
@objc func getBackgroundTaskStatus(_ call: CAPPluginCall) { |
@objc func scheduleDualNotification(_ call: CAPPluginCall) { |
||||
print("\(Self.TAG): Background task status requested") |
guard let config = call.getObject("config"), |
||||
|
let contentFetchConfig = config["contentFetch"] as? [String: Any], |
||||
if #available(iOS 13.0, *) { |
let userNotificationConfig = config["userNotification"] as? [String: Any] else { |
||||
let status = backgroundTaskManager?.getBackgroundTaskStatus() ?? [:] |
call.reject("Dual notification config required") |
||||
call.resolve(status) |
return |
||||
} else { |
|
||||
call.resolve(["available": false, "reason": "iOS version not supported"]) |
|
||||
} |
|
||||
} |
} |
||||
|
|
||||
@objc func cancelAllBackgroundTasks(_ call: CAPPluginCall) { |
print("DNP-PLUGIN: Scheduling dual notification") |
||||
print("\(Self.TAG): Cancelling all background tasks") |
|
||||
|
|
||||
if #available(iOS 13.0, *) { |
do { |
||||
backgroundTaskManager?.cancelAllBackgroundTasks() |
try scheduleBackgroundFetch(config: contentFetchConfig) |
||||
|
try scheduleUserNotification(config: userNotificationConfig) |
||||
call.resolve() |
call.resolve() |
||||
} else { |
} catch { |
||||
call.reject("Background tasks not available on this iOS version") |
print("DNP-PLUGIN: Failed to schedule dual notification: \(error)") |
||||
|
call.reject("Dual notification scheduling failed: \(error.localizedDescription)") |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
@objc func getTTLViolationStats(_ call: CAPPluginCall) { |
@objc func getDualScheduleStatus(_ call: CAPPluginCall) { |
||||
print("\(Self.TAG): TTL violation stats requested") |
Task { |
||||
|
do { |
||||
if let ttlEnforcer = ttlEnforcer { |
let status = try await getHealthStatus() |
||||
let stats = ttlEnforcer.getTTLViolationStats() |
call.resolve(status) |
||||
call.resolve(["stats": stats]) |
} catch { |
||||
} else { |
print("DNP-PLUGIN: Failed to get dual schedule status: \(error)") |
||||
call.reject("TTL enforcer not initialized") |
call.reject("Status retrieval failed: \(error.localizedDescription)") |
||||
|
} |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
@objc func scheduleDailyNotification(_ call: CAPPluginCall) { |
// MARK: - Private Implementation Methods |
||||
print("\(Self.TAG): Scheduling daily notification") |
|
||||
|
|
||||
guard let time = call.getString("time") else { |
private func setupBackgroundTasks() { |
||||
call.reject("Time parameter is required") |
// Register background fetch task |
||||
return |
backgroundTaskScheduler.register(forTaskWithIdentifier: fetchTaskIdentifier, using: nil) { task in |
||||
|
self.handleBackgroundFetch(task: task as! BGAppRefreshTask) |
||||
} |
} |
||||
|
|
||||
let formatter = DateFormatter() |
// Register background processing task |
||||
formatter.dateFormat = "HH:mm" |
backgroundTaskScheduler.register(forTaskWithIdentifier: notifyTaskIdentifier, using: nil) { task in |
||||
guard let scheduledTime = formatter.date(from: time) else { |
self.handleBackgroundNotify(task: task as! BGProcessingTask) |
||||
call.reject("Invalid time format") |
|
||||
return |
|
||||
} |
} |
||||
|
|
||||
let notification = NotificationContent( |
|
||||
id: UUID().uuidString, |
|
||||
title: call.getString("title") ?? "Daily Update", |
|
||||
body: call.getString("body") ?? "Your daily notification is ready", |
|
||||
scheduledTime: scheduledTime.timeIntervalSince1970 * 1000, |
|
||||
fetchedAt: Date().timeIntervalSince1970 * 1000, |
|
||||
url: call.getString("url"), |
|
||||
payload: nil, |
|
||||
etag: nil |
|
||||
) |
|
||||
|
|
||||
if let ttlEnforcer = ttlEnforcer, !ttlEnforcer.validateBeforeArming(notification) { |
|
||||
call.reject("Notification content violates TTL") |
|
||||
return |
|
||||
} |
} |
||||
|
|
||||
scheduleNotification(notification) |
private func scheduleBackgroundFetch(config: [String: Any]) throws { |
||||
|
let request = BGAppRefreshTaskRequest(identifier: fetchTaskIdentifier) |
||||
|
|
||||
if #available(iOS 13.0, *) { |
// Calculate next run time (simplified - would use proper cron parsing in production) |
||||
backgroundTaskManager?.scheduleBackgroundTask(scheduledTime: scheduledTime, |
let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *") |
||||
prefetchLeadMinutes: prefetchLeadMinutes) |
request.earliestBeginDate = Date(timeIntervalSinceNow: nextRunTime) |
||||
} |
|
||||
|
|
||||
call.resolve() |
try backgroundTaskScheduler.submit(request) |
||||
|
print("DNP-FETCH-SCHEDULE: Background fetch scheduled for \(request.earliestBeginDate!)") |
||||
} |
} |
||||
|
|
||||
private func scheduleNotification(_ notification: NotificationContent) { |
private func scheduleUserNotification(config: [String: Any]) throws { |
||||
let content = UNMutableNotificationContent() |
let content = UNMutableNotificationContent() |
||||
content.title = notification.title ?? "Daily Notification" |
content.title = config["title"] as? String ?? "Daily Notification" |
||||
content.body = notification.body ?? "Your daily notification is ready" |
content.body = config["body"] as? String ?? "Your daily update is ready" |
||||
content.sound = UNNotificationSound.default |
content.sound = (config["sound"] as? Bool ?? true) ? .default : nil |
||||
|
|
||||
let scheduledTime = Date(timeIntervalSince1970: notification.scheduledTime / 1000) |
// Create trigger (simplified - would use proper cron parsing in production) |
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: scheduledTime), repeats: false) |
let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *") |
||||
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: nextRunTime, repeats: false) |
||||
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger) |
|
||||
|
let request = UNNotificationRequest( |
||||
|
identifier: "daily-notification-\(Date().timeIntervalSince1970)", |
||||
|
content: content, |
||||
|
trigger: trigger |
||||
|
) |
||||
|
|
||||
UNUserNotificationCenter.current().add(request) { error in |
notificationCenter.add(request) { error in |
||||
if let error = error { |
if let error = error { |
||||
print("\(Self.TAG): Failed to schedule notification \(notification.id): \(error)") |
print("DNP-NOTIFY-SCHEDULE: Failed to schedule notification: \(error)") |
||||
} else { |
} else { |
||||
print("\(Self.TAG): Successfully scheduled notification: \(notification.id)") |
print("DNP-NOTIFY-SCHEDULE: Notification scheduled successfully") |
||||
} |
} |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
@objc func getLastNotification(_ call: CAPPluginCall) { |
private func calculateNextRunTime(from schedule: String) -> TimeInterval { |
||||
let result = [ |
// Simplified implementation - would use proper cron parsing in production |
||||
"id": "placeholder", |
// For now, return next day at 9 AM |
||||
"title": "Last Notification", |
return 86400 // 24 hours |
||||
"body": "This is a placeholder", |
|
||||
"timestamp": Date().timeIntervalSince1970 * 1000 |
|
||||
] as [String : Any] |
|
||||
|
|
||||
call.resolve(result) |
|
||||
} |
|
||||
|
|
||||
@objc func cancelAllNotifications(_ call: CAPPluginCall) { |
|
||||
UNUserNotificationCenter.current().removeAllPendingNotificationRequests() |
|
||||
call.resolve() |
|
||||
} |
} |
||||
} |
} |
@ -0,0 +1,146 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<!-- Background Task Identifiers --> |
||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key> |
||||
|
<array> |
||||
|
<string>com.timesafari.dailynotification.fetch</string> |
||||
|
<string>com.timesafari.dailynotification.notify</string> |
||||
|
</array> |
||||
|
|
||||
|
<!-- Background Modes --> |
||||
|
<key>UIBackgroundModes</key> |
||||
|
<array> |
||||
|
<string>background-fetch</string> |
||||
|
<string>background-processing</string> |
||||
|
<string>remote-notification</string> |
||||
|
</array> |
||||
|
|
||||
|
<!-- Notification Permissions --> |
||||
|
<key>NSUserNotificationUsageDescription</key> |
||||
|
<string>This app uses notifications to deliver daily updates and reminders.</string> |
||||
|
|
||||
|
<!-- Core Data Model --> |
||||
|
<key>CoreDataModelName</key> |
||||
|
<string>DailyNotificationModel</string> |
||||
|
|
||||
|
<!-- App Transport Security --> |
||||
|
<key>NSAppTransportSecurity</key> |
||||
|
<dict> |
||||
|
<key>NSAllowsArbitraryLoads</key> |
||||
|
<false/> |
||||
|
<key>NSExceptionDomains</key> |
||||
|
<dict> |
||||
|
<!-- Add your callback domains here --> |
||||
|
</dict> |
||||
|
</dict> |
||||
|
|
||||
|
<!-- Background App Refresh --> |
||||
|
<key>UIRequiredDeviceCapabilities</key> |
||||
|
<array> |
||||
|
<string>armv7</string> |
||||
|
</array> |
||||
|
|
||||
|
<!-- Minimum iOS Version --> |
||||
|
<key>LSMinimumSystemVersion</key> |
||||
|
<string>13.0</string> |
||||
|
|
||||
|
<!-- App Display Name --> |
||||
|
<key>CFBundleDisplayName</key> |
||||
|
<string>Daily Notification Plugin</string> |
||||
|
|
||||
|
<!-- Bundle Identifier --> |
||||
|
<key>CFBundleIdentifier</key> |
||||
|
<string>com.timesafari.dailynotification</string> |
||||
|
|
||||
|
<!-- Version --> |
||||
|
<key>CFBundleShortVersionString</key> |
||||
|
<string>1.1.0</string> |
||||
|
|
||||
|
<!-- Build Number --> |
||||
|
<key>CFBundleVersion</key> |
||||
|
<string>1</string> |
||||
|
|
||||
|
<!-- Supported Interface Orientations --> |
||||
|
<key>UISupportedInterfaceOrientations</key> |
||||
|
<array> |
||||
|
<string>UIInterfaceOrientationPortrait</string> |
||||
|
<string>UIInterfaceOrientationLandscapeLeft</string> |
||||
|
<string>UIInterfaceOrientationLandscapeRight</string> |
||||
|
</array> |
||||
|
|
||||
|
<!-- Supported Interface Orientations (iPad) --> |
||||
|
<key>UISupportedInterfaceOrientations~ipad</key> |
||||
|
<array> |
||||
|
<string>UIInterfaceOrientationPortrait</string> |
||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string> |
||||
|
<string>UIInterfaceOrientationLandscapeLeft</string> |
||||
|
<string>UIInterfaceOrientationLandscapeRight</string> |
||||
|
</array> |
||||
|
|
||||
|
<!-- Launch Screen --> |
||||
|
<key>UILaunchStoryboardName</key> |
||||
|
<string>LaunchScreen</string> |
||||
|
|
||||
|
<!-- Main Storyboard --> |
||||
|
<key>UIMainStoryboardFile</key> |
||||
|
<string>Main</string> |
||||
|
|
||||
|
<!-- Status Bar Style --> |
||||
|
<key>UIStatusBarStyle</key> |
||||
|
<string>UIStatusBarStyleDefault</string> |
||||
|
|
||||
|
<!-- Status Bar Hidden --> |
||||
|
<key>UIStatusBarHidden</key> |
||||
|
<false/> |
||||
|
|
||||
|
<!-- Device Family --> |
||||
|
<key>UIDeviceFamily</key> |
||||
|
<array> |
||||
|
<integer>1</integer> |
||||
|
<integer>2</integer> |
||||
|
</array> |
||||
|
|
||||
|
<!-- Privacy Usage Descriptions --> |
||||
|
<key>NSUserNotificationsUsageDescription</key> |
||||
|
<string>This app uses notifications to deliver daily updates and reminders.</string> |
||||
|
|
||||
|
<key>NSLocationWhenInUseUsageDescription</key> |
||||
|
<string>This app may use location to provide location-based notifications.</string> |
||||
|
|
||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key> |
||||
|
<string>This app may use location to provide location-based notifications.</string> |
||||
|
|
||||
|
<!-- Network Usage --> |
||||
|
<key>NSNetworkVolumesUsageDescription</key> |
||||
|
<string>This app uses network to fetch daily content and deliver callbacks.</string> |
||||
|
|
||||
|
<!-- Background App Refresh --> |
||||
|
<key>UIApplicationExitsOnSuspend</key> |
||||
|
<false/> |
||||
|
|
||||
|
<!-- Background Processing --> |
||||
|
<key>UIApplicationSupportsIndirectInputEvents</key> |
||||
|
<true/> |
||||
|
|
||||
|
<!-- Scene Configuration --> |
||||
|
<key>UIApplicationSceneManifest</key> |
||||
|
<dict> |
||||
|
<key>UIApplicationSupportsMultipleScenes</key> |
||||
|
<false/> |
||||
|
<key>UISceneConfigurations</key> |
||||
|
<dict> |
||||
|
<key>UIWindowSceneSessionRoleApplication</key> |
||||
|
<array> |
||||
|
<dict> |
||||
|
<key>UISceneConfigurationName</key> |
||||
|
<string>Default Configuration</string> |
||||
|
<key>UISceneDelegateClassName</key> |
||||
|
<string>SceneDelegate</string> |
||||
|
</dict> |
||||
|
</array> |
||||
|
</dict> |
||||
|
</dict> |
||||
|
</dict> |
||||
|
</plist> |
Loading…
Reference in new issue