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
41 changes: 41 additions & 0 deletions Modules/Sources/Networking/Network/BackgroundDownloadState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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"
private static var userDefaults: UserDefaults = .standard

/// Configure UserDefaults instance for testing.
/// - Parameter userDefaults: The UserDefaults instance to use for persistence.
// periphery:ignore - required by tests
public static func configure(userDefaults: UserDefaults) {
self.userDefaults = userDefaults
}

/// Saves download state for later retrieval.
public static func save(_ state: BackgroundDownloadState) {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(state) {
userDefaults.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.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.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,63 @@ 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 {
// Wait for the next state change
await observeNextStateChange()

// Read the new state after change is detected
let newState = currentSyncState
guard newState != previousState else { continue }

// Handle terminal states when user initiated refresh
switch newState {
case .syncCompleted, .syncFailed, .initialSyncFailed:
// Sync finished - clear the refreshing state if it was set
if isRefreshingCatalog {
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
}
}
}

/// Waits for the next change to the observed sync state.
/// Re-registers observation each time it's called.
private func observeNextStateChange() async {
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()
}
}
}
}
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 @@ -98,6 +105,27 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol
throw error
}
}

public func parseAndPersistBackgroundDownload(fileURL: URL, siteID: Int64) async throws -> POSCatalog {
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
}
}

// MARK: - Remote Loading
Expand Down
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