Address 16 issues from external audit: - Move StoreKit transaction listener ownership to StoreManager singleton with proper deinit - Remove noisy VoiceOver announcements, add missing accessibility on StatPill and BootstrapLoadingView - Replace String @retroactive Identifiable with IdentifiableShareCode wrapper - Add crash guard in AchievementEngine getContributingVisitIds + cache stadium lookups - Pre-compute GamesHistoryViewModel filtered properties to avoid redundant SwiftUI recomputation - Remove force-unwraps in ProgressMapView with safe guard-let fallback - Add diff-based update gating in ItineraryTableViewWrapper to prevent unnecessary reloads - Replace deprecated UIScreen.main with UIWindowScene lookup - Add deinit task cancellation in ScheduleViewModel and SuggestedTripsGenerator - Wrap ~234 unguarded print() calls across 27 files in #if DEBUG Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
564 lines
22 KiB
Swift
564 lines
22 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 {
|
|
|
|
enum SyncTriggerError: Error, LocalizedError {
|
|
case modelContainerNotConfigured
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .modelContainerNotConfigured:
|
|
return "Sync is unavailable because the model container is not configured."
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
// using: DispatchQueue.main ensures the handler runs on the main queue,
|
|
// satisfying @MainActor isolation (using: nil invokes on a background queue,
|
|
// which crashes with dispatch_assert_queue_fail in Swift 6)
|
|
BGTaskScheduler.shared.register(
|
|
forTaskWithIdentifier: Self.refreshTaskIdentifier,
|
|
using: DispatchQueue.main
|
|
) { [weak self] 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: DispatchQueue.main
|
|
) { [weak self] task in
|
|
guard let task = task as? BGProcessingTask else { return }
|
|
Task { @MainActor in
|
|
await self?.handleProcessingTask(task)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
print("Background tasks registered")
|
|
#endif
|
|
}
|
|
|
|
// 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)
|
|
#if DEBUG
|
|
print("Background refresh scheduled")
|
|
#endif
|
|
} catch {
|
|
#if DEBUG
|
|
print("Failed to schedule background refresh: \(error.localizedDescription)")
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
#if DEBUG
|
|
print("Overnight processing task scheduled for \(targetDate)")
|
|
#endif
|
|
} catch {
|
|
#if DEBUG
|
|
print("Failed to schedule processing task: \(error.localizedDescription)")
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
// Note: expirationHandler runs on an arbitrary queue, so avoid accessing @MainActor self
|
|
let expirationLogger = logger
|
|
task.expirationHandler = {
|
|
expirationLogger.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: true)
|
|
}
|
|
} 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
|
|
let processingLogger = logger
|
|
task.expirationHandler = {
|
|
processingLogger.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: true)
|
|
}
|
|
} 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)")
|
|
}
|
|
}
|
|
|
|
/// Trigger sync from a CloudKit push notification.
|
|
/// Returns true if local data changed and UI should refresh.
|
|
@MainActor
|
|
func triggerSyncFromPushNotification(subscriptionID: String?) async -> Bool {
|
|
guard let container = modelContainer else {
|
|
logger.error("Push sync: No model container configured")
|
|
return false
|
|
}
|
|
|
|
if let subscriptionID {
|
|
logger.info("Triggering sync from CloudKit push (\(subscriptionID, privacy: .public))")
|
|
} else {
|
|
logger.info("Triggering sync from CloudKit push")
|
|
}
|
|
|
|
do {
|
|
let result = try await performSync(context: container.mainContext)
|
|
return !result.isEmpty || result.wasCancelled
|
|
} catch {
|
|
logger.error("Push-triggered sync failed: \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Apply a targeted local deletion for records removed remotely.
|
|
/// This covers deletions which are not returned by date-based delta queries.
|
|
@MainActor
|
|
func applyDeletionHint(recordType: String?, recordName: String) async -> Bool {
|
|
guard let container = modelContainer else {
|
|
logger.error("Deletion hint: No model container configured")
|
|
return false
|
|
}
|
|
|
|
let context = container.mainContext
|
|
var changed = false
|
|
|
|
do {
|
|
switch recordType {
|
|
case CKRecordType.game:
|
|
let descriptor = FetchDescriptor<CanonicalGame>(
|
|
predicate: #Predicate<CanonicalGame> { game in
|
|
game.canonicalId == recordName && game.deprecatedAt == nil
|
|
}
|
|
)
|
|
if let game = try context.fetch(descriptor).first {
|
|
game.deprecatedAt = Date()
|
|
game.deprecationReason = "Deleted in CloudKit"
|
|
changed = true
|
|
}
|
|
|
|
case CKRecordType.team:
|
|
let descriptor = FetchDescriptor<CanonicalTeam>(
|
|
predicate: #Predicate<CanonicalTeam> { team in
|
|
team.canonicalId == recordName && team.deprecatedAt == nil
|
|
}
|
|
)
|
|
if let team = try context.fetch(descriptor).first {
|
|
team.deprecatedAt = Date()
|
|
team.deprecationReason = "Deleted in CloudKit"
|
|
changed = true
|
|
}
|
|
|
|
// Avoid dangling schedule entries when a referenced team disappears.
|
|
let impactedGames = FetchDescriptor<CanonicalGame>(
|
|
predicate: #Predicate<CanonicalGame> { game in
|
|
game.deprecatedAt == nil &&
|
|
(game.homeTeamCanonicalId == recordName || game.awayTeamCanonicalId == recordName)
|
|
}
|
|
)
|
|
for game in try context.fetch(impactedGames) {
|
|
game.deprecatedAt = Date()
|
|
game.deprecationReason = "Dependent team deleted in CloudKit"
|
|
changed = true
|
|
}
|
|
|
|
case CKRecordType.stadium:
|
|
let descriptor = FetchDescriptor<CanonicalStadium>(
|
|
predicate: #Predicate<CanonicalStadium> { stadium in
|
|
stadium.canonicalId == recordName && stadium.deprecatedAt == nil
|
|
}
|
|
)
|
|
if let stadium = try context.fetch(descriptor).first {
|
|
stadium.deprecatedAt = Date()
|
|
stadium.deprecationReason = "Deleted in CloudKit"
|
|
changed = true
|
|
}
|
|
|
|
// Avoid dangling schedule entries when a referenced stadium disappears.
|
|
let impactedGames = FetchDescriptor<CanonicalGame>(
|
|
predicate: #Predicate<CanonicalGame> { game in
|
|
game.deprecatedAt == nil && game.stadiumCanonicalId == recordName
|
|
}
|
|
)
|
|
for game in try context.fetch(impactedGames) {
|
|
game.deprecatedAt = Date()
|
|
game.deprecationReason = "Dependent stadium deleted in CloudKit"
|
|
changed = true
|
|
}
|
|
|
|
case CKRecordType.leagueStructure:
|
|
let descriptor = FetchDescriptor<LeagueStructureModel>(
|
|
predicate: #Predicate<LeagueStructureModel> { $0.id == recordName }
|
|
)
|
|
let records = try context.fetch(descriptor)
|
|
for record in records {
|
|
context.delete(record)
|
|
changed = true
|
|
}
|
|
|
|
case CKRecordType.teamAlias:
|
|
let descriptor = FetchDescriptor<TeamAlias>(
|
|
predicate: #Predicate<TeamAlias> { $0.id == recordName }
|
|
)
|
|
let records = try context.fetch(descriptor)
|
|
for record in records {
|
|
context.delete(record)
|
|
changed = true
|
|
}
|
|
|
|
case CKRecordType.stadiumAlias:
|
|
let descriptor = FetchDescriptor<StadiumAlias>(
|
|
predicate: #Predicate<StadiumAlias> { $0.aliasName == recordName }
|
|
)
|
|
let records = try context.fetch(descriptor)
|
|
for record in records {
|
|
context.delete(record)
|
|
changed = true
|
|
}
|
|
|
|
case CKRecordType.sport:
|
|
let descriptor = FetchDescriptor<CanonicalSport>(
|
|
predicate: #Predicate<CanonicalSport> { $0.id == recordName }
|
|
)
|
|
if let sport = try context.fetch(descriptor).first, sport.isActive {
|
|
sport.isActive = false
|
|
sport.lastModified = Date()
|
|
changed = true
|
|
}
|
|
|
|
default:
|
|
break
|
|
}
|
|
|
|
guard changed else { return false }
|
|
|
|
try context.save()
|
|
await AppDataProvider.shared.loadInitialData()
|
|
logger.info("Applied CloudKit deletion hint for \(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)")
|
|
return true
|
|
|
|
} catch {
|
|
logger.error("Failed to apply deletion hint (\(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)): \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Trigger a manual full sync from settings or other explicit user action.
|
|
@MainActor
|
|
func triggerManualSync() async throws -> CanonicalSyncService.SyncResult {
|
|
guard let container = modelContainer else {
|
|
throw SyncTriggerError.modelContainerNotConfigured
|
|
}
|
|
|
|
let context = container.mainContext
|
|
let syncService = CanonicalSyncService()
|
|
let syncState = SyncState.current(in: context)
|
|
|
|
if !syncState.syncEnabled {
|
|
syncService.resumeSync(context: context)
|
|
}
|
|
|
|
return try await performSync(context: context)
|
|
}
|
|
|
|
/// Register CloudKit subscriptions for canonical data updates.
|
|
@MainActor
|
|
func ensureCanonicalSubscriptions() async {
|
|
do {
|
|
try await CloudKitService.shared.subscribeToAllUpdates()
|
|
logger.info("CloudKit subscriptions ensured for canonical sync")
|
|
} catch {
|
|
logger.error("Failed to register CloudKit subscriptions: \(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()
|
|
#if DEBUG
|
|
print("Background cleanup: Archived \(oldGames.count) old games")
|
|
#endif
|
|
return true
|
|
|
|
} catch {
|
|
#if DEBUG
|
|
print("Background cleanup failed: \(error.localizedDescription)")
|
|
#endif
|
|
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
|