feat(sync): add background task system for nightly CloudKit sync
Add BGTaskScheduler-based background sync for keeping local data fresh: - BackgroundSyncManager: New singleton managing background tasks - BGAppRefreshTask for periodic CloudKit sync (system-determined frequency) - BGProcessingTask for overnight sync + database cleanup (2 AM) - Auto-archives games older than 1 year during cleanup - Info.plist: Added BGTaskSchedulerPermittedIdentifiers - com.sportstime.app.refresh (periodic sync) - com.sportstime.app.db-cleanup (overnight processing) - SportsTimeApp: Integrated background task lifecycle - Register tasks in init() (required before app finishes launching) - Schedule tasks after bootstrap and when app enters background Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
278
SportsTime/Core/Services/BackgroundSyncManager.swift
Normal file
278
SportsTime/Core/Services/BackgroundSyncManager.swift
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
//
|
||||||
|
// BackgroundSyncManager.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Manages background tasks for nightly CloudKit sync and database cleanup.
|
||||||
|
// Uses BGTaskScheduler to run sync operations even when app is not active.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import BackgroundTasks
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Manages background refresh and processing tasks for CloudKit sync.
|
||||||
|
@MainActor
|
||||||
|
final class BackgroundSyncManager {
|
||||||
|
|
||||||
|
// MARK: - Task Identifiers
|
||||||
|
|
||||||
|
/// Background app refresh task - runs periodically (system decides frequency)
|
||||||
|
static let refreshTaskIdentifier = "com.sportstime.app.refresh"
|
||||||
|
|
||||||
|
/// Background processing task - runs during optimal conditions (plugged in, overnight)
|
||||||
|
static let processingTaskIdentifier = "com.sportstime.app.db-cleanup"
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
|
||||||
|
static let shared = BackgroundSyncManager()
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private var modelContainer: ModelContainer?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
/// Configure the manager with the model container.
|
||||||
|
/// Must be called before registering tasks.
|
||||||
|
func configure(with container: ModelContainer) {
|
||||||
|
self.modelContainer = container
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Task Registration
|
||||||
|
|
||||||
|
/// Register all background tasks with the system.
|
||||||
|
/// Must be called early in app lifecycle, before `applicationDidFinishLaunching` returns.
|
||||||
|
func registerTasks() {
|
||||||
|
// Register refresh task (for periodic CloudKit sync)
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: Self.refreshTaskIdentifier,
|
||||||
|
using: nil
|
||||||
|
) { task in
|
||||||
|
guard let task = task as? BGAppRefreshTask else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.handleRefreshTask(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register processing task (for overnight heavy sync/cleanup)
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: Self.processingTaskIdentifier,
|
||||||
|
using: nil
|
||||||
|
) { task in
|
||||||
|
guard let task = task as? BGProcessingTask else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.handleProcessingTask(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Background tasks registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Task Scheduling
|
||||||
|
|
||||||
|
/// Schedule the next background refresh.
|
||||||
|
/// System will run this periodically based on app usage patterns.
|
||||||
|
func scheduleRefresh() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: Self.refreshTaskIdentifier)
|
||||||
|
|
||||||
|
// Request to run no earlier than 1 hour from now
|
||||||
|
// System will actually schedule based on usage patterns, battery, network, etc.
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
print("Background refresh scheduled")
|
||||||
|
} catch {
|
||||||
|
print("Failed to schedule background refresh: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule overnight processing task for heavy sync/cleanup.
|
||||||
|
/// Runs when device is charging and connected to WiFi.
|
||||||
|
func scheduleProcessingTask() {
|
||||||
|
let request = BGProcessingTaskRequest(identifier: Self.processingTaskIdentifier)
|
||||||
|
|
||||||
|
// Schedule for overnight - earliest 2 AM
|
||||||
|
let calendar = Calendar.current
|
||||||
|
var components = calendar.dateComponents([.year, .month, .day], from: Date())
|
||||||
|
components.hour = 2
|
||||||
|
components.minute = 0
|
||||||
|
|
||||||
|
// If it's past 2 AM, schedule for tomorrow
|
||||||
|
var targetDate = calendar.date(from: components) ?? Date()
|
||||||
|
if targetDate < Date() {
|
||||||
|
targetDate = calendar.date(byAdding: .day, value: 1, to: targetDate) ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
request.earliestBeginDate = targetDate
|
||||||
|
request.requiresNetworkConnectivity = true
|
||||||
|
request.requiresExternalPower = false // Don't require power, but prefer it
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
print("Overnight processing task scheduled for \(targetDate)")
|
||||||
|
} catch {
|
||||||
|
print("Failed to schedule processing task: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule all background tasks.
|
||||||
|
/// Call this after app finishes launching and when app enters background.
|
||||||
|
func scheduleAllTasks() {
|
||||||
|
scheduleRefresh()
|
||||||
|
scheduleProcessingTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Task Handlers
|
||||||
|
|
||||||
|
/// Handle the background refresh task.
|
||||||
|
@MainActor
|
||||||
|
private func handleRefreshTask(_ task: BGAppRefreshTask) async {
|
||||||
|
print("Background refresh task started")
|
||||||
|
|
||||||
|
// Schedule the next refresh before we start (in case we get terminated)
|
||||||
|
scheduleRefresh()
|
||||||
|
|
||||||
|
// Set up expiration handler
|
||||||
|
task.expirationHandler = {
|
||||||
|
print("Background refresh task expired")
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let container = modelContainer else {
|
||||||
|
print("Background refresh: No model container configured")
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let success = try await performSync(context: container.mainContext)
|
||||||
|
task.setTaskCompleted(success: success)
|
||||||
|
print("Background refresh completed: \(success ? "success" : "no updates")")
|
||||||
|
} catch {
|
||||||
|
print("Background refresh failed: \(error.localizedDescription)")
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle the background processing task (overnight heavy sync).
|
||||||
|
@MainActor
|
||||||
|
private func handleProcessingTask(_ task: BGProcessingTask) async {
|
||||||
|
print("Background processing task started")
|
||||||
|
|
||||||
|
// Schedule the next processing task
|
||||||
|
scheduleProcessingTask()
|
||||||
|
|
||||||
|
// Set up expiration handler
|
||||||
|
task.expirationHandler = {
|
||||||
|
print("Background processing task expired")
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let container = modelContainer else {
|
||||||
|
print("Background processing: No model container configured")
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
// 1. Full sync from CloudKit
|
||||||
|
let syncSuccess = try await performSync(context: container.mainContext)
|
||||||
|
|
||||||
|
// 2. Clean up old data (games older than 1 year)
|
||||||
|
let cleanupSuccess = await performCleanup(context: container.mainContext)
|
||||||
|
|
||||||
|
task.setTaskCompleted(success: syncSuccess || cleanupSuccess)
|
||||||
|
print("Background processing completed: sync=\(syncSuccess), cleanup=\(cleanupSuccess)")
|
||||||
|
} catch {
|
||||||
|
print("Background processing failed: \(error.localizedDescription)")
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Operations
|
||||||
|
|
||||||
|
/// Perform CloudKit sync operation.
|
||||||
|
@MainActor
|
||||||
|
private func performSync(context: ModelContext) async throws -> Bool {
|
||||||
|
let syncService = CanonicalSyncService()
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await syncService.syncAll(context: context)
|
||||||
|
|
||||||
|
// Reload DataProvider if data changed
|
||||||
|
if !result.isEmpty {
|
||||||
|
await AppDataProvider.shared.loadInitialData()
|
||||||
|
print("Background sync: \(result.totalUpdated) items updated")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
|
||||||
|
} catch CanonicalSyncService.SyncError.cloudKitUnavailable {
|
||||||
|
// No network - this is expected, not a failure
|
||||||
|
print("Background sync: CloudKit unavailable (offline)")
|
||||||
|
return false
|
||||||
|
} catch CanonicalSyncService.SyncError.syncAlreadyInProgress {
|
||||||
|
// Another sync is running - that's fine
|
||||||
|
print("Background sync: Already in progress")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up old game data (older than 1 year).
|
||||||
|
@MainActor
|
||||||
|
private func performCleanup(context: ModelContext) async -> Bool {
|
||||||
|
let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: Date()) ?? Date()
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Fetch old games
|
||||||
|
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||||
|
predicate: #Predicate<CanonicalGame> { game in
|
||||||
|
game.dateTime < oneYearAgo && game.deprecatedAt == nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
let oldGames = try context.fetch(descriptor)
|
||||||
|
|
||||||
|
guard !oldGames.isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-delete old games (mark as deprecated)
|
||||||
|
for game in oldGames {
|
||||||
|
game.deprecatedAt = Date()
|
||||||
|
game.deprecationReason = "Auto-archived: game older than 1 year"
|
||||||
|
}
|
||||||
|
|
||||||
|
try context.save()
|
||||||
|
print("Background cleanup: Archived \(oldGames.count) old games")
|
||||||
|
return true
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("Background cleanup failed: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Helpers
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension BackgroundSyncManager {
|
||||||
|
/// Manually trigger refresh task for testing.
|
||||||
|
/// Run in debugger: `e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.sportstime.app.refresh"]`
|
||||||
|
func debugTriggerRefresh() {
|
||||||
|
print("Debug: Use lldb command to trigger background refresh:")
|
||||||
|
print("e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@\"com.sportstime.app.refresh\"]")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manually trigger processing task for testing.
|
||||||
|
func debugTriggerProcessing() {
|
||||||
|
print("Debug: Use lldb command to trigger background processing:")
|
||||||
|
print("e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@\"com.sportstime.app.db-cleanup\"]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -8,5 +8,10 @@
|
|||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
<string>processing</string>
|
<string>processing</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.sportstime.app.refresh</string>
|
||||||
|
<string>com.sportstime.app.db-cleanup</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import BackgroundTasks
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct SportsTimeApp: App {
|
struct SportsTimeApp: App {
|
||||||
@@ -14,6 +15,10 @@ struct SportsTimeApp: App {
|
|||||||
private var transactionListener: Task<Void, Never>?
|
private var transactionListener: Task<Void, Never>?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
// Register background tasks BEFORE app finishes launching
|
||||||
|
// This must happen synchronously in init or applicationDidFinishLaunching
|
||||||
|
BackgroundSyncManager.shared.registerTasks()
|
||||||
|
|
||||||
// Start listening for transactions immediately
|
// Start listening for transactions immediately
|
||||||
transactionListener = StoreManager.shared.listenForTransactions()
|
transactionListener = StoreManager.shared.listenForTransactions()
|
||||||
}
|
}
|
||||||
@@ -105,11 +110,19 @@ struct BootstrappedContentView: View {
|
|||||||
await performBootstrap()
|
await performBootstrap()
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
// Sync when app comes to foreground (but not on initial launch)
|
switch newPhase {
|
||||||
if newPhase == .active && hasCompletedInitialSync {
|
case .active:
|
||||||
Task {
|
// Sync when app comes to foreground (but not on initial launch)
|
||||||
await performBackgroundSync(context: modelContainer.mainContext)
|
if hasCompletedInitialSync {
|
||||||
|
Task {
|
||||||
|
await performBackgroundSync(context: modelContainer.mainContext)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
case .background:
|
||||||
|
// Schedule background tasks when app goes to background
|
||||||
|
BackgroundSyncManager.shared.scheduleAllTasks()
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,17 +142,23 @@ struct BootstrappedContentView: View {
|
|||||||
// 2. Configure DataProvider with SwiftData context
|
// 2. Configure DataProvider with SwiftData context
|
||||||
AppDataProvider.shared.configure(with: context)
|
AppDataProvider.shared.configure(with: context)
|
||||||
|
|
||||||
// 3. Load data from SwiftData into memory
|
// 3. Configure BackgroundSyncManager with model container
|
||||||
|
BackgroundSyncManager.shared.configure(with: modelContainer)
|
||||||
|
|
||||||
|
// 4. Load data from SwiftData into memory
|
||||||
await AppDataProvider.shared.loadInitialData()
|
await AppDataProvider.shared.loadInitialData()
|
||||||
|
|
||||||
// 4. Load store products and entitlements
|
// 5. Load store products and entitlements
|
||||||
await StoreManager.shared.loadProducts()
|
await StoreManager.shared.loadProducts()
|
||||||
await StoreManager.shared.updateEntitlements()
|
await StoreManager.shared.updateEntitlements()
|
||||||
|
|
||||||
// 5. App is now usable
|
// 6. App is now usable
|
||||||
isBootstrapping = false
|
isBootstrapping = false
|
||||||
|
|
||||||
// 6. Background: Try to refresh from CloudKit (non-blocking)
|
// 7. Schedule background tasks for future syncs
|
||||||
|
BackgroundSyncManager.shared.scheduleAllTasks()
|
||||||
|
|
||||||
|
// 8. Background: Try to refresh from CloudKit (non-blocking)
|
||||||
Task.detached(priority: .background) {
|
Task.detached(priority: .background) {
|
||||||
await self.performBackgroundSync(context: context)
|
await self.performBackgroundSync(context: context)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|||||||
Reference in New Issue
Block a user