Files
honeyDueKMP/iosApp/iosApp/Background/BackgroundTaskManager.swift
T
Trey t ef8eab4a07 iOS: complete bundle ID + team ID migration to com.myhoneydue.*
Carries the rebrand from the backend (APPLE_CLIENT_ID, APNS_TOPIC) all
the way through the iOS targets:

- All target PRODUCT_BUNDLE_IDENTIFIERs: com.tt.honeyDue.* → com.myhoneydue.honeyDue.*
- DEVELOPMENT_TEAM: V3PF3M6B6U → X86BR9WTLD (across every target)
- APP_GROUP_IDENTIFIER: group.com.tt.honeyDue.* → group.com.myhoneydue.honeyDue.*
- BGTaskSchedulerPermittedIdentifiers + BackgroundTaskManager constant
- KeychainHelper service identifier
- StoreKit fallback product IDs + Info.plist IAP product ID keys
- ExportOptions.plist teamID
- NSCamera / NSPhotoLibrary usage descriptions reworded
- Onboarding suggestion strings reworked (new %lld%% match copy,
  dropped old "Great match" / "Good match" / "Generating suggestions"
  strings — replaced by relevance-percentage labels)
- xctestplan + settings.local.json housekeeping

App-group rename means UserDefaults / shared-container data written to
the old group ID is abandoned. Acceptable since this is pre-launch.
2026-05-01 18:30:52 -07:00

203 lines
7.7 KiB
Swift

//
// 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.myhoneydue.honeyDue.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
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 = ApiResultBridge.error(from: result) {
print("BackgroundTaskManager: API error - \(error.message)")
return false
}
print("BackgroundTaskManager: Unexpected API result type during refresh")
return false
} 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.myhoneydue.honeyDue.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)"]
"""
}
}