Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Foundation
import CocoaLumberjackSwift

/// Coordinates background catalog downloads, including handling app wake events.
public class BackgroundCatalogDownloadCoordinator {
private let backgroundDownloader: BackgroundDownloadProtocol
private let fileManager: FileManager

public init(backgroundDownloader: BackgroundDownloadProtocol = BackgroundDownloadService(),
fileManager: FileManager = .default) {
self.backgroundDownloader = backgroundDownloader
self.fileManager = fileManager
}

/// Handles a background URLSession wake event.
/// Called from AppDelegate when iOS wakes the app for a completed download.
/// - Parameters:
/// - sessionIdentifier: The session identifier from the callback
/// - completionHandler: Completion handler to call when processing is done
/// - parseHandler: Closure to parse and persist the downloaded file
public func handleBackgroundSessionEvent(
sessionIdentifier: String,
completionHandler: @escaping () -> Void,
parseHandler: @escaping (URL, Int64) async throws -> Void
) async {
DDLogInfo("🟣 Handling background session event for: \(sessionIdentifier)")

// Load the saved download state to know which site this is for
guard let state = BackgroundDownloadState.load(for: sessionIdentifier) else {
DDLogError("⛔️ No saved state found for background download session: \(sessionIdentifier)")
completionHandler()
return
}

// Reconnect to the background session and get the downloaded file
guard let fileURL = await backgroundDownloader.reconnectToSession(identifier: sessionIdentifier,
allowCellular: true,
completionHandler: completionHandler) else {
DDLogError("⛔️ Failed to reconnect to background download session")
BackgroundDownloadState.clear()
return
}

DDLogInfo("🟣 Background download file ready at: \(fileURL.path)")

// Parse the catalog file in this background window (~30 seconds)
// TODO: WOOMOB-1677 - For very large catalogs, consider hybrid approach: try immediate parse, defer if timeout.
do {
try await parseHandler(fileURL, state.siteID)
DDLogInfo("βœ… Background catalog processing completed successfully")
} catch {
DDLogError("⛔️ Failed to process catalog in background: \(error)")
}

// Clean up state
BackgroundDownloadState.clear()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ public protocol BackgroundDownloadProtocol {
/// - Parameter completionHandler: Handler to call when background download completes.
func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> Void)

/// Reconnects to an existing background session after app wake.
/// Call this from AppDelegate when iOS wakes the app for background URLSession events.
/// - Parameters:
/// - sessionIdentifier: The session identifier from the callback
/// - allowCellular: Whether cellular data should be allowed
/// - completionHandler: Completion handler to call when all events are processed
/// - Returns: Downloaded file URL if download completed, nil if still in progress
func reconnectToSession(identifier sessionIdentifier: String,
allowCellular: Bool,
completionHandler: @escaping () -> Void) async -> URL?

/// Cancels all active downloads for the session.
/// - Parameter sessionIdentifier: The session identifier to cancel.
func cancelDownloads(for sessionIdentifier: String) async
Expand Down
22 changes: 22 additions & 0 deletions Modules/Sources/Networking/Network/BackgroundDownloadService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@ extension BackgroundDownloadService: BackgroundDownloadProtocol {
backgroundCompletionHandler = completionHandler
}

/// Reconnects to an existing background session after app wake.
/// Call this from AppDelegate when iOS wakes the app for background URLSession events.
/// - Parameters:
/// - sessionIdentifier: The session identifier from the callback
/// - completionHandler: Completion handler to call when all events are processed
/// - Returns: Downloaded file URL if download completed, nil if still in progress
public func reconnectToSession(identifier sessionIdentifier: String,
allowCellular: Bool,
completionHandler: @escaping () -> Void) async -> URL? {
DDLogInfo("🟣 Reconnecting to background session: \(sessionIdentifier)")

setBackgroundCompletionHandler(completionHandler)

// Create session with same identifier - this reconnects to the existing download
let session = createBackgroundSession(identifier: sessionIdentifier, allowCellular: allowCellular)

// Wait for delegate callbacks to complete
return try? await withCheckedThrowingContinuation { continuation in
downloadContinuations[sessionIdentifier] = continuation
}
}

public func cancelDownloads(for sessionIdentifier: String) async {
if let task = downloadTasks[sessionIdentifier] {
task.cancel()
Expand Down
34 changes: 34 additions & 0 deletions Modules/Sources/Networking/Network/BackgroundDownloadState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

/// Persisted state for background catalog downloads.
/// Allows the app to resume processing downloads after being terminated.
public struct BackgroundDownloadState: Codable {
let sessionIdentifier: String
let siteID: Int64
let startedAt: Date

private static let userDefaultsKey = "com.woocommerce.pos.backgroundDownloadState"

/// Saves download state for later retrieval.
public static func save(_ state: BackgroundDownloadState) {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(state) {
UserDefaults.standard.set(encoded, forKey: userDefaultsKey)
}
}

/// Loads saved download state for a specific session identifier.
public static func load(for sessionIdentifier: String) -> BackgroundDownloadState? {
guard let data = UserDefaults.standard.data(forKey: userDefaultsKey),
let state = try? JSONDecoder().decode(BackgroundDownloadState.self, from: data),
state.sessionIdentifier == sessionIdentifier else {
return nil
}
return state
}

/// Clears saved download state.
public static func clear() {
UserDefaults.standard.removeObject(forKey: userDefaultsKey)
}
}
28 changes: 26 additions & 2 deletions Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ public protocol POSCatalogSyncRemoteProtocol {
downloadURL: String,
allowCellular: Bool) async throws -> POSCatalogResponse

/// Parses a downloaded catalog file.
/// Used for processing background downloads after app wake.
/// - Parameters:
/// - fileURL: Local file URL of the downloaded catalog.
/// - siteID: Site ID for proper mapping.
/// - Returns: Parsed POS catalog response.
func parseDownloadedCatalog(from fileURL: URL, siteID: Int64) async throws -> POSCatalogResponse

/// Loads POS products for full sync.
///
/// - Parameters:
Expand Down Expand Up @@ -203,18 +211,34 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {
}

let sessionIdentifier = "\(POSCatalogSyncConstants.backgroundDownloadSessionPrefix).\(siteID).\(UUID().uuidString)"

// Save download state so we can resume if app is terminated
let downloadState = BackgroundDownloadState(
sessionIdentifier: sessionIdentifier,
siteID: siteID,
startedAt: Date()
)
BackgroundDownloadState.save(downloadState)

let fileURL = try await backgroundDownloader.downloadFile(from: url,
sessionIdentifier: sessionIdentifier,
allowCellular: allowCellular)
return try await parseDownloadedCatalog(from: fileURL, siteID: siteID)

// Download completed - parse the file
let catalogResponse = try await parseDownloadedCatalog(from: fileURL, siteID: siteID)

// Clear the saved state since we successfully completed
BackgroundDownloadState.clear()

return catalogResponse
}

/// Parses the downloaded catalog file.
/// - Parameters:
/// - fileURL: Local file URL of the downloaded catalog.
/// - siteID: Site ID for proper mapping.
/// - Returns: Parsed POS catalog.
private func parseDownloadedCatalog(from fileURL: URL, siteID: Int64) async throws -> POSCatalogResponse {
public func parseDownloadedCatalog(from fileURL: URL, siteID: Int64) async throws -> POSCatalogResponse {
let data = try Data(contentsOf: fileURL)

// Clean up the downloaded file after reading.
Expand Down
4 changes: 4 additions & 0 deletions Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,10 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol
func stopOngoingSyncs(for siteID: Int64) async {
// Preview implementation - no-op
}

func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws {
// no-op
}
}

#endif
28 changes: 28 additions & 0 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ public protocol POSCatalogFullSyncServiceProtocol {
/// - allowCellular: Should cellular data be used if required.
/// - Returns: The synced catalog containing products and variations
func startFullSync(for siteID: Int64, regenerateCatalog: Bool, allowCellular: Bool) async throws -> POSCatalog

/// Parses and persists a downloaded catalog file from a background download.
/// - Parameters:
/// - fileURL: Local file URL of the downloaded catalog
/// - siteID: Site ID for this catalog
/// - Returns: The parsed catalog
func parseAndPersistBackgroundDownload(fileURL: URL, siteID: Int64) async throws -> POSCatalog
}

/// POS catalog from full sync.
Expand Down Expand Up @@ -200,4 +207,25 @@ private extension POSCatalogFullSyncService {

throw POSCatalogSyncError.timeout
}

public func parseAndPersistBackgroundDownload(fileURL: URL, siteID: Int64) async throws -> POSCatalog {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: We can move this signature to a different extension or to the class. I get a warning here: 'public' modifier conflicts with extension's default access of 'private'

DDLogInfo("🟣 Parsing background catalog download for site \(siteID)")

let syncStartDate = Date.now
let catalogResponse = try await syncRemote.parseDownloadedCatalog(from: fileURL, siteID: siteID)

let catalog = POSCatalog(
products: catalogResponse.products,
variations: catalogResponse.variations,
syncDate: syncStartDate
)

DDLogInfo("βœ… Loaded \(catalog.products.count) products and \(catalog.variations.count) variations for siteID \(siteID)")

// Persist to database
try await persistenceService.replaceAllCatalogData(catalog, siteID: siteID)
DDLogInfo("βœ… Persisted \(catalog.products.count) products and \(catalog.variations.count) variations to database for siteID \(siteID)")

return catalog
}
}
23 changes: 23 additions & 0 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ public protocol POSCatalogSyncCoordinatorProtocol {
/// Stops all ongoing sync tasks for the specified site
/// - Parameter siteID: The site ID to stop syncs for
func stopOngoingSyncs(for siteID: Int64) async

/// Processes a completed background catalog download.
/// Called when the app is woken by iOS for a background URLSession completion.
/// Parses and persists the catalog, then updates sync state.
/// - Parameters:
/// - fileURL: Local file URL of the downloaded catalog
/// - siteID: Site ID for this catalog
func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws
}

public extension POSCatalogSyncCoordinatorProtocol {
Expand Down Expand Up @@ -423,6 +431,21 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
}
}
}

public func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws {
DDLogInfo("🟣 POSCatalogSyncCoordinator: Processing background download for site \(siteID)")

// Parse and persist using the full sync service
let catalog = try await fullSyncService.parseAndPersistBackgroundDownload(fileURL: fileURL, siteID: siteID)

DDLogInfo("βœ… Background catalog processed: \(catalog.products.count) products, \(catalog.variations.count) variations")

// Update sync state to completed
emitSyncState(.syncCompleted(siteID: siteID))

// Record first sync date if needed
recordFirstSyncIfNeeded(for: siteID)
}
}

