Skip to content

Commit 5a7449f

Browse files
authored
[Local Catalog] Persist catalog downloads in the background (#16342)
2 parents d283dc7 + d0558a2 commit 5a7449f

18 files changed

+670
-15
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import Foundation
2+
import CocoaLumberjackSwift
3+
4+
/// Coordinates background catalog downloads, including handling app wake events.
5+
public class BackgroundCatalogDownloadCoordinator {
6+
private let backgroundDownloader: BackgroundDownloadProtocol
7+
8+
public init(backgroundDownloader: BackgroundDownloadProtocol = BackgroundDownloadService()) {
9+
self.backgroundDownloader = backgroundDownloader
10+
}
11+
12+
/// Handles a background URLSession wake event.
13+
/// Called from AppDelegate when iOS wakes the app for a completed download.
14+
/// - Parameters:
15+
/// - sessionIdentifier: The session identifier from the callback
16+
/// - completionHandler: Completion handler to call when processing is done
17+
/// - parseHandler: Closure to parse and persist the downloaded file
18+
public func handleBackgroundSessionEvent(
19+
sessionIdentifier: String,
20+
completionHandler: @escaping () -> Void,
21+
parseHandler: @escaping (URL, Int64) async throws -> Void
22+
) async {
23+
DDLogInfo("🟣 Handling background session event for: \(sessionIdentifier)")
24+
25+
// Load the saved download state to know which site this is for
26+
guard let state = BackgroundDownloadState.load(for: sessionIdentifier) else {
27+
DDLogError("⛔️ No saved state found for background download session: \(sessionIdentifier)")
28+
completionHandler()
29+
return
30+
}
31+
32+
// Reconnect to the background session and get the downloaded file
33+
guard let fileURL = await backgroundDownloader.reconnectToSession(identifier: sessionIdentifier,
34+
allowCellular: true,
35+
completionHandler: completionHandler) else {
36+
DDLogError("⛔️ Failed to reconnect to background download session")
37+
BackgroundDownloadState.clear()
38+
return
39+
}
40+
41+
DDLogInfo("🟣 Background download file ready at: \(fileURL.path)")
42+
43+
// Parse the catalog file in this background window (~30 seconds)
44+
// TODO: WOOMOB-1677 - For very large catalogs, consider hybrid approach: try immediate parse, defer if timeout.
45+
do {
46+
try await parseHandler(fileURL, state.siteID)
47+
DDLogInfo("✅ Background catalog processing completed successfully")
48+
} catch {
49+
DDLogError("⛔️ Failed to process catalog in background: \(error)")
50+
}
51+
52+
// Clean up state
53+
BackgroundDownloadState.clear()
54+
}
55+
}

Modules/Sources/Networking/Network/BackgroundDownloadProtocol.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ public protocol BackgroundDownloadProtocol {
1515
/// - Parameter completionHandler: Handler to call when background download completes.
1616
func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> Void)
1717

18+
/// Reconnects to an existing background session after app wake.
19+
/// Call this from AppDelegate when iOS wakes the app for background URLSession events.
20+
/// - Parameters:
21+
/// - sessionIdentifier: The session identifier from the callback
22+
/// - allowCellular: Whether cellular data should be allowed
23+
/// - completionHandler: Completion handler to call when all events are processed
24+
/// - Returns: Downloaded file URL if download completed, nil if still in progress
25+
func reconnectToSession(identifier sessionIdentifier: String,
26+
allowCellular: Bool,
27+
completionHandler: @escaping () -> Void) async -> URL?
28+
1829
/// Cancels all active downloads for the session.
1930
/// - Parameter sessionIdentifier: The session identifier to cancel.
2031
func cancelDownloads(for sessionIdentifier: String) async

Modules/Sources/Networking/Network/BackgroundDownloadService.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,28 @@ extension BackgroundDownloadService: BackgroundDownloadProtocol {
3636
backgroundCompletionHandler = completionHandler
3737
}
3838

39+
/// Reconnects to an existing background session after app wake.
40+
/// Call this from AppDelegate when iOS wakes the app for background URLSession events.
41+
/// - Parameters:
42+
/// - sessionIdentifier: The session identifier from the callback
43+
/// - completionHandler: Completion handler to call when all events are processed
44+
/// - Returns: Downloaded file URL if download completed, nil if still in progress
45+
public func reconnectToSession(identifier sessionIdentifier: String,
46+
allowCellular: Bool,
47+
completionHandler: @escaping () -> Void) async -> URL? {
48+
DDLogInfo("🟣 Reconnecting to background session: \(sessionIdentifier)")
49+
50+
setBackgroundCompletionHandler(completionHandler)
51+
52+
// Create session with same identifier - this reconnects to the existing download
53+
let session = createBackgroundSession(identifier: sessionIdentifier, allowCellular: allowCellular)
54+
55+
// Wait for delegate callbacks to complete
56+
return try? await withCheckedThrowingContinuation { continuation in
57+
downloadContinuations[sessionIdentifier] = continuation
58+
}
59+
}
60+
3961
public func cancelDownloads(for sessionIdentifier: String) async {
4062
if let task = downloadTasks[sessionIdentifier] {
4163
task.cancel()
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Foundation
2+
3+
/// Persisted state for background catalog downloads.
4+
/// Allows the app to resume processing downloads after being terminated.
5+
public struct BackgroundDownloadState: Codable {
6+
let sessionIdentifier: String
7+
let siteID: Int64
8+
9+
private static let userDefaultsKey = "com.woocommerce.pos.backgroundDownloadState"
10+
private static var userDefaults: UserDefaults = .standard
11+
12+
/// Configure UserDefaults instance for testing.
13+
/// - Parameter userDefaults: The UserDefaults instance to use for persistence.
14+
// periphery:ignore - required by tests
15+
public static func configure(userDefaults: UserDefaults) {
16+
self.userDefaults = userDefaults
17+
}
18+
19+
/// Saves download state for later retrieval.
20+
public static func save(_ state: BackgroundDownloadState) {
21+
let encoder = JSONEncoder()
22+
if let encoded = try? encoder.encode(state) {
23+
userDefaults.set(encoded, forKey: userDefaultsKey)
24+
}
25+
}
26+
27+
/// Loads saved download state for a specific session identifier.
28+
public static func load(for sessionIdentifier: String) -> BackgroundDownloadState? {
29+
guard let data = userDefaults.data(forKey: userDefaultsKey),
30+
let state = try? JSONDecoder().decode(BackgroundDownloadState.self, from: data),
31+
state.sessionIdentifier == sessionIdentifier else {
32+
return nil
33+
}
34+
return state
35+
}
36+
37+
/// Clears saved download state.
38+
public static func clear() {
39+
userDefaults.removeObject(forKey: userDefaultsKey)
40+
}
41+
}

Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ public protocol POSCatalogSyncRemoteProtocol {
4747
downloadURL: String,
4848
allowCellular: Bool) async throws -> POSCatalogResponse
4949

50+
/// Parses a downloaded catalog file.
51+
/// Used for processing background downloads after app wake.
52+
/// - Parameters:
53+
/// - fileURL: Local file URL of the downloaded catalog.
54+
/// - siteID: Site ID for proper mapping.
55+
/// - Returns: Parsed POS catalog response.
56+
func parseDownloadedCatalog(from fileURL: URL, siteID: Int64) async throws -> POSCatalogResponse
57+
5058
/// Loads POS products for full sync.
5159
///
5260
/// - Parameters:
@@ -203,18 +211,33 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {
203211
}
204212

205213
let sessionIdentifier = "\(POSCatalogSyncConstants.backgroundDownloadSessionPrefix).\(siteID).\(UUID().uuidString)"
214+
215+
// Save download state so we can resume if app is terminated
216+
let downloadState = BackgroundDownloadState(
217+
sessionIdentifier: sessionIdentifier,
218+
siteID: siteID
219+
)
220+
BackgroundDownloadState.save(downloadState)
221+
206222
let fileURL = try await backgroundDownloader.downloadFile(from: url,
207223
sessionIdentifier: sessionIdentifier,
208224
allowCellular: allowCellular)
209-
return try await parseDownloadedCatalog(from: fileURL, siteID: siteID)
225+
226+
// Download completed - parse the file
227+
let catalogResponse = try await parseDownloadedCatalog(from: fileURL, siteID: siteID)
228+
229+
// Clear the saved state since we successfully completed
230+
BackgroundDownloadState.clear()
231+
232+
return catalogResponse
210233
}
211234

212235
/// Parses the downloaded catalog file.
213236
/// - Parameters:
214237
/// - fileURL: Local file URL of the downloaded catalog.
215238
/// - siteID: Site ID for proper mapping.
216239
/// - Returns: Parsed POS catalog.
217-
func parseDownloadedCatalog(from fileURL: URL, siteID: Int64) async throws -> POSCatalogResponse {
240+
public func parseDownloadedCatalog(from fileURL: URL, siteID: Int64) async throws -> POSCatalogResponse {
218241
let data = try Data(contentsOf: fileURL)
219242

220243
// Clean up downloaded files, but only if they're in our Documents directory.

Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,20 @@ final class POSSettingsLocalCatalogViewModel {
1616
private let catalogSettingsService: POSCatalogSettingsServiceProtocol
1717
private let catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol
1818
private let siteSettings: SiteSpecificAppSettingsStoreMethodsProtocol
19+
private let syncStateModel: POSCatalogSyncStateModel
1920
private let dateFormatter: RelativeDateTimeFormatter = {
2021
let formatter = RelativeDateTimeFormatter()
2122
formatter.dateTimeStyle = .named
2223
formatter.unitsStyle = .full
2324
return formatter
2425
}()
2526

27+
private var syncStateObservationTask: Task<Void, Never>?
28+
29+
private var currentSyncState: POSCatalogSyncState {
30+
syncStateModel.state[siteID] ?? .syncNeverDone(siteID: siteID)
31+
}
32+
2633
var allowFullSyncOnCellular: Bool {
2734
get {
2835
siteSettings.getPOSLocalCatalogCellularDataAllowed(siteID: siteID)
@@ -39,7 +46,15 @@ final class POSSettingsLocalCatalogViewModel {
3946
self.siteID = siteID
4047
self.catalogSettingsService = catalogSettingsService
4148
self.catalogSyncCoordinator = catalogSyncCoordinator
49+
self.syncStateModel = catalogSyncCoordinator.fullSyncStateModel
4250
self.siteSettings = siteSettings ?? SiteSpecificAppSettingsStoreMethods(fileStorage: PListFileStorage())
51+
52+
// Observe sync state changes to update UI when sync completes in background
53+
startObservingSyncState()
54+
}
55+
56+
deinit {
57+
syncStateObservationTask?.cancel()
4358
}
4459

4560
@MainActor
@@ -63,13 +78,63 @@ final class POSSettingsLocalCatalogViewModel {
6378
@MainActor
6479
func refreshCatalog() async {
6580
isRefreshingCatalog = true
66-
defer { isRefreshingCatalog = false }
6781

6882
do {
6983
try await catalogSyncCoordinator.performFullSync(for: siteID, regenerateCatalog: true)
84+
// Sync completed synchronously - update UI
85+
isRefreshingCatalog = false
7086
await loadCatalogData()
7187
} catch {
7288
DDLogError("⛔️ POSSettingsLocalCatalog: Failed to refresh catalog: \(error)")
89+
isRefreshingCatalog = false
90+
}
91+
}
92+
93+
/// Starts observing sync state changes to update UI when sync completes in background
94+
private func startObservingSyncState() {
95+
syncStateObservationTask = Task { @MainActor in
96+
var previousState = currentSyncState
97+
98+
while !Task.isCancelled {
99+
// Wait for the next state change
100+
await observeNextStateChange()
101+
102+
// Read the new state after change is detected
103+
let newState = currentSyncState
104+
guard newState != previousState else { continue }
105+
106+
// Handle terminal states when user initiated refresh
107+
switch newState {
108+
case .syncCompleted, .syncFailed, .initialSyncFailed:
109+
// Sync finished - clear the refreshing state if it was set
110+
if isRefreshingCatalog {
111+
isRefreshingCatalog = false
112+
// Reload catalog data to show updated info
113+
await loadCatalogData()
114+
}
115+
case .syncStarted, .initialSyncStarted:
116+
// Sync is running - keep spinner active
117+
break
118+
case .syncNeverDone:
119+
// No sync has been done
120+
break
121+
}
122+
previousState = newState
123+
}
124+
}
125+
}
126+
127+
/// Waits for the next change to the observed sync state.
128+
/// Re-registers observation each time it's called.
129+
private func observeNextStateChange() async {
130+
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
131+
withObservationTracking {
132+
// Access the observed property to register observation
133+
_ = currentSyncState
134+
} onChange: {
135+
// When state changes, resume the continuation
136+
continuation.resume()
137+
}
73138
}
74139
}
75140
}

Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,10 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol
645645
func stopOngoingSyncs(for siteID: Int64) async {
646646
// Preview implementation - no-op
647647
}
648+
649+
func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws {
650+
// no-op
651+
}
648652
}
649653

650654
#endif

Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ public protocol POSCatalogFullSyncServiceProtocol {
1717
/// - allowCellular: Should cellular data be used if required.
1818
/// - Returns: The synced catalog containing products and variations
1919
func startFullSync(for siteID: Int64, regenerateCatalog: Bool, allowCellular: Bool) async throws -> POSCatalog
20+
21+
/// Parses and persists a downloaded catalog file from a background download.
22+
/// - Parameters:
23+
/// - fileURL: Local file URL of the downloaded catalog
24+
/// - siteID: Site ID for this catalog
25+
/// - Returns: The parsed catalog
26+
func parseAndPersistBackgroundDownload(fileURL: URL, siteID: Int64) async throws -> POSCatalog
2027
}
2128

2229
/// POS catalog from full sync.
@@ -98,6 +105,27 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol
98105
throw error
99106
}
100107
}
108+
109+
public func parseAndPersistBackgroundDownload(fileURL: URL, siteID: Int64) async throws -> POSCatalog {
110+
DDLogInfo("🟣 Parsing background catalog download for site \(siteID)")
111+
112+
let syncStartDate = Date.now
113+
let catalogResponse = try await syncRemote.parseDownloadedCatalog(from: fileURL, siteID: siteID)
114+
115+
let catalog = POSCatalog(
116+
products: catalogResponse.products,
117+
variations: catalogResponse.variations,
118+
syncDate: syncStartDate
119+
)
120+
121+
DDLogInfo("✅ Loaded \(catalog.products.count) products and \(catalog.variations.count) variations for siteID \(siteID)")
122+
123+
// Persist to database
124+
try await persistenceService.replaceAllCatalogData(catalog, siteID: siteID)
125+
DDLogInfo("✅ Persisted \(catalog.products.count) products and \(catalog.variations.count) variations to database for siteID \(siteID)")
126+
127+
return catalog
128+
}
101129
}
102130

103131
// MARK: - Remote Loading

Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ public protocol POSCatalogSyncCoordinatorProtocol {
4646
/// Stops all ongoing sync tasks for the specified site
4747
/// - Parameter siteID: The site ID to stop syncs for
4848
func stopOngoingSyncs(for siteID: Int64) async
49+
50+
/// Processes a completed background catalog download.
51+
/// Called when the app is woken by iOS for a background URLSession completion.
52+
/// Parses and persists the catalog, then updates sync state.
53+
/// - Parameters:
54+
/// - fileURL: Local file URL of the downloaded catalog
55+
/// - siteID: Site ID for this catalog
56+
func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws
4957
}
5058

5159
public extension POSCatalogSyncCoordinatorProtocol {
@@ -423,6 +431,21 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
423431
}
424432
}
425433
}
434+
435+
public func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws {
436+
DDLogInfo("🟣 POSCatalogSyncCoordinator: Processing background download for site \(siteID)")
437+
438+
// Parse and persist using the full sync service
439+
let catalog = try await fullSyncService.parseAndPersistBackgroundDownload(fileURL: fileURL, siteID: siteID)
440+
441+
DDLogInfo("✅ Background catalog processed: \(catalog.products.count) products, \(catalog.variations.count) variations")
442+
443+
// Update sync state to completed
444+
emitSyncState(.syncCompleted(siteID: siteID))
445+
446+
// Record first sync date if needed
447+
recordFirstSyncIfNeeded(for: siteID)
448+
}
426449
}
427450

428451
// MARK: - Syncing State

0 commit comments

Comments
 (0)