Fixed iOS 13.0 compatibility issue in test harness by replacing Logger (iOS 14+) with os_log (iOS 13+). Fixed build script to correctly detect and sync Capacitor config from App subdirectory. Unified both Android and iOS test app UIs to use www/index.html as the canonical source. Changes: - DailyNotificationBackgroundTaskTestHarness: Replace Logger with os_log for iOS 13.0 deployment target compatibility - build-ios-test-app.sh: Fix Capacitor sync path detection to check both current directory and App/ subdirectory for config files - test-apps: Update both Android and iOS test apps to use www/index.html as the canonical UI source for consistency This ensures the plugin builds on iOS 13.0+ and both test apps provide the same testing experience across platforms.
365 lines
14 KiB
Swift
365 lines
14 KiB
Swift
//
|
|
// DailyNotificationBackgroundTaskTestHarness.swift
|
|
// DailyNotificationPlugin
|
|
//
|
|
// Test harness for BGTaskScheduler prefetch functionality
|
|
// Reference implementation demonstrating task registration, scheduling, and handling
|
|
//
|
|
// See: doc/test-app-ios/IOS_PREFETCH_TESTING.md for testing procedures
|
|
//
|
|
|
|
import Foundation
|
|
import BackgroundTasks
|
|
import UIKit
|
|
import os.log
|
|
|
|
/// Minimal BGTaskScheduler test harness for DailyNotificationPlugin prefetch testing
|
|
///
|
|
/// This is a reference implementation demonstrating:
|
|
/// - Task registration
|
|
/// - Task scheduling
|
|
/// - Task handler implementation
|
|
/// - Expiration handling
|
|
/// - Completion reporting
|
|
///
|
|
/// **Usage:**
|
|
/// - Reference this when implementing actual prefetch logic in `DailyNotificationBackgroundTaskManager.swift`
|
|
/// - Use in test app for debugging BGTaskScheduler behavior
|
|
/// - See `doc/test-app-ios/IOS_PREFETCH_TESTING.md` for comprehensive testing guide
|
|
///
|
|
/// **Info.plist Requirements:**
|
|
/// ```xml
|
|
/// <key>BGTaskSchedulerPermittedIdentifiers</key>
|
|
/// <array>
|
|
/// <string>com.timesafari.dailynotification.fetch</string>
|
|
/// </array>
|
|
/// ```
|
|
///
|
|
/// **Background Modes (Xcode Capabilities):**
|
|
/// - Background fetch
|
|
/// - Background processing (if using BGProcessingTask)
|
|
class DailyNotificationBackgroundTaskTestHarness {
|
|
|
|
// MARK: - Constants
|
|
|
|
static let prefetchTaskIdentifier = "com.timesafari.dailynotification.fetch"
|
|
|
|
// MARK: - Structured Logging
|
|
|
|
/// OSLog categories for structured logging (iOS 13.0+ compatible)
|
|
private static let pluginLog = OSLog(subsystem: "com.timesafari.dailynotification", category: "plugin")
|
|
private static let fetchLog = OSLog(subsystem: "com.timesafari.dailynotification", category: "fetch")
|
|
private static let schedulerLog = OSLog(subsystem: "com.timesafari.dailynotification", category: "scheduler")
|
|
private static let storageLog = OSLog(subsystem: "com.timesafari.dailynotification", category: "storage")
|
|
|
|
/// Log telemetry snapshot for validation
|
|
static func logTelemetrySnapshot(prefix: String = "DNP-") {
|
|
// Phase 2: Capture telemetry counters from structured logs
|
|
os_log("[DNP-FETCH] Telemetry snapshot: %{public}@prefetch_scheduled_total, %{public}@prefetch_executed_total, %{public}@prefetch_success_total", log: fetchLog, type: .info, prefix, prefix, prefix)
|
|
}
|
|
|
|
// MARK: - Registration
|
|
|
|
/// Register BGTaskScheduler task handler
|
|
///
|
|
/// Call this in AppDelegate.application(_:didFinishLaunchingWithOptions:)
|
|
/// before app finishes launching.
|
|
static func registerBackgroundTasks() {
|
|
BGTaskScheduler.shared.register(
|
|
forTaskWithIdentifier: prefetchTaskIdentifier,
|
|
using: nil
|
|
) { task in
|
|
// This closure is called when the task is launched by the system
|
|
handlePrefetchTask(task: task as! BGAppRefreshTask)
|
|
}
|
|
|
|
print("[DNP-FETCH] Registered BGTaskScheduler with id=\(prefetchTaskIdentifier)")
|
|
}
|
|
|
|
// MARK: - Scheduling
|
|
|
|
/// Schedule a BGAppRefreshTask for prefetch
|
|
///
|
|
/// **Validation:**
|
|
/// - Ensures earliestBeginDate is at least 60 seconds in future (iOS requirement)
|
|
/// - Cancels any existing pending task for this notification (one active task rule)
|
|
/// - Handles simulator limitations gracefully (Code=1 error is expected)
|
|
///
|
|
/// - Parameter earliestOffsetSeconds: Seconds from now when task can begin
|
|
/// - Parameter notificationId: Optional notification ID to cancel existing task
|
|
/// - Returns: true if scheduling succeeded, false otherwise
|
|
@discardableResult
|
|
static func schedulePrefetchTask(earliestOffsetSeconds: TimeInterval, notificationId: String? = nil) -> Bool {
|
|
// Validate minimum lead time (iOS requires at least 60 seconds)
|
|
guard earliestOffsetSeconds >= 60 else {
|
|
print("[DNP-FETCH] ERROR: earliestOffsetSeconds must be >= 60 (iOS requirement)")
|
|
return false
|
|
}
|
|
|
|
// One Active Task Rule: Cancel any existing pending task for this notification
|
|
if let notificationId = notificationId {
|
|
cancelPendingTask(for: notificationId)
|
|
}
|
|
|
|
let request = BGAppRefreshTaskRequest(identifier: prefetchTaskIdentifier)
|
|
request.earliestBeginDate = Date(timeIntervalSinceNow: earliestOffsetSeconds)
|
|
|
|
do {
|
|
try BGTaskScheduler.shared.submit(request)
|
|
os_log("[DNP-FETCH] BGAppRefreshTask scheduled (earliestBeginDate=%{public}@)", log: fetchLog, type: .info, String(describing: request.earliestBeginDate))
|
|
return true
|
|
} catch {
|
|
// Handle simulator limitation (Code=1 is expected on simulator)
|
|
if let nsError = error as NSError?, nsError.domain == "BGTaskSchedulerErrorDomain", nsError.code == 1 {
|
|
os_log("[DNP-FETCH] BGTask scheduling failed on simulator (expected): Code=1 notPermitted", log: fetchLog, type: .default)
|
|
print("[DNP-FETCH] NOTE: BGTaskScheduler doesn't work on simulator - this is expected. Use Xcode → Debug → Simulate Background Fetch for testing.")
|
|
return false
|
|
}
|
|
os_log("[DNP-FETCH] Failed to schedule BGAppRefreshTask: %{public}@", log: fetchLog, type: .error, error.localizedDescription)
|
|
print("[DNP-FETCH] Failed to schedule BGAppRefreshTask: \(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Cancel pending task for a specific notification (one active task rule)
|
|
private static func cancelPendingTask(for notificationId: String) {
|
|
// Get all pending tasks
|
|
BGTaskScheduler.shared.getPendingTaskRequests { requests in
|
|
for request in requests {
|
|
if request.identifier == prefetchTaskIdentifier {
|
|
// In real implementation, check if this request matches the notificationId
|
|
// For now, cancel all prefetch tasks (Phase 1: single notification)
|
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: prefetchTaskIdentifier)
|
|
os_log("[DNP-FETCH] Cancelled existing pending task for notificationId=%{public}@", log: fetchLog, type: .info, notificationId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Verify only one active task is pending (debug helper)
|
|
static func verifyOneActiveTask() -> Bool {
|
|
var pendingCount = 0
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
|
|
BGTaskScheduler.shared.getPendingTaskRequests { requests in
|
|
pendingCount = requests.filter { $0.identifier == prefetchTaskIdentifier }.count
|
|
semaphore.signal()
|
|
}
|
|
|
|
semaphore.wait()
|
|
|
|
if pendingCount > 1 {
|
|
os_log("[DNP-FETCH] WARNING: Multiple pending tasks detected (%d) - expected 1", log: fetchLog, type: .default, pendingCount)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// MARK: - Time Warp Simulation (Testing)
|
|
|
|
/// Simulate time warp for accelerated testing
|
|
///
|
|
/// Useful to accelerate DST, T-Lead, and cache TTL tests without waiting in real time.
|
|
/// - Parameter minutesForward: Number of minutes to advance simulated time
|
|
static func simulateTimeWarp(minutesForward: Int) {
|
|
let timeWarpOffset = TimeInterval(minutesForward * 60)
|
|
// Store time warp offset in UserDefaults for test harness use
|
|
UserDefaults.standard.set(timeWarpOffset, forKey: "DNP_TimeWarpOffset")
|
|
print("[DNP-FETCH] Time warp simulated: +\(minutesForward) minutes")
|
|
}
|
|
|
|
/// Get current time with time warp applied (testing only)
|
|
static func getWarpedTime() -> Date {
|
|
let offset = UserDefaults.standard.double(forKey: "DNP_TimeWarpOffset")
|
|
return Date().addingTimeInterval(offset)
|
|
}
|
|
|
|
// MARK: - Force Reschedule
|
|
|
|
/// Force reschedule all BGTasks and notifications
|
|
///
|
|
/// Forces re-registration of BGTasks and notifications.
|
|
/// Useful when testing repeated failures or BGTask recovery behavior.
|
|
static func forceRescheduleAll() {
|
|
print("[DNP-FETCH] Force rescheduling all tasks and notifications")
|
|
// Cancel existing tasks
|
|
BGTaskScheduler.shared.cancelAllTaskRequests()
|
|
// Re-register and reschedule
|
|
registerBackgroundTasks()
|
|
// Trigger reschedule logic (implementation-specific)
|
|
}
|
|
|
|
// MARK: - Handler
|
|
|
|
/// Handle BGAppRefreshTask execution
|
|
///
|
|
/// **Apple Best Practice Pattern:**
|
|
/// 1. Schedule next task immediately (at start of execution)
|
|
/// 2. Initiate async work
|
|
/// 3. Mark task as complete
|
|
/// 4. Use expiration handler to cancel if needed
|
|
///
|
|
/// This is called by the system when the background task is launched.
|
|
/// Replace PrefetchOperation with your actual prefetch logic.
|
|
private static func handlePrefetchTask(task: BGAppRefreshTask) {
|
|
os_log("[DNP-FETCH] BGTask handler invoked (task.identifier=%{public}@)", log: fetchLog, type: .info, task.identifier)
|
|
|
|
// STEP 1: Schedule the next task IMMEDIATELY (Apple best practice)
|
|
// This ensures continuity even if app is terminated shortly after
|
|
// In real implementation, calculate next schedule based on notification time
|
|
schedulePrefetchTask(earliestOffsetSeconds: 60 * 30) // 30 minutes later, for example
|
|
|
|
// Define the work
|
|
let queue = OperationQueue()
|
|
queue.maxConcurrentOperationCount = 1
|
|
|
|
let operation = PrefetchOperation()
|
|
var taskCompleted = false
|
|
|
|
// STEP 4: Set expiration handler (called if iOS terminates task early, ~30 seconds)
|
|
task.expirationHandler = {
|
|
os_log("[DNP-FETCH] Task expired - cancelling operations", log: fetchLog, type: .default)
|
|
operation.cancel()
|
|
|
|
// Ensure task is marked complete even on expiration
|
|
if !taskCompleted {
|
|
taskCompleted = true
|
|
task.setTaskCompleted(success: false)
|
|
os_log("[DNP-FETCH] Task marked complete (success=false) due to expiration", log: fetchLog, type: .info)
|
|
}
|
|
}
|
|
|
|
// STEP 2: Initiate async work
|
|
// STEP 3: Mark task as complete when done
|
|
operation.completionBlock = {
|
|
let success = !operation.isCancelled && !operation.isFailed
|
|
|
|
// Ensure setTaskCompleted is called exactly once
|
|
if !taskCompleted {
|
|
taskCompleted = true
|
|
task.setTaskCompleted(success: success)
|
|
os_log("[DNP-FETCH] Task marked complete (success=%{public}@)", log: fetchLog, type: .info, success ? "true" : "false")
|
|
} else {
|
|
os_log("[DNP-FETCH] WARNING: Attempted to complete task twice", log: fetchLog, type: .default)
|
|
}
|
|
}
|
|
|
|
queue.addOperation(operation)
|
|
}
|
|
}
|
|
|
|
// MARK: - Prefetch Operation
|
|
|
|
/// Simple Operation example for testing
|
|
///
|
|
/// Replace this with your actual prefetch logic:
|
|
/// - HTTP fetch from TimeSafari API
|
|
/// - JWT signing
|
|
/// - ETag validation
|
|
/// - Content caching
|
|
/// - Error handling
|
|
class PrefetchOperation: Operation {
|
|
|
|
var isFailed = false
|
|
private static let fetchLog = OSLog(subsystem: "com.timesafari.dailynotification", category: "fetch")
|
|
|
|
override func main() {
|
|
if isCancelled { return }
|
|
|
|
os_log("[DNP-FETCH] PrefetchOperation: starting fetch...", log: Self.fetchLog, type: .info)
|
|
|
|
// Simulate some work
|
|
// In real implementation, this would be:
|
|
// - Make HTTP request
|
|
// - Parse response
|
|
// - Validate TTL and scheduled_for match notificationTime
|
|
// - Cache content (ensure persisted before completion)
|
|
// - Update database
|
|
// - Handle errors (network, auth, etc.)
|
|
|
|
// Simulate network delay
|
|
Thread.sleep(forTimeInterval: 2)
|
|
|
|
if isCancelled { return }
|
|
|
|
// Simulate success/failure (in real code, check HTTP response)
|
|
let success = true // Replace with actual fetch result
|
|
|
|
if success {
|
|
os_log("[DNP-FETCH] PrefetchOperation: fetch success", log: Self.fetchLog, type: .info)
|
|
// In real implementation: persist cache here before completion
|
|
} else {
|
|
isFailed = true
|
|
os_log("[DNP-FETCH] PrefetchOperation: fetch failed", log: Self.fetchLog, type: .error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - AppDelegate Integration Example
|
|
|
|
/*
|
|
Example integration in AppDelegate.swift:
|
|
|
|
import UIKit
|
|
import BackgroundTasks
|
|
|
|
@main
|
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
|
|
func application(
|
|
_ application: UIApplication,
|
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
|
) -> Bool {
|
|
|
|
// Register background tasks BEFORE app finishes launching
|
|
DailyNotificationBackgroundTaskTestHarness.registerBackgroundTasks()
|
|
|
|
// Schedule initial task (for testing)
|
|
DailyNotificationBackgroundTaskTestHarness.schedulePrefetchTask(earliestOffsetSeconds: 5 * 60) // 5 minutes
|
|
|
|
return true
|
|
}
|
|
}
|
|
*/
|
|
|
|
// MARK: - Testing in Simulator
|
|
|
|
/*
|
|
To test in simulator:
|
|
|
|
1. Run app in Xcode
|
|
2. Background the app (Home button / Cmd+Shift+H)
|
|
3. In Xcode menu:
|
|
- Debug → Simulate Background Fetch, or
|
|
- Debug → Simulate Background Refresh
|
|
4. Check console logs for [DNP-FETCH] messages
|
|
|
|
Expected logs:
|
|
- [DNP-FETCH] Registered BGTaskScheduler with id=...
|
|
- [DNP-FETCH] BGAppRefreshTask scheduled (earliestBeginDate=...)
|
|
- [DNP-FETCH] BGTask handler invoked (task.identifier=...)
|
|
- [DNP-FETCH] PrefetchOperation: starting fake fetch...
|
|
- [DNP-FETCH] PrefetchOperation: finished fake fetch.
|
|
- [DNP-FETCH] Task completionBlock (success=true)
|
|
*/
|
|
|
|
// MARK: - Testing on Real Device
|
|
|
|
/*
|
|
To test on real device:
|
|
|
|
1. Install app on iPhone
|
|
2. Enable Background App Refresh in Settings → [Your App]
|
|
3. Schedule a notification with prefetch
|
|
4. Lock device and leave idle (plugged in for best results)
|
|
5. Monitor logs via:
|
|
- Xcode → Devices & Simulators → device → open console
|
|
- Or os_log aggregator / remote logging
|
|
|
|
Note: Real device timing is heuristic, not deterministic.
|
|
iOS will run the task when it determines it's appropriate,
|
|
not necessarily at the exact earliestBeginDate.
|
|
*/
|
|
|