Skip to content

Commit 46e6186

Browse files
authored
[Local Catalog] Background catalog download (#16337)
2 parents 01c729e + 3980e7e commit 46e6186

File tree

9 files changed

+656
-37
lines changed

9 files changed

+656
-37
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// periphery:ignore:all
2+
import Foundation
3+
4+
/// Protocol for handling background downloads with app suspension support.
5+
public protocol BackgroundDownloadProtocol {
6+
/// Downloads a file from the specified URL in the background.
7+
/// - Parameters:
8+
/// - url: The URL to download from.
9+
/// - sessionIdentifier: Unique identifier for the background session.
10+
/// - allowCellular: Whether cellular data should be allowed for this download.
11+
/// - Returns: Local file URL where the downloaded content is stored.
12+
func downloadFile(from url: URL, sessionIdentifier: String, allowCellular: Bool) async throws -> URL
13+
14+
/// Sets up background app suspension handling.
15+
/// - Parameter completionHandler: Handler to call when background download completes.
16+
func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> Void)
17+
18+
/// Cancels all active downloads for the session.
19+
/// - Parameter sessionIdentifier: The session identifier to cancel.
20+
func cancelDownloads(for sessionIdentifier: String) async
21+
}
22+
23+
/// Progress and status information for background downloads.
24+
public struct BackgroundDownloadProgress {
25+
public let bytesDownloaded: Int64
26+
public let totalBytes: Int64
27+
public let progress: Double
28+
29+
public init(bytesDownloaded: Int64, totalBytes: Int64) {
30+
self.bytesDownloaded = bytesDownloaded
31+
self.totalBytes = totalBytes
32+
self.progress = totalBytes > 0 ? Double(bytesDownloaded) / Double(totalBytes) : 0.0
33+
}
34+
}
35+
36+
/// Errors that can occur during background downloads.
37+
public enum BackgroundDownloadError: Error, LocalizedError, Equatable {
38+
case invalidURL
39+
case sessionCreationFailed
40+
case downloadFailed(Error)
41+
case fileNotFound
42+
case cancelled
43+
44+
public var errorDescription: String? {
45+
switch self {
46+
case .invalidURL:
47+
return "The provided URL is invalid"
48+
case .sessionCreationFailed:
49+
return "Failed to create background download session"
50+
case .downloadFailed(let error):
51+
return "Download failed: \(error.localizedDescription)"
52+
case .fileNotFound:
53+
return "Downloaded file not found"
54+
case .cancelled:
55+
return "Download was cancelled"
56+
}
57+
}
58+
59+
public static func == (lhs: BackgroundDownloadError, rhs: BackgroundDownloadError) -> Bool {
60+
switch (lhs, rhs) {
61+
case (.invalidURL, .invalidURL):
62+
return true
63+
case (.sessionCreationFailed, .sessionCreationFailed):
64+
return true
65+
case (.downloadFailed(let lhsError), .downloadFailed(let rhsError)):
66+
return lhsError.localizedDescription == rhsError.localizedDescription
67+
case (.fileNotFound, .fileNotFound):
68+
return true
69+
case (.cancelled, .cancelled):
70+
return true
71+
default:
72+
return false
73+
}
74+
}
75+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// periphery:ignore:all
2+
import CocoaLumberjackSwift
3+
import Foundation
4+
5+
/// Service for handling background downloads using `URLSessionConfiguration.background`.
6+
/// Follows Apple's guidelines for background downloads with app suspension support.
7+
public class BackgroundDownloadService: NSObject {
8+
private var backgroundCompletionHandler: (() -> Void)?
9+
private var downloadTasks: [String: URLSessionDownloadTask] = [:]
10+
private var downloadContinuations: [String: CheckedContinuation<URL, Error>] = [:]
11+
private let fileManager: FileManager
12+
13+
public init(fileManager: FileManager = .default) {
14+
self.fileManager = fileManager
15+
super.init()
16+
}
17+
}
18+
19+
// MARK: - BackgroundDownloadProtocol
20+
21+
extension BackgroundDownloadService: BackgroundDownloadProtocol {
22+
public func downloadFile(from url: URL, sessionIdentifier: String, allowCellular: Bool) async throws -> URL {
23+
try await withCheckedThrowingContinuation { continuation in
24+
let session = createBackgroundSession(identifier: sessionIdentifier, allowCellular: allowCellular)
25+
let downloadTask = session.downloadTask(with: url)
26+
27+
// Stores the continuation for later use in delegate methods.
28+
downloadContinuations[sessionIdentifier] = continuation
29+
downloadTasks[sessionIdentifier] = downloadTask
30+
31+
downloadTask.resume()
32+
}
33+
}
34+
35+
public func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> Void) {
36+
backgroundCompletionHandler = completionHandler
37+
}
38+
39+
public func cancelDownloads(for sessionIdentifier: String) async {
40+
if let task = downloadTasks[sessionIdentifier] {
41+
task.cancel()
42+
downloadTasks.removeValue(forKey: sessionIdentifier)
43+
44+
// Resumes continuation with cancellation error.
45+
if let continuation = downloadContinuations.removeValue(forKey: sessionIdentifier) {
46+
continuation.resume(throwing: BackgroundDownloadError.cancelled)
47+
}
48+
}
49+
}
50+
51+
// MARK: - Private Methods
52+
53+
private func createBackgroundSession(identifier: String, allowCellular: Bool) -> URLSession {
54+
let config = URLSessionConfiguration.background(withIdentifier: identifier)
55+
56+
// Configure for background downloads as per Apple guidelines
57+
config.sessionSendsLaunchEvents = true
58+
config.isDiscretionary = false // Don't wait for optimal conditions
59+
config.allowsCellularAccess = allowCellular
60+
config.timeoutIntervalForRequest = 30
61+
config.timeoutIntervalForResource = 300 // 5 minutes
62+
63+
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
64+
}
65+
66+
private func handleDownloadCompletion(for sessionIdentifier: String, fileURL: URL?, error: Error?) {
67+
guard let continuation = downloadContinuations.removeValue(forKey: sessionIdentifier) else {
68+
return
69+
}
70+
71+
downloadTasks.removeValue(forKey: sessionIdentifier)
72+
73+
if let error {
74+
continuation.resume(throwing: BackgroundDownloadError.downloadFailed(error))
75+
} else if let fileURL {
76+
continuation.resume(returning: fileURL)
77+
} else {
78+
continuation.resume(throwing: BackgroundDownloadError.fileNotFound)
79+
}
80+
}
81+
}
82+
83+
// MARK: - URLSessionDownloadDelegate
84+
85+
extension BackgroundDownloadService: URLSessionDownloadDelegate {
86+
public func urlSession(_ session: URLSession,
87+
downloadTask: URLSessionDownloadTask,
88+
didFinishDownloadingTo location: URL) {
89+
// For catalog downloads, reads data directly from temp location to save storage space.
90+
// This is safe for typical JSON catalog files which are usually < 50MB.
91+
guard let sessionIdentifier = session.configuration.identifier else {
92+
DDLogError("🟣 Background download session missing identifier")
93+
return
94+
}
95+
96+
do {
97+
// Move downloaded file to temporary directory to prevent iOS from cleaning it up
98+
// before parsing completes. The temp location returned by URLSession is cleaned
99+
// immediately after this delegate method returns, but we need the file to persist
100+
// until async parsing completes.
101+
let tempDirectory = fileManager.temporaryDirectory
102+
let fileName = downloadTask.originalRequest?.url?.lastPathComponent ?? "catalog_\(UUID().uuidString).json"
103+
let persistentTempURL = tempDirectory.appendingPathComponent(fileName)
104+
105+
// Remove existing file if it exists
106+
if fileManager.fileExists(atPath: persistentTempURL.path) {
107+
try fileManager.removeItem(at: persistentTempURL)
108+
}
109+
110+
// Move the downloaded file to our managed temp location
111+
try fileManager.moveItem(at: location, to: persistentTempURL)
112+
113+
DDLogInfo("🟣 Background download completed, file moved to: \(persistentTempURL.path)")
114+
115+
handleDownloadCompletion(for: sessionIdentifier,
116+
fileURL: persistentTempURL,
117+
error: nil)
118+
} catch {
119+
DDLogError("🟣 Failed to move downloaded file: \(error.localizedDescription)")
120+
handleDownloadCompletion(for: sessionIdentifier,
121+
fileURL: nil,
122+
error: error)
123+
}
124+
}
125+
126+
public func urlSession(_ session: URLSession,
127+
downloadTask: URLSessionDownloadTask,
128+
didWriteData bytesWritten: Int64,
129+
totalBytesWritten: Int64,
130+
totalBytesExpectedToWrite: Int64) {
131+
let progress = BackgroundDownloadProgress(
132+
bytesDownloaded: totalBytesWritten,
133+
totalBytes: totalBytesExpectedToWrite
134+
)
135+
136+
// For now, the download progress is logged.
137+
// We might want to notify observers to update UI on progress changes.
138+
if totalBytesExpectedToWrite > 0 {
139+
let percentComplete = Int(progress.progress * 100)
140+
DDLogInfo("🟣 Download progress: \(percentComplete)%")
141+
}
142+
}
143+
144+
public func urlSession(_ session: URLSession,
145+
task: URLSessionTask,
146+
didCompleteWithError error: Error?) {
147+
guard let sessionIdentifier = session.configuration.identifier else {
148+
DDLogError("🟣 Background download session missing identifier in error handling")
149+
return
150+
}
151+
152+
if let error {
153+
handleDownloadCompletion(for: sessionIdentifier,
154+
fileURL: nil,
155+
error: error)
156+
}
157+
}
158+
}
159+
160+
// MARK: - URLSessionDelegate
161+
162+
extension BackgroundDownloadService: URLSessionDelegate {
163+
public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
164+
// Executes the background URL session completion handler on the main queue because this method may be called on a secondary queue
165+
// according to doc:
166+
// https://developer.apple.com/documentation/foundation/downloading-files-in-the-background#Handle-app-suspension
167+
DispatchQueue.main.async { [weak self] in
168+
self?.backgroundCompletionHandler?()
169+
self?.backgroundCompletionHandler = nil
170+
}
171+
}
172+
}

Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// periphery:ignore:all
12
import Foundation
23

34
/// Protocol for POS Catalog Sync Remote operations.
@@ -81,6 +82,16 @@ public protocol POSCatalogSyncRemoteProtocol {
8182
///
8283
public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {
8384
private let dateFormatter = ISO8601DateFormatter()
85+
private let backgroundDownloader: BackgroundDownloadProtocol
86+
private let fileManager: FileManager
87+
88+
public init(network: Network,
89+
backgroundDownloader: BackgroundDownloadProtocol = BackgroundDownloadService(),
90+
fileManager: FileManager = .default) {
91+
self.backgroundDownloader = backgroundDownloader
92+
self.fileManager = fileManager
93+
super.init(network: network)
94+
}
8495

8596
// MARK: - Incremental Sync Endpoints
8697

@@ -177,30 +188,65 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {
177188
return try await enqueue(request, mapper: mapper)
178189
}
179190

180-
/// Downloads the generated catalog at the specified download URL.
191+
/// Downloads the generated catalog at the specified download URL using background downloads.
181192
/// - Parameters:
182193
/// - siteID: Site ID to download catalog for.
183194
/// - downloadURL: Download URL of the catalog file.
184195
/// - allowCellular: Should cellular data be used if required.
185196
/// - Returns: List of products and variations in the POS catalog.
197+
/// - Note: Uses background download with URLSessionConfiguration.background to support app suspension.
186198
public func downloadCatalog(for siteID: Int64,
187199
downloadURL: String,
188200
allowCellular: Bool) async throws -> POSCatalogResponse {
189-
// TODO: WOOMOB-1173 - move download task to the background using `URLSessionConfiguration.background`
190201
guard let url = URL(string: downloadURL) else {
191202
throw NetworkError.invalidURL
192203
}
193-
var request = URLRequest(url: url)
194-
request.allowsCellularAccess = allowCellular
204+
205+
let sessionIdentifier = "\(POSCatalogSyncConstants.backgroundDownloadSessionPrefix).\(siteID).\(UUID().uuidString)"
206+
let fileURL = try await backgroundDownloader.downloadFile(from: url,
207+
sessionIdentifier: sessionIdentifier,
208+
allowCellular: allowCellular)
209+
return try await parseDownloadedCatalog(from: fileURL, siteID: siteID)
210+
}
211+
212+
/// Parses the downloaded catalog file.
213+
/// - Parameters:
214+
/// - fileURL: Local file URL of the downloaded catalog.
215+
/// - siteID: Site ID for proper mapping.
216+
/// - Returns: Parsed POS catalog.
217+
func parseDownloadedCatalog(from fileURL: URL, siteID: Int64) async throws -> POSCatalogResponse {
218+
let data = try Data(contentsOf: fileURL)
219+
220+
// Clean up downloaded files, but only if they're in our Documents directory.
221+
// Files in iOS temporary directories should be left for iOS to clean up automatically.
222+
defer {
223+
cleanupDownloadedFileIfNeeded(at: fileURL)
224+
}
225+
195226
let mapper = ListMapper<POSProduct>(siteID: siteID)
196-
let items = try await enqueue(request, mapper: mapper)
227+
let items = try mapper.map(response: data)
197228
let variationProductTypeKey = "variation"
198229
let products = items.filter { $0.productTypeKey != variationProductTypeKey }
199230
let variations = items.filter { $0.productTypeKey == variationProductTypeKey }
200231
.map { $0.toVariation }
201232
return POSCatalogResponse(products: products, variations: variations)
202233
}
203234

235+
/// Cleans up the downloaded catalog file if it's in our Documents directory.
236+
/// Files in temporary directories are left for iOS to clean up automatically.
237+
private func cleanupDownloadedFileIfNeeded(at fileURL: URL) {
238+
// Only clean up files in our Documents directory
239+
// Temporary files should be left for iOS to handle
240+
let documentsURLs = fileManager.urls(for: .documentDirectory, in: .userDomainMask)
241+
guard let documentsURL = documentsURLs.first,
242+
fileURL.path.hasPrefix(documentsURL.path),
243+
fileManager.fileExists(atPath: fileURL.path) else {
244+
return
245+
}
246+
247+
try? fileManager.removeItem(at: fileURL)
248+
}
249+
204250
/// Loads POS products for full sync.
205251
///
206252
/// - Parameters:
@@ -367,6 +413,14 @@ public struct POSCatalogResponse {
367413
public let variations: [POSProductVariation]
368414
}
369415

416+
// MARK: - POS Catalog Sync Constants
417+
418+
/// Constants used across POS catalog sync functionality
419+
public enum POSCatalogSyncConstants {
420+
/// Background download session identifier prefix for POS catalog downloads
421+
public static let backgroundDownloadSessionPrefix = "com.woocommerce.pos.catalog.download"
422+
}
423+
370424
private extension POSProduct {
371425
var toVariation: POSProductVariation {
372426
let variationAttributes = attributes.compactMap { attribute in

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ private extension POSCatalogFullSyncService {
164164
return try await syncRemote.downloadCatalog(for: siteID, downloadURL: downloadURL, allowCellular: allowCellular)
165165
}
166166

167+
// TODO: WOOMOB-1677 - This blocking polling approach is incompatible with background execution.
168+
// Background App Refresh tasks have ~30 seconds of execution time, but catalog generation
169+
// typically takes 5-10 minutes. This needs to be refactored to use stateful polling that
170+
// resumes across multiple background refresh sessions and foreground app opens.
167171
func pollForCatalogCompletion(siteID: Int64,
168172
syncRemote: POSCatalogSyncRemoteProtocol,
169173
allowCellular: Bool) async throws -> String {

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
167167
recordFirstSyncIfNeeded(for: siteID)
168168
}
169169

170+
// TODO: WOOMOB-1677 - Add logic to check for in-progress catalog generation before starting new sync.
171+
// When Background App Refresh or foreground app open triggers this, first check if there's a
172+
// pending catalog generation from a previous session (requires state persistence). If so,
173+
// poll once for status and download/parse if ready instead of starting a new generation.
170174
public func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval, incrementalSyncMaxAge: TimeInterval) async throws {
171175
let lastFullSync = await lastFullSyncDate(for: siteID) ?? Date(timeIntervalSince1970: 0)
172176
let lastFullSyncUTC = ISO8601DateFormatter().string(from: lastFullSync)

0 commit comments

Comments
 (0)