// MARK: - Syncing State
Expand Down
26 changes: 17 additions & 9 deletions WooCommerce/Classes/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import UIKit
import Combine
import Storage
import class Networking.UserAgent
import class Networking.BackgroundCatalogDownloadCoordinator
import Experiments
import protocol WooFoundation.Analytics
import protocol Yosemite.StoresManager
Expand Down Expand Up @@ -149,16 +150,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
completionHandler: @escaping () -> Void) {
DDLogInfo("🟣 Handling background URLSession events for identifier: \(identifier)")

// Store the completion handler for the background session
// The BackgroundDownloadService will invoke this when all events are processed
if identifier.hasPrefix("com.woocommerce.pos.catalog.download") {
// In a production implementation, this would be passed to the BackgroundDownloadService
// For now, we store it and the service will need to retrieve it
// TODO: WOOMOB-1173 - Wire this to BackgroundDownloadService.setBackgroundCompletionHandler
// TODO: WOOMOB-1677 - Catalog parsing happens in the ~30s window after download completes.
// For very large catalogs, consider hybrid approach: try immediate parse, defer if timeout.
DDLogInfo("🟣 Background catalog download session completion handler stored")
completionHandler()
// Handle POS catalog download completion in background
Task {
let downloadCoordinator = BackgroundCatalogDownloadCoordinator()
await downloadCoordinator.handleBackgroundSessionEvent(
sessionIdentifier: identifier,
completionHandler: completionHandler,
parseHandler: { fileURL, siteID in
// Use the POS catalog sync coordinator to parse and persist
guard let coordinator = ServiceLocator.stores.posCatalogSyncCoordinator else {
throw NSError(domain: "com.woocommerce.pos", code: -1,
userInfo: [NSLocalizedDescriptionKey: "POS catalog coordinator not available"])
}
try await coordinator.processBackgroundDownload(fileURL: fileURL, siteID: siteID)
}
)
}
} else {
// Unknown session identifier - call completion handler immediately
DDLogWarn("🟣 Unknown background URLSession identifier: \(identifier)")
Expand Down