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

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

public init(backgroundDownloader: BackgroundDownloadProtocol = BackgroundDownloadService()) {
self.backgroundDownloader = backgroundDownloader
}

/// 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
33 changes: 33 additions & 0 deletions Modules/Sources/Networking/Network/BackgroundDownloadState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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

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)
}
}
27 changes: 25 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,33 @@ 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
)
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.
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 downloaded files, but only if they're in our Documents directory.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,20 @@ final class POSSettingsLocalCatalogViewModel {
private let catalogSettingsService: POSCatalogSettingsServiceProtocol
private let catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol
private let siteSettings: SiteSpecificAppSettingsStoreMethodsProtocol
private let syncStateModel: POSCatalogSyncStateModel
private let dateFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.dateTimeStyle = .named
formatter.unitsStyle = .full
return formatter
}()

private var syncStateObservationTask: Task<Void, Never>?

private var currentSyncState: POSCatalogSyncState {
syncStateModel.state[siteID] ?? .syncNeverDone(siteID: siteID)
}

var allowFullSyncOnCellular: Bool {
get {
siteSettings.getPOSLocalCatalogCellularDataAllowed(siteID: siteID)
Expand All @@ -39,7 +46,15 @@ final class POSSettingsLocalCatalogViewModel {
self.siteID = siteID
self.catalogSettingsService = catalogSettingsService
self.catalogSyncCoordinator = catalogSyncCoordinator
self.syncStateModel = catalogSyncCoordinator.fullSyncStateModel
self.siteSettings = siteSettings ?? SiteSpecificAppSettingsStoreMethods(fileStorage: PListFileStorage())

// Observe sync state changes to update UI when sync completes in background
startObservingSyncState()
}

deinit {
syncStateObservationTask?.cancel()
}

@MainActor
Expand All @@ -63,13 +78,56 @@ final class POSSettingsLocalCatalogViewModel {
@MainActor
func refreshCatalog() async {
isRefreshingCatalog = true
defer { isRefreshingCatalog = false }

do {
try await catalogSyncCoordinator.performFullSync(for: siteID, regenerateCatalog: true)
// Sync completed synchronously - update UI
isRefreshingCatalog = false
await loadCatalogData()
} catch {
DDLogError("⛔️ POSSettingsLocalCatalog: Failed to refresh catalog: \(error)")
isRefreshingCatalog = false
}
}

/// Starts observing sync state changes to update UI when sync completes in background
private func startObservingSyncState() {
syncStateObservationTask = Task { @MainActor in
var previousState = currentSyncState

while !Task.isCancelled {
// Use withObservationTracking to detect changes
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
withObservationTracking {
// Access the observed property to register observation
_ = currentSyncState
} onChange: {
// When state changes, resume the continuation
continuation.resume()
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a bit tricky to wrap my head around, I have a question: When we setup the observation through withCheckedContinuation, and then becomes inactive before reading the new state (let newState = currentSyncState)... could there be state changes that are missed before we loop back to the beginning of the loop?

It was my understanding that when .onChange fires we must callwithObservationTracking again to re-register, so the gap between these calls is not observed. But this could be just a miss-understanding on my part, since the signature does not return any cancellable/subscription object but is just a closure.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, it's a little odd. The while loop takes care of re-upping the observation tracking, but I've changed it to make that a little clearer.


// Check if state actually changed
let newState = currentSyncState
if newState != previousState {
switch newState {
case .syncCompleted, .syncFailed, .initialSyncFailed:
// Sync finished - clear the refreshing state if it was set
if isRefreshingCatalog {
Copy link
Contributor

Choose a reason for hiding this comment

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

We only toggle the value of isRefreshingCatalog when is user-initiated through refreshCatalog(). If a non-user-initiated background sync completes this always be false, so UI would only update when user-initiated. Is intended for now?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, that's totally intended. It'd be strange to show a button spinner for something the user didn't do, IMO

isRefreshingCatalog = false
// Reload catalog data to show updated info
await loadCatalogData()
}
case .syncStarted, .initialSyncStarted:
// Sync is running - keep spinner active
break
case .syncNeverDone:
// No sync has been done
break
}
previousState = newState
}
}
}
}
}
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
Loading
Loading