Files
Sportstime/SportsTime/Core/Services/BackgroundSyncManager.swift
Trey t 61c4e39807 feat: update bundle ID config, CloudKit container, and add landing page
- Update CloudKit container ID to iCloud.com.88oakapps.SportsTime across all services
- Update IAP product IDs to match new bundle ID (com.88oakapps.SportsTime)
- Add app landing page with light, welcoming design matching app aesthetic
- Update entitlements and project configuration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 22:47:55 -06:00

348 lines
13 KiB
Swift

//
// 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
import os
/// 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.88oakapps.SportsTime.refresh"
/// Background processing task - runs during optimal conditions (plugged in, overnight)
static let processingTaskIdentifier = "com.88oakapps.SportsTime.db-cleanup"
// MARK: - Singleton
static let shared = BackgroundSyncManager()
// MARK: - Properties
private var modelContainer: ModelContainer?
private var currentCancellationToken: BackgroundTaskCancellationToken?
private let logger = Logger(subsystem: "com.88oakapps.SportsTime", category: "BackgroundSyncManager")
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 {
logger.info("Background refresh task started")
// Schedule the next refresh before we start (in case we get terminated)
scheduleRefresh()
// Create cancellation token for this task
let cancellationToken = BackgroundTaskCancellationToken()
currentCancellationToken = cancellationToken
// Set up expiration handler - cancel sync gracefully
task.expirationHandler = { [weak self] in
self?.logger.warning("Background refresh task expiring - cancelling sync")
cancellationToken.cancel()
}
guard let container = modelContainer else {
logger.error("Background refresh: No model container configured")
task.setTaskCompleted(success: false)
return
}
do {
let result = try await performSync(context: container.mainContext, cancellationToken: cancellationToken)
currentCancellationToken = nil
if result.wasCancelled {
logger.info("Background refresh cancelled with partial progress: \(result.totalUpdated) items synced")
task.setTaskCompleted(success: true) // Partial success - progress was saved
} else {
logger.info("Background refresh completed: \(result.totalUpdated) items updated")
task.setTaskCompleted(success: !result.isEmpty)
}
} catch {
currentCancellationToken = nil
logger.error("Background refresh failed: \(error.localizedDescription)")
task.setTaskCompleted(success: false)
}
}
/// Handle the background processing task (overnight heavy sync).
@MainActor
private func handleProcessingTask(_ task: BGProcessingTask) async {
logger.info("Background processing task started")
// Schedule the next processing task
scheduleProcessingTask()
// Create cancellation token for this task
let cancellationToken = BackgroundTaskCancellationToken()
currentCancellationToken = cancellationToken
// Set up expiration handler - cancel sync gracefully
task.expirationHandler = { [weak self] in
self?.logger.warning("Background processing task expiring - cancelling sync")
cancellationToken.cancel()
}
guard let container = modelContainer else {
logger.error("Background processing: No model container configured")
task.setTaskCompleted(success: false)
return
}
do {
// 1. Full sync from CloudKit
let syncResult = try await performSync(context: container.mainContext, cancellationToken: cancellationToken)
currentCancellationToken = nil
// 2. Clean up old data (games older than 1 year) - only if sync wasn't cancelled
var cleanupSuccess = false
if !syncResult.wasCancelled {
cleanupSuccess = await performCleanup(context: container.mainContext)
}
if syncResult.wasCancelled {
logger.info("Background processing cancelled with partial progress: \(syncResult.totalUpdated) items synced")
task.setTaskCompleted(success: true) // Partial success
} else {
logger.info("Background processing completed: sync=\(syncResult.totalUpdated), cleanup=\(cleanupSuccess)")
task.setTaskCompleted(success: !syncResult.isEmpty || cleanupSuccess)
}
} catch {
currentCancellationToken = nil
logger.error("Background processing failed: \(error.localizedDescription)")
task.setTaskCompleted(success: false)
}
}
// MARK: - Sync Operations
/// Perform CloudKit sync operation.
/// - Parameters:
/// - context: The ModelContext to use
/// - cancellationToken: Optional token for graceful cancellation
/// - Returns: The sync result with counts and cancellation status
@MainActor
private func performSync(
context: ModelContext,
cancellationToken: SyncCancellationToken? = nil
) async throws -> CanonicalSyncService.SyncResult {
let syncService = CanonicalSyncService()
do {
let result = try await syncService.syncAll(context: context, cancellationToken: cancellationToken)
// Reload DataProvider if data changed
if !result.isEmpty || result.wasCancelled {
await AppDataProvider.shared.loadInitialData()
logger.info("Sync completed: \(result.totalUpdated) items updated, cancelled: \(result.wasCancelled)")
}
return result
} catch CanonicalSyncService.SyncError.cloudKitUnavailable {
// No network - this is expected, not a failure
logger.info("CloudKit unavailable (offline)")
return CanonicalSyncService.SyncResult(
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0,
duration: 0, wasCancelled: false
)
} catch CanonicalSyncService.SyncError.syncAlreadyInProgress {
// Another sync is running - that's fine
logger.info("Sync already in progress")
return CanonicalSyncService.SyncResult(
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0,
duration: 0, wasCancelled: false
)
}
}
/// Trigger sync from network restoration (called by NetworkMonitor).
/// This is a non-background task sync, so no cancellation token is needed.
@MainActor
func triggerSyncFromNetworkRestoration() async {
guard let container = modelContainer else {
logger.error("Network sync: No model container configured")
return
}
logger.info("Triggering sync from network restoration")
do {
let result = try await performSync(context: container.mainContext)
if !result.isEmpty {
logger.info("Network restoration sync completed: \(result.totalUpdated) items updated")
} else {
logger.info("Network restoration sync: no updates needed")
}
} catch {
logger.error("Network restoration sync failed: \(error.localizedDescription)")
}
}
/// 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.88oakapps.SportsTime.refresh"]`
func debugTriggerRefresh() {
print("Debug: Use lldb command to trigger background refresh:")
print("e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@\"com.88oakapps.SportsTime.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.88oakapps.SportsTime.db-cleanup\"]")
}
}
#endif