diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index f3c6569..84317ea 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -675,6 +675,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO; INFOPLIST_KEY_NSCameraUsageDescription = "Casera needs access to your camera to take photos of completed tasks"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Casera needs access to your photo library to select photos of completed tasks"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -1124,6 +1126,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO; INFOPLIST_KEY_NSCameraUsageDescription = "Casera needs access to your camera to take photos of completed tasks"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Casera needs access to your photo library to select photos of completed tasks"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; diff --git a/iosApp/iosApp/Background/BackgroundTaskManager.swift b/iosApp/iosApp/Background/BackgroundTaskManager.swift new file mode 100644 index 0000000..c688f4f --- /dev/null +++ b/iosApp/iosApp/Background/BackgroundTaskManager.swift @@ -0,0 +1,202 @@ +// +// BackgroundTaskManager.swift +// iosApp +// +// Manages background task scheduling for overnight task data refresh. +// Runs at a random time between 12:00 AM and 4:00 AM local time. +// + +import Foundation +import BackgroundTasks +import WidgetKit +import ComposeApp + +/// Manages background app refresh to keep task data fresh overnight +final class BackgroundTaskManager { + static let shared = BackgroundTaskManager() + + /// Background task identifier - must match Info.plist BGTaskSchedulerPermittedIdentifiers + static let taskIdentifier = "com.tt.casera.refresh" + + /// Time window for overnight refresh (12:00 AM - 4:00 AM) + private let refreshWindowStartHour = 0 // 12:00 AM + private let refreshWindowEndHour = 4 // 4:00 AM + + private init() {} + + // MARK: - Registration + + /// Register background tasks with the system. Call this in AppDelegate didFinishLaunchingWithOptions. + func registerBackgroundTasks() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.taskIdentifier, + using: nil + ) { [weak self] task in + guard let bgTask = task as? BGAppRefreshTask else { return } + self?.handleAppRefresh(task: bgTask) + } + print("BackgroundTaskManager: Registered background task \(Self.taskIdentifier)") + } + + // MARK: - Scheduling + + /// Schedule the next background refresh. Call this when the app goes to background. + func scheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) + + // Calculate a random time between 12:00 AM and 4:00 AM + request.earliestBeginDate = calculateNextRefreshDate() + + do { + try BGTaskScheduler.shared.submit(request) + print("BackgroundTaskManager: Scheduled background refresh for \(request.earliestBeginDate?.description ?? "unknown")") + } catch { + print("BackgroundTaskManager: Failed to schedule background refresh - \(error.localizedDescription)") + } + } + + /// Cancel any pending background refresh requests + func cancelPendingRefresh() { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) + print("BackgroundTaskManager: Cancelled pending background refresh") + } + + // MARK: - Task Handling + + /// Handle the background app refresh task + private func handleAppRefresh(task: BGAppRefreshTask) { + print("BackgroundTaskManager: Starting background refresh at \(Date())") + + // Schedule the next refresh before we start (in case this one fails) + scheduleAppRefresh() + + // Create a task to fetch data + let refreshTask = Task { + await performDataRefresh() + } + + // Set up expiration handler + task.expirationHandler = { + print("BackgroundTaskManager: Task expired, cancelling") + refreshTask.cancel() + } + + // Wait for the refresh to complete + Task { + let success = await refreshTask.value + task.setTaskCompleted(success: success) + print("BackgroundTaskManager: Background refresh completed with success=\(success)") + } + } + + /// Perform the actual data refresh + @MainActor + private func performDataRefresh() async -> Bool { + // Check if user is authenticated + guard let token = TokenStorage.shared.getToken(), !token.isEmpty else { + print("BackgroundTaskManager: No auth token, skipping refresh") + return true // Not a failure, just nothing to do + } + + do { + // Fetch tasks from API + print("BackgroundTaskManager: Fetching tasks from API...") + let result = try await APILayer.shared.getTasks(forceRefresh: true) + + if let success = result as? ApiResultSuccess, + let data = success.data { + // Write to DataManager (memory) - this happens automatically in APILayer + print("BackgroundTaskManager: Tasks loaded into DataManager") + + // Write to shared App Group for widget + WidgetDataManager.shared.saveTasks(from: data) + print("BackgroundTaskManager: Tasks saved to shared App Group") + + // Update widget + WidgetCenter.shared.reloadAllTimelines() + print("BackgroundTaskManager: Widget timelines reloaded") + + return true + } else if let error = result as? ApiResultError { + print("BackgroundTaskManager: API error - \(error.message)") + return false + } + + return true + } catch { + print("BackgroundTaskManager: Error during refresh - \(error.localizedDescription)") + return false + } + } + + // MARK: - Time Calculation + + /// Calculate the next refresh date (random time between 12:00 AM and 4:00 AM) + private func calculateNextRefreshDate() -> Date { + let calendar = Calendar.current + let now = Date() + + // Get today's date components + var components = calendar.dateComponents([.year, .month, .day], from: now) + + // Determine if we should schedule for tonight or tomorrow night + let currentHour = calendar.component(.hour, from: now) + + // If it's already past midnight (past the refresh window), schedule for tomorrow night + if currentHour >= refreshWindowEndHour { + // Add one day to schedule for tomorrow's window + if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now) { + components = calendar.dateComponents([.year, .month, .day], from: tomorrow) + } + } + + // Generate random hour (0-3) and minute (0-59) within the refresh window + let randomHour = Int.random(in: refreshWindowStartHour.. now { + return scheduledDate + } else { + // If somehow in the past, add a day + return calendar.date(byAdding: .day, value: 1, to: scheduledDate) ?? scheduledDate + } + } + + // Fallback: schedule for 2 hours from now + return now.addingTimeInterval(2 * 60 * 60) + } + + // MARK: - Debug Helpers + + /// Get a description of the current scheduling state (for debugging) + func getSchedulingStatus() -> String { + let nextDate = calculateNextRefreshDate() + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return "Next refresh scheduled for approximately: \(formatter.string(from: nextDate))" + } + + /// Force a background refresh for testing (only works in debug builds with Xcode) + /// Usage: In Xcode debugger console: + /// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.tt.casera.refresh"] + func debugInfo() -> String { + return """ + Background Task Debug Info: + - Task ID: \(Self.taskIdentifier) + - Refresh Window: \(refreshWindowStartHour):00 - \(refreshWindowEndHour):00 + - \(getSchedulingStatus()) + + To test in Xcode debugger: + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"\(Self.taskIdentifier)"] + """ + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index d279db1..14cf99e 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -2,45 +2,12 @@ + BGTaskSchedulerPermittedIdentifiers + + com.tt.casera.refresh + CADisableMinimumFrameDurationOnPhone - CFBundleURLTypes - - - CFBundleURLName - com.casera.app - CFBundleURLSchemes - - casera - - - - ITSAppUsesNonExemptEncryption - - NSAppTransportSecurity - - NSAllowsLocalNetworking - - NSExceptionDomains - - localhost - - NSExceptionAllowsInsecureHTTPLoads - - - 127.0.0.1 - - NSExceptionAllowsInsecureHTTPLoads - - - - - UIBackgroundModes - - remote-notification - - LSSupportsOpeningDocumentsInPlace - CFBundleDocumentTypes @@ -56,20 +23,55 @@ + CFBundleURLTypes + + + CFBundleURLName + com.casera.app + CFBundleURLSchemes + + casera + + + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSExceptionDomains + + 127.0.0.1 + + NSExceptionAllowsInsecureHTTPLoads + + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + UIBackgroundModes + + remote-notification + fetch + processing + UTExportedTypeDeclarations - UTTypeIdentifier - com.casera.contractor - UTTypeDescription - Casera Contractor UTTypeConformsTo public.data public.content + UTTypeDescription + Casera Contractor UTTypeIconFiles + UTTypeIdentifier + com.casera.contractor UTTypeTagSpecification public.filename-extension diff --git a/iosApp/iosApp/PushNotifications/AppDelegate.swift b/iosApp/iosApp/PushNotifications/AppDelegate.swift index 6f9dea4..54ac7b4 100644 --- a/iosApp/iosApp/PushNotifications/AppDelegate.swift +++ b/iosApp/iosApp/PushNotifications/AppDelegate.swift @@ -1,5 +1,6 @@ import UIKit import UserNotifications +import BackgroundTasks import ComposeApp class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { @@ -14,6 +15,9 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele // Register notification categories for actionable notifications NotificationCategories.registerCategories() + // Register background tasks for overnight data refresh + BackgroundTaskManager.shared.registerBackgroundTasks() + // Request notification permission Task { @MainActor in await PushNotificationManager.shared.requestNotificationPermission() diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index a42de68..5069e2b 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -92,6 +92,9 @@ struct iOSApp: App { } else if newPhase == .background { // Refresh widget when app goes to background WidgetCenter.shared.reloadAllTimelines() + + // Schedule overnight background refresh (12am-4am) + BackgroundTaskManager.shared.scheduleAppRefresh() } } // Import confirmation dialog - routes to appropriate handler