Add background task for overnight widget data refresh
- Create BackgroundTaskManager that schedules refresh at random time between 12:00 AM - 4:00 AM local time - Fetches tasks from API, writes to DataManager and shared App Group - Reloads widget timelines after refresh - Register task in AppDelegate, schedule when app goes to background - Add BGTaskSchedulerPermittedIdentifiers and fetch/processing modes to Info.plist 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
202
iosApp/iosApp/Background/BackgroundTaskManager.swift
Normal file
202
iosApp/iosApp/Background/BackgroundTaskManager.swift
Normal file
@@ -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<TaskColumnsResponse>,
|
||||
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..<refreshWindowEndHour)
|
||||
let randomMinute = Int.random(in: 0..<60)
|
||||
let randomSecond = Int.random(in: 0..<60)
|
||||
|
||||
components.hour = randomHour
|
||||
components.minute = randomMinute
|
||||
components.second = randomSecond
|
||||
|
||||
// Create the scheduled date
|
||||
if let scheduledDate = calendar.date(from: components) {
|
||||
// Make sure it's in the future
|
||||
if scheduledDate > 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)"]
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -2,45 +2,12 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.tt.casera.refresh</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.casera.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>casera</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>localhost</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>127.0.0.1</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<false/>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -56,20 +23,55 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.casera.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>casera</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>127.0.0.1</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>localhost</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.casera.contractor</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Casera Contractor</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.content</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Casera Contractor</string>
|
||||
<key>UTTypeIconFiles</key>
|
||||
<array/>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.casera.contractor</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user