diff --git a/Modules/Sources/PointOfSale/Presentation/Barcode Scanning/GameControllerBarcodeParser.swift b/Modules/Sources/PointOfSale/Presentation/Barcode Scanning/GameControllerBarcodeParser.swift index 3810c042f81..3dd00cd8f68 100644 --- a/Modules/Sources/PointOfSale/Presentation/Barcode Scanning/GameControllerBarcodeParser.swift +++ b/Modules/Sources/PointOfSale/Presentation/Barcode Scanning/GameControllerBarcodeParser.swift @@ -1,5 +1,6 @@ import Foundation import GameController +import WooFoundation /// Parses GameController keyboard input into barcode scans. /// This class handles the core logic for interpreting GameController GCKeyCode input as barcode data, diff --git a/Modules/Sources/PointOfSale/Presentation/Barcode Scanning/UIKitBarcodeObserver.swift b/Modules/Sources/PointOfSale/Presentation/Barcode Scanning/UIKitBarcodeObserver.swift index a502ef8c71d..75df04fe8b6 100644 --- a/Modules/Sources/PointOfSale/Presentation/Barcode Scanning/UIKitBarcodeObserver.swift +++ b/Modules/Sources/PointOfSale/Presentation/Barcode Scanning/UIKitBarcodeObserver.swift @@ -1,6 +1,7 @@ import Foundation import GameController import UIKit +import WooFoundation /// An observer that processes UIKit UIPress events for barcode scanner input. /// This class serves as a fallback for VoiceOver scenarios where GameController framework diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index 6f4a012f368..88fbe1c4ff2 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -618,6 +618,11 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol // Simulates an incremental sync operation with a 0.5 second delay. try await Task.sleep(nanoseconds: 500_000_000) } + + func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval) async throws { + // Simulates a smart sync operation with a 1 second delay. + try await Task.sleep(nanoseconds: 1_000_000_000) + } } #endif diff --git a/Modules/Sources/PointOfSale/Presentation/Barcode Scanning/TimeProvider.swift b/Modules/Sources/WooFoundation/Utilities/TimeProvider.swift similarity index 55% rename from Modules/Sources/PointOfSale/Presentation/Barcode Scanning/TimeProvider.swift rename to Modules/Sources/WooFoundation/Utilities/TimeProvider.swift index 470289929a2..7779366d240 100644 --- a/Modules/Sources/PointOfSale/Presentation/Barcode Scanning/TimeProvider.swift +++ b/Modules/Sources/WooFoundation/Utilities/TimeProvider.swift @@ -1,16 +1,18 @@ import Foundation -protocol TimeProvider { +public protocol TimeProvider { func now() -> Date func scheduleTimer(timeInterval: TimeInterval, target: Any, selector: Selector) -> Timer } -struct DefaultTimeProvider: TimeProvider { - func now() -> Date { +public struct DefaultTimeProvider: TimeProvider { + public init() {} + + public func now() -> Date { Date() } - func scheduleTimer(timeInterval: TimeInterval, target: Any, selector: Selector) -> Timer { + public func scheduleTimer(timeInterval: TimeInterval, target: Any, selector: Selector) -> Timer { return Timer.scheduledTimer(timeInterval: timeInterval, target: target, selector: selector, userInfo: nil, repeats: false) } } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift index a635781bc8e..86d2fa259b1 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift @@ -60,10 +60,10 @@ public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncSe do { let catalog = try await loadCatalog(for: siteID, modifiedAfter: modifiedAfter, syncRemote: syncRemote) - DDLogInfo("✅ Loaded \(catalog.products.count) products and \(catalog.variations.count) variations for siteID \(siteID)") + DDLogInfo("✅ Loaded \(catalog.products.count) updated products and \(catalog.variations.count) updated variations for siteID \(siteID)") try await persistenceService.persistIncrementalCatalogData(catalog, siteID: siteID) - DDLogInfo("✅ Persisted \(catalog.products.count) products and \(catalog.variations.count) variations to database for siteID \(siteID)") + DDLogInfo("✅ Persisted \(catalog.products.count) updated products and \(catalog.variations.count) updated variations to database for siteID \(siteID)") } catch { DDLogError("❌ Failed to sync and persist catalog incrementally: \(error)") diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift index 447d83eb993..3f2df716726 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift @@ -68,12 +68,12 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { DDLogInfo("✅ Catalog persistence complete") try await grdbManager.databaseConnection.read { db in - let productCount = try PersistedProduct.fetchCount(db) - let productImageCount = try PersistedProductImage.fetchCount(db) - let productAttributeCount = try PersistedProductAttribute.fetchCount(db) - let variationCount = try PersistedProductVariation.fetchCount(db) - let variationImageCount = try PersistedProductVariationImage.fetchCount(db) - let variationAttributeCount = try PersistedProductVariationAttribute.fetchCount(db) + let productCount = try PersistedProduct.filter { $0.siteID == siteID }.fetchCount(db) + let productImageCount = try PersistedProductImage.filter { $0.siteID == siteID }.fetchCount(db) + let productAttributeCount = try PersistedProductAttribute.filter { $0.siteID == siteID }.fetchCount(db) + let variationCount = try PersistedProductVariation.filter { $0.siteID == siteID }.fetchCount(db) + let variationImageCount = try PersistedProductVariationImage.filter { $0.siteID == siteID }.fetchCount(db) + let variationAttributeCount = try PersistedProductVariationAttribute.filter { $0.siteID == siteID }.fetchCount(db) DDLogInfo("Persisted \(productCount) products, \(productImageCount) product images, " + "\(productAttributeCount) product attributes, \(variationCount) variations, " + @@ -82,7 +82,7 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { } func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws { - DDLogInfo("💾 Persisting incremental catalog with \(catalog.products.count) products and \(catalog.variations.count) variations") + DDLogInfo("💾 Persisting incremental catalog with \(catalog.products.count) updated products and \(catalog.variations.count) updated variations") try await grdbManager.databaseConnection.write { db in for product in catalog.productsToPersist { @@ -140,12 +140,12 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { DDLogInfo("✅ Incremental catalog persistence complete") try await grdbManager.databaseConnection.read { db in - let productCount = try PersistedProduct.fetchCount(db) - let productImageCount = try PersistedProductImage.fetchCount(db) - let productAttributeCount = try PersistedProductAttribute.fetchCount(db) - let variationCount = try PersistedProductVariation.fetchCount(db) - let variationImageCount = try PersistedProductVariationImage.fetchCount(db) - let variationAttributeCount = try PersistedProductVariationAttribute.fetchCount(db) + let productCount = try PersistedProduct.filter { $0.siteID == siteID }.fetchCount(db) + let productImageCount = try PersistedProductImage.filter { $0.siteID == siteID }.fetchCount(db) + let productAttributeCount = try PersistedProductAttribute.filter { $0.siteID == siteID }.fetchCount(db) + let variationCount = try PersistedProductVariation.filter { $0.siteID == siteID }.fetchCount(db) + let variationImageCount = try PersistedProductVariationImage.filter { $0.siteID == siteID }.fetchCount(db) + let variationAttributeCount = try PersistedProductVariationAttribute.filter { $0.siteID == siteID }.fetchCount(db) DDLogInfo("Total after incremental update: \(productCount) products, \(productImageCount) product images, " + "\(productAttributeCount) product attributes, \(variationCount) variations, " + diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index a1b94d8311b..50cf47ddb93 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -18,6 +18,14 @@ public protocol POSCatalogSyncCoordinatorProtocol { /// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site //periphery:ignore - remove ignore comment when incremental sync is integrated with POS func performIncrementalSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws + + /// Performs a smart sync that decides between full and incremental sync based on the last full sync date + /// - Parameters: + /// - siteID: The site ID to sync catalog for + /// - fullSyncMaxAge: Maximum age before a full sync is triggered. If the last full sync is older than this, + /// performs full sync; otherwise, performs incremental sync + /// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site + func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval) async throws } public extension POSCatalogSyncCoordinatorProtocol { @@ -28,6 +36,12 @@ public extension POSCatalogSyncCoordinatorProtocol { func performIncrementalSync(for siteID: Int64) async throws { try await performIncrementalSyncIfApplicable(for: siteID, maxAge: .zero) } + + /// Performs a smart sync with a default 24-hour threshold for full sync + func performSmartSync(for siteID: Int64) async throws { + let twentyFourHours: TimeInterval = 24 * 60 * 60 + try await performSmartSync(for: siteID, fullSyncMaxAge: twentyFourHours) + } } public enum POSCatalogSyncError: Error, Equatable { @@ -88,6 +102,19 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { DDLogInfo("✅ POSCatalogSyncCoordinator completed full sync for site \(siteID)") } + public func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval) async throws { + let lastFullSync = await lastFullSyncDate(for: siteID) ?? Date(timeIntervalSince1970: 0) + let lastFullSyncUTC = ISO8601DateFormatter().string(from: lastFullSync) + + if Date().timeIntervalSince(lastFullSync) >= fullSyncMaxAge { + DDLogInfo("🔄 POSCatalogSyncCoordinator: Performing full sync for site \(siteID) (last full sync: \(lastFullSyncUTC) UTC)") + try await performFullSync(for: siteID) + } else { + DDLogInfo("🔄 POSCatalogSyncCoordinator: Performing incremental sync for site \(siteID) (last full sync: \(lastFullSyncUTC) UTC)") + try await performIncrementalSync(for: siteID) + } + } + /// Determines if a full sync should be performed based on the age of the last sync /// - Parameters: /// - siteID: The site ID to check diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift index e760334622a..867578b5244 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift @@ -5,6 +5,12 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { var performFullSyncInvocationCount = 0 var performFullSyncSiteID: Int64? var performFullSyncResult: Result = .success(()) + var lastSyncDate: Date? + + var performSmartSyncInvocationCount = 0 + var performSmartSyncSiteID: Int64? + var performSmartSyncFullSyncMaxAge: TimeInterval? + var performSmartSyncResult: Result = .success(()) var onPerformFullSyncCalled: (() -> Void)? @@ -23,4 +29,17 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { } func performIncrementalSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws {} + + func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval) async throws { + performSmartSyncInvocationCount += 1 + performSmartSyncSiteID = siteID + performSmartSyncFullSyncMaxAge = fullSyncMaxAge + + switch performSmartSyncResult { + case .success: + return + case .failure(let error): + throw error + } + } } diff --git a/Modules/Tests/PointOfSaleTests/Presentation/Barcode Scanning/GameControllerBarcodeParserTests.swift b/Modules/Tests/PointOfSaleTests/Presentation/Barcode Scanning/GameControllerBarcodeParserTests.swift index 898218234cc..6bac64c7476 100644 --- a/Modules/Tests/PointOfSaleTests/Presentation/Barcode Scanning/GameControllerBarcodeParserTests.swift +++ b/Modules/Tests/PointOfSaleTests/Presentation/Barcode Scanning/GameControllerBarcodeParserTests.swift @@ -1,6 +1,7 @@ import Testing import GameController @testable import PointOfSale +import WooFoundation struct GameControllerBarcodeParserTests { diff --git a/Modules/Tests/PointOfSaleTests/Presentation/Barcode Scanning/MockTimeProvider.swift b/Modules/Tests/PointOfSaleTests/Presentation/Barcode Scanning/MockTimeProvider.swift index c1a8ff8520c..6bd801b22d4 100644 --- a/Modules/Tests/PointOfSaleTests/Presentation/Barcode Scanning/MockTimeProvider.swift +++ b/Modules/Tests/PointOfSaleTests/Presentation/Barcode Scanning/MockTimeProvider.swift @@ -1,5 +1,6 @@ import Foundation @testable import PointOfSale +import WooFoundation final class MockTimer: Timer { var isCancelled = false diff --git a/Modules/Tests/PointOfSaleTests/Presentation/Barcode Scanning/UIKitBarcodeObserverTests.swift b/Modules/Tests/PointOfSaleTests/Presentation/Barcode Scanning/UIKitBarcodeObserverTests.swift index 03a8f0a10c7..1a42a9aa08f 100644 --- a/Modules/Tests/PointOfSaleTests/Presentation/Barcode Scanning/UIKitBarcodeObserverTests.swift +++ b/Modules/Tests/PointOfSaleTests/Presentation/Barcode Scanning/UIKitBarcodeObserverTests.swift @@ -2,6 +2,7 @@ import Testing import GameController import UIKit @testable import PointOfSale +import WooFoundation @MainActor struct UIKitBarcodeObserverTests { diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift index 5705805f6f6..ca4c8031a42 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift @@ -590,6 +590,102 @@ struct POSCatalogSyncCoordinatorTests { #expect(mockCatalogSizeChecker.lastCheckedSiteID == sampleSiteID) } + // MARK: - Smart Sync Tests + + @Test func performSmartSync_performs_full_sync_when_last_full_sync_older_than_threshold() async throws { + // Given - last full sync was 25 hours ago (older than 24 hour threshold) + let twentyFiveHoursAgo = Date().addingTimeInterval(-25 * 60 * 60) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: twentyFiveHoursAgo) + + // When + try await sut.performSmartSync(for: sampleSiteID) + + // Then - should perform full sync + #expect(mockSyncService.startFullSyncCallCount == 1) + #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0) + } + + @Test func performSmartSync_performs_incremental_sync_when_last_full_sync_within_threshold() async throws { + // Given - last full sync was 12 hours ago (within 24 hour threshold) + let twelveHoursAgo = Date().addingTimeInterval(-12 * 60 * 60) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: twelveHoursAgo) + + // When + try await sut.performSmartSync(for: sampleSiteID) + + // Then - should perform incremental sync + #expect(mockSyncService.startFullSyncCallCount == 0) + #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1) + } + + @Test func performSmartSync_performs_full_sync_when_no_previous_sync() async throws { + // Given - no previous sync exists + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) + + // When + try await sut.performSmartSync(for: sampleSiteID) + + // Then - should perform full sync + #expect(mockSyncService.startFullSyncCallCount == 1) + #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0) + } + + @Test func performSmartSync_respects_custom_fullSyncMaxAge() async throws { + // Given - last full sync was 2 hours ago + let twoHoursAgo = Date().addingTimeInterval(-2 * 60 * 60) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: twoHoursAgo) + + // When - using custom threshold of 1 hour + let oneHour: TimeInterval = 60 * 60 + try await sut.performSmartSync(for: sampleSiteID, fullSyncMaxAge: oneHour) + + // Then - should perform full sync because last sync is older than 1 hour + #expect(mockSyncService.startFullSyncCallCount == 1) + #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0) + } + + @Test func performSmartSync_performs_incremental_sync_with_custom_threshold() async throws { + // Given - last full sync was 30 minutes ago + let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: thirtyMinutesAgo) + + // When - using custom threshold of 1 hour + let oneHour: TimeInterval = 60 * 60 + try await sut.performSmartSync(for: sampleSiteID, fullSyncMaxAge: oneHour) + + // Then - should perform incremental sync because last sync is within 1 hour + #expect(mockSyncService.startFullSyncCallCount == 0) + #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1) + } + + @Test func performSmartSync_propagates_full_sync_errors() async throws { + // Given - last full sync was 25 hours ago (should trigger full sync) + let twentyFiveHoursAgo = Date().addingTimeInterval(-25 * 60 * 60) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: twentyFiveHoursAgo) + + let expectedError = NSError(domain: "full_sync", code: 500, userInfo: [NSLocalizedDescriptionKey: "Full sync failed"]) + mockSyncService.startFullSyncResult = .failure(expectedError) + + // When/Then + await #expect(throws: expectedError) { + try await sut.performSmartSync(for: sampleSiteID) + } + } + + @Test func performSmartSync_propagates_incremental_sync_errors() async throws { + // Given - last full sync was 12 hours ago (should trigger incremental sync) + let twelveHoursAgo = Date().addingTimeInterval(-12 * 60 * 60) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: twelveHoursAgo) + + let expectedError = NSError(domain: "incremental_sync", code: 500, userInfo: [NSLocalizedDescriptionKey: "Incremental sync failed"]) + mockIncrementalSyncService.startIncrementalSyncResult = .failure(expectedError) + + // When/Then + await #expect(throws: expectedError) { + try await sut.performSmartSync(for: sampleSiteID) + } + } + // MARK: - Helper Methods private func createSiteInDatabase(siteID: Int64, lastFullSyncDate: Date? = nil, lastIncrementalSyncDate: Date? = nil) throws { diff --git a/WooCommerce/Classes/Tools/BackgroundTasks/BackgroundTaskRefreshDispatcher.swift b/WooCommerce/Classes/Tools/BackgroundTasks/BackgroundTaskRefreshDispatcher.swift index 83bf1bd6184..8be25509032 100644 --- a/WooCommerce/Classes/Tools/BackgroundTasks/BackgroundTaskRefreshDispatcher.swift +++ b/WooCommerce/Classes/Tools/BackgroundTasks/BackgroundTaskRefreshDispatcher.swift @@ -4,16 +4,36 @@ import BackgroundTasks import Network final class BackgroundTaskRefreshDispatcher { - enum BackgroundTaskType: CaseIterable { + enum BackgroundTaskType: Codable, CaseIterable { case ordersAndDashboardSync - case posCatalogFullSync - case posCatalogIncrementalSync + case posCatalogSync } + private let schedule = BackgroundTaskSchedule() + /// Schedule the app refresh background task. /// func scheduleAppRefresh() { - scheduleTask(type: .ordersAndDashboardSync, earliestBeginDate: Date(timeIntervalSinceNow: 30 * 60)) + for taskType in BackgroundTaskType.allCases { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskType.identifier) + } + schedule.setDefaultPreferredTaskDates() + scheduleNextTask() + } + + /// Schedules a next background task using a BackgroundTaskSchedule + /// Sets earliestBeginDate to nil (no delay) if preferred run date is in the past + /// + private func scheduleNextTask() { + guard ServiceLocator.featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) else { + scheduleTask(type: .ordersAndDashboardSync, earliestBeginDate: Date(timeIntervalSinceNow: 30 * 60)) + return + } + + let nextTask = schedule.getNextTask() + let preferredDate = schedule.preferredRunDate(for: nextTask) + let earliestBeginDate = preferredDate > Date() ? preferredDate : nil + scheduleTask(type: nextTask, earliestBeginDate: earliestBeginDate) } /// Schedules a background task with the specified type and timing. @@ -21,7 +41,7 @@ final class BackgroundTaskRefreshDispatcher { /// - Parameters: /// - type: The type of background task to schedule. /// - earliestBeginDate: The earliest date at which the task can begin. When `nil`, the task can be submitted right away. - func scheduleTask(type: BackgroundTaskType, earliestBeginDate: Date?) { + private func scheduleTask(type: BackgroundTaskType, earliestBeginDate: Date?) { // Do not run this code while running test because this framework is not enabled in the simulator guard Self.isNotRunningTests() else { return @@ -30,6 +50,7 @@ final class BackgroundTaskRefreshDispatcher { let request = BGAppRefreshTaskRequest(identifier: type.identifier) request.earliestBeginDate = earliestBeginDate do { + DDLogInfo("Scheduling background refresh task \(type) in \(Int(earliestBeginDate?.timeIntervalSinceNow ?? 0))s") try BGTaskScheduler.shared.submit(request) } catch { DDLogError("⛔️ Could not schedule \(type) task: \(error)") @@ -63,27 +84,26 @@ final class BackgroundTaskRefreshDispatcher { /// Routes background task to appropriate handler based on type. /// private func handleBackgroundTask(_ backgroundTask: BGAppRefreshTask, type: BackgroundTaskType) { + guard let siteID = ServiceLocator.stores.sessionManager.defaultStoreID else { + backgroundTask.setTaskCompleted(success: false) + return + } + + // Make sure the next task is scheduled + schedule.setNextPreferredRunDate(for: type) + scheduleNextTask() + switch type { case .ordersAndDashboardSync: - handleOrdersAndDashboardSync(backgroundTask: backgroundTask) - case .posCatalogFullSync: - handlePOSCatalogFullSync(backgroundTask: backgroundTask) - case .posCatalogIncrementalSync: - handlePOSCatalogIncrementalSync(backgroundTask: backgroundTask) + handleOrdersAndDashboardSync(backgroundTask: backgroundTask, siteID: siteID) + case .posCatalogSync: + handlePOSCatalogSync(backgroundTask: backgroundTask, siteID: siteID) } } /// Handles orders and dashboard sync. /// - private func handleOrdersAndDashboardSync(backgroundTask: BGAppRefreshTask) { - guard let siteID = ServiceLocator.stores.sessionManager.defaultStoreID else { - backgroundTask.setTaskCompleted(success: false) - return - } - - // Schedules the next orders and dashboard sync. - scheduleAppRefresh() - + private func handleOrdersAndDashboardSync(backgroundTask: BGAppRefreshTask, siteID: Int64) { // Launch all refresh tasks in parallel. let refreshTasks = Task { do { @@ -143,55 +163,27 @@ final class BackgroundTaskRefreshDispatcher { } } - /// Handles POS catalog full sync refresh task. + /// Handles POS catalog sync refresh task. /// - private func handlePOSCatalogFullSync(backgroundTask: BGAppRefreshTask) { - guard let siteID = ServiceLocator.stores.sessionManager.defaultStoreID else { - backgroundTask.setTaskCompleted(success: false) - return - } - - let syncTask = Task { - do { - // Performs full sync only if the catalog age is older than 24 hours. - let maxAge: TimeInterval = 24 * 60 * 60 - try await ServiceLocator.stores.posCatalogSyncCoordinator?.performFullSyncIfApplicable(for: siteID, maxAge: maxAge) - backgroundTask.setTaskCompleted(success: true) - } catch { - DDLogError("⛔️ POS catalog full sync background refresh failed: \(error)") - backgroundTask.setTaskCompleted(success: false) - } - } - - backgroundTask.expirationHandler = { - DDLogError("⛔️ POS catalog full sync background refresh expired") - syncTask.cancel() - backgroundTask.setTaskCompleted(success: false) - } - } - - /// Handles POS catalog incremental sync refresh task. - /// - private func handlePOSCatalogIncrementalSync(backgroundTask: BGAppRefreshTask) { - guard let siteID = ServiceLocator.stores.sessionManager.defaultStoreID else { + private func handlePOSCatalogSync(backgroundTask: BGAppRefreshTask, siteID: Int64) { + guard let coordinator = ServiceLocator.stores.posCatalogSyncCoordinator else { + DDLogInfo("POS catalog sync background refresh skipped: Feature flag disabled or logged out") backgroundTask.setTaskCompleted(success: false) return } let syncTask = Task { do { - // Performs incremental sync only if the catalog age is older than 1 hour. - let maxAge: TimeInterval = 60 * 60 - try await ServiceLocator.stores.posCatalogSyncCoordinator?.performIncrementalSyncIfApplicable(for: siteID, maxAge: maxAge) + try await coordinator.performSmartSync(for: siteID) backgroundTask.setTaskCompleted(success: true) } catch { - DDLogError("⛔️ POS catalog incremental sync background refresh failed: \(error)") + DDLogError("⛔️ POS catalog sync background refresh failed: \(error)") backgroundTask.setTaskCompleted(success: false) } } backgroundTask.expirationHandler = { - DDLogError("⛔️ POS catalog incremental sync background refresh expired") + DDLogError("⛔️ POS catalog sync background refresh expired") syncTask.cancel() backgroundTask.setTaskCompleted(success: false) } @@ -220,10 +212,8 @@ fileprivate extension BackgroundTaskRefreshDispatcher.BackgroundTaskType { switch self { case .ordersAndDashboardSync: return "com.automattic.woocommerce.refresh" - case .posCatalogFullSync: - return "com.automattic.woocommerce.refresh.pos.catalog.sync.full" - case .posCatalogIncrementalSync: - return "com.automattic.woocommerce.refresh.pos.catalog.sync.incremental" + case .posCatalogSync: + return "com.automattic.woocommerce.refresh.pos.catalog.sync" } } } diff --git a/WooCommerce/Classes/Tools/BackgroundTasks/BackgroundTaskSchedule.swift b/WooCommerce/Classes/Tools/BackgroundTasks/BackgroundTaskSchedule.swift new file mode 100644 index 00000000000..d3f2ee9d12b --- /dev/null +++ b/WooCommerce/Classes/Tools/BackgroundTasks/BackgroundTaskSchedule.swift @@ -0,0 +1,83 @@ +import Foundation +import WooFoundation + +extension BackgroundTaskRefreshDispatcher.BackgroundTaskType { + var period: TimeInterval { + switch self { + case .ordersAndDashboardSync: + return 30 * 60 // 30 minutes + case .posCatalogSync: + return 60 * 60 // 60 minutes + } + } +} + +/// BackgroundTaskSchedule is a helper tool to determine the next BackgroundTask based on the preferred run period +/// +final class BackgroundTaskSchedule { + private var preferredTaskDate: [BackgroundTaskRefreshDispatcher.BackgroundTaskType: Date] = [:] { + didSet { setPersistedDates () } + } + private let timeProvider: TimeProvider + private let userDefaults: UserDefaults + + init(timeProvider: TimeProvider = DefaultTimeProvider(), + userDefaults: UserDefaults = .standard) { + self.timeProvider = timeProvider + self.userDefaults = userDefaults + loadPersistedDates() + } + + // Set preferred task dates when going into background + // This allows to pick the most appropriate next task + /// Example: + /// Set preferred dates Task A: in 30 min and Task B: in 45 min when app enters background + /// System executes Task A after 40 min. It allows us to know that preferred time for Task B is in 5 min, not 45 min + /// Next task is Task B with preferred time in 5 minutes + /// + func setDefaultPreferredTaskDates() { + for task in BackgroundTaskRefreshDispatcher.BackgroundTaskType.allCases { + preferredTaskDate[task] = timeProvider.now().addingTimeInterval(task.period) + } + } + + func getNextTask() -> BackgroundTaskRefreshDispatcher.BackgroundTaskType { + return BackgroundTaskRefreshDispatcher.BackgroundTaskType.allCases.min { task1, task2 in + preferredRunDate(for: task1) < preferredRunDate(for: task2) + } ?? .ordersAndDashboardSync + } + + func preferredRunDate(for task: BackgroundTaskRefreshDispatcher.BackgroundTaskType) -> Date { + if let preferred = preferredTaskDate[task] { + return preferred + } + let next = timeProvider.now().addingTimeInterval(task.period) + preferredTaskDate[task] = next + return next + } + + func setNextPreferredRunDate(for task: BackgroundTaskRefreshDispatcher.BackgroundTaskType) { + preferredTaskDate[task] = timeProvider.now().addingTimeInterval(task.period) + } +} + +// MARK: - Persistence + +private extension BackgroundTaskSchedule { + private var userDefaultsKey: String { "BackgroundTaskSchedule.preferredTaskDate" } + + private func loadPersistedDates() { + guard let data = userDefaults.data(forKey: userDefaultsKey), + let decoded = try? JSONDecoder().decode([BackgroundTaskRefreshDispatcher.BackgroundTaskType: Date].self, from: data) + else { + return + } + preferredTaskDate = decoded + } + + private func setPersistedDates() { + if let data = try? JSONEncoder().encode(preferredTaskDate) { + userDefaults.set(data, forKey: userDefaultsKey) + } + } +} diff --git a/WooCommerce/Resources/Info.plist b/WooCommerce/Resources/Info.plist index 87bda813e4b..8df5783b259 100644 --- a/WooCommerce/Resources/Info.plist +++ b/WooCommerce/Resources/Info.plist @@ -6,8 +6,7 @@ BGTaskSchedulerPermittedIdentifiers - com.automattic.woocommerce.refresh.pos.catalog.sync.incremental - com.automattic.woocommerce.refresh.pos.catalog.sync.full + com.automattic.woocommerce.refresh.pos.catalog.sync com.automattic.woocommerce.refresh CFBundleDevelopmentRegion diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index d58a4bd09da..c8434d65030 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -65,6 +65,8 @@ 01B744E22D2FCA1400AEB3F4 /* PushNotificationBackgroundSynchronizerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B744E12D2FCA1300AEB3F4 /* PushNotificationBackgroundSynchronizerFactory.swift */; }; 01BB6C072D09DC560094D55B /* CardPresentModalLocationPreAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BB6C062D09DC470094D55B /* CardPresentModalLocationPreAlert.swift */; }; 01BB6C0A2D09E9630094D55B /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BB6C092D09E9630094D55B /* LocationService.swift */; }; + 01CA99F12E9EB6BC008DA881 /* BackgroundTaskSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CA99F02E9EB6AB008DA881 /* BackgroundTaskSchedule.swift */; }; + 01CA99F32E9EB94A008DA881 /* BackgroundTaskScheduleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CA99F22E9EB948008DA881 /* BackgroundTaskScheduleTests.swift */; }; 01F067ED2D0C5D59001C5805 /* MockLocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F067EC2D0C5D56001C5805 /* MockLocationService.swift */; }; 01F42C162CE34AB8003D0A5A /* CardPresentModalTapToPaySuccessEmailSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F42C152CE34AB3003D0A5A /* CardPresentModalTapToPaySuccessEmailSent.swift */; }; 01F42C182CE34AD2003D0A5A /* CardPresentModalSuccessEmailSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F42C172CE34AD1003D0A5A /* CardPresentModalSuccessEmailSent.swift */; }; @@ -2992,6 +2994,8 @@ 01B744E12D2FCA1300AEB3F4 /* PushNotificationBackgroundSynchronizerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationBackgroundSynchronizerFactory.swift; sourceTree = ""; }; 01BB6C062D09DC470094D55B /* CardPresentModalLocationPreAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalLocationPreAlert.swift; sourceTree = ""; }; 01BB6C092D09E9630094D55B /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; + 01CA99F02E9EB6AB008DA881 /* BackgroundTaskSchedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskSchedule.swift; sourceTree = ""; }; + 01CA99F22E9EB948008DA881 /* BackgroundTaskScheduleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskScheduleTests.swift; sourceTree = ""; }; 01F067EC2D0C5D56001C5805 /* MockLocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLocationService.swift; sourceTree = ""; }; 01F42C152CE34AB3003D0A5A /* CardPresentModalTapToPaySuccessEmailSent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalTapToPaySuccessEmailSent.swift; sourceTree = ""; }; 01F42C172CE34AD1003D0A5A /* CardPresentModalSuccessEmailSent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalSuccessEmailSent.swift; sourceTree = ""; }; @@ -7772,6 +7776,7 @@ 26BCA03E2C35E965000BE96C /* BackgroundTasks */ = { isa = PBXGroup; children = ( + 01CA99F02E9EB6AB008DA881 /* BackgroundTaskSchedule.swift */, 26BCA03F2C35E9A9000BE96C /* BackgroundTaskRefreshDispatcher.swift */, 26BCA0412C35EDBF000BE96C /* OrderListSyncBackgroundTask.swift */, 26F115AE2C49A9250019CD73 /* DashboardSyncBackgroundTask.swift */, @@ -9493,6 +9498,7 @@ D8A8C4F22268288F001C72BF /* AddManualCustomTrackingViewModelTests.swift */, D83F5938225B424B00626E75 /* AddManualTrackingViewModelTests.swift */, CECC759823D6160000486676 /* AggregateDataHelperTests.swift */, + 01CA99F22E9EB948008DA881 /* BackgroundTaskScheduleTests.swift */, CEEC9B6521E7C5200055EEF0 /* AppRatingManagerTests.swift */, 02BA23BF22EE9DAF009539E7 /* AsyncDictionaryTests.swift */, 021125B72578ECF00075AD2A /* BoldableTextParserTests.swift */, @@ -15713,6 +15719,7 @@ 6856DB2E741639716E149967 /* KeyboardStateProvider.swift in Sources */, B95700AC2A72C1E4001BADF2 /* CustomerSelectorViewController.swift in Sources */, ABC35F18E744C5576B986CB3 /* InPersonPaymentsUnavailableView.swift in Sources */, + 01CA99F12E9EB6BC008DA881 /* BackgroundTaskSchedule.swift in Sources */, ABC35528D2D6BE6F516E5CEF /* InPersonPaymentsOnboardingError.swift in Sources */, CE606D812BE14000001CB424 /* ShippingLineSelectionDetails.swift in Sources */, 3F1FA84C28B60126009E246C /* StoreWidgets.intentdefinition in Sources */, @@ -16263,6 +16270,7 @@ 0271139A24DD15D800574A07 /* ProductsTabProductViewModel+VariationTests.swift in Sources */, 57A5D8DF253500F300AA54D6 /* RefundConfirmationViewModelTests.swift in Sources */, 026A23FF2A3173F100EFE4BD /* MockBlazeEligibilityChecker.swift in Sources */, + 01CA99F32E9EB94A008DA881 /* BackgroundTaskScheduleTests.swift in Sources */, 023078FE25872CCF008EADEE /* PrintShippingLabelViewModelTests.swift in Sources */, 027B8BBF23FE0F850040944E /* MockMediaStoresManager.swift in Sources */, 025A1246247CDF55008EA761 /* ProductFormViewModel+ChangesTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Tools/BackgroundTaskScheduleTests.swift b/WooCommerce/WooCommerceTests/Tools/BackgroundTaskScheduleTests.swift new file mode 100644 index 00000000000..f0a07484472 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Tools/BackgroundTaskScheduleTests.swift @@ -0,0 +1,224 @@ +@testable import WooCommerce +import Foundation +import Testing +import WooFoundation + +struct BackgroundTaskScheduleTests { + private let sut: BackgroundTaskSchedule + private let timeProvider: MockTimeProvider + + init() { + let userDefaults = UserDefaults(suiteName: #file)! + userDefaults.removePersistentDomain(forName: #file) + timeProvider = MockTimeProvider() + sut = BackgroundTaskSchedule(timeProvider: timeProvider, userDefaults: userDefaults) + } + + @Test func initial_state_returns_task_with_shortest_period() { + // Given - Fresh schedule with no preferred dates set + + // When + let nextTask = sut.getNextTask() + + // Then - Orders sync has shorter period (30min vs 60min) + #expect(nextTask == .ordersAndDashboardSync) + } + + @Test func setDefaultPreferredTaskDates_schedules_all_tasks_from_now() { + // Given + let startTime = Date(timeIntervalSince1970: 1000) + timeProvider.set(now: startTime) + + // When + sut.setDefaultPreferredTaskDates() + + // Then - All tasks should have preferred dates set from current time + let ordersDate = sut.preferredRunDate(for: .ordersAndDashboardSync) + let posDate = sut.preferredRunDate(for: .posCatalogSync) + + #expect(ordersDate == startTime.addingTimeInterval(30 * 60)) // 30 minutes + #expect(posDate == startTime.addingTimeInterval(60 * 60)) // 60 minutes + } + + @Test func background_task_execution_simulation_over_extended_period() { + // Given - App enters background at T=0 + let startTime = Date(timeIntervalSince1970: 0) + timeProvider.set(now: startTime) + sut.setDefaultPreferredTaskDates() + + // Expected: Orders (30min), POS (60min) + var nextTask = sut.getNextTask() + #expect(nextTask == .ordersAndDashboardSync) + + // When - System executes orders sync at T=35min (5min late) + timeProvider.advance(by: 35 * 60) + sut.setNextPreferredRunDate(for: .ordersAndDashboardSync) + + // Then - Next should be POS (preferred at T=60, in 25min) + nextTask = sut.getNextTask() + #expect(nextTask == .posCatalogSync) + #expect(sut.preferredRunDate(for: .posCatalogSync) == startTime.addingTimeInterval(60 * 60)) + + // When - System executes POS sync at T=75min (15min late) + timeProvider.advance(by: 40 * 60) // Now at T=75min + sut.setNextPreferredRunDate(for: .posCatalogSync) + + // Then - Orders is next (preferred at T=65, already overdue by 10min) + nextTask = sut.getNextTask() + #expect(nextTask == .ordersAndDashboardSync) + let ordersPreferred = sut.preferredRunDate(for: .ordersAndDashboardSync) + #expect(ordersPreferred == startTime.addingTimeInterval(65 * 60)) + + // When - System executes orders at T=80min (15min late from T=65) + timeProvider.advance(by: 5 * 60) // Now at T=80min + sut.setNextPreferredRunDate(for: .ordersAndDashboardSync) + + // Then - Orders is next again (preferred at T=110min, in 30min) + // POS is preferred at T=135min (in 55min) + nextTask = sut.getNextTask() + #expect(nextTask == .ordersAndDashboardSync) + #expect(sut.preferredRunDate(for: .ordersAndDashboardSync) == startTime.addingTimeInterval(110 * 60)) + #expect(sut.preferredRunDate(for: .posCatalogSync) == startTime.addingTimeInterval(135 * 60)) + } + + @Test func tasks_execute_at_random_times_maintains_correct_scheduling() { + // Given - Simulate realistic background task execution with system delays + let startTime = Date(timeIntervalSince1970: 0) + timeProvider.set(now: startTime) + sut.setDefaultPreferredTaskDates() + + // When/Then - Track 10 background executions with varying delays + var executionLog: [(time: TimeInterval, task: BackgroundTaskRefreshDispatcher.BackgroundTaskType)] = [] + + // Execution 1: Orders at T=0min (immediate, unusual but possible) + let task1 = sut.getNextTask() + sut.setNextPreferredRunDate(for: task1) + executionLog.append((0, task1)) + #expect(task1 == .ordersAndDashboardSync) + + // Execution 2: Orders again at T=32min (2min late from T=30) + timeProvider.advance(by: 32 * 60) + let task2 = sut.getNextTask() + sut.setNextPreferredRunDate(for: task2) + executionLog.append((32, task2)) + #expect(task2 == .ordersAndDashboardSync) + + // Execution 3: POS at T=58min (2min early from T=60) + timeProvider.advance(by: 26 * 60) + let task3 = sut.getNextTask() + sut.setNextPreferredRunDate(for: task3) + executionLog.append((58, task3)) + #expect(task3 == .posCatalogSync) + + // Execution 4: Orders at T=70min (8min late from T=62) + timeProvider.advance(by: 12 * 60) + let task4 = sut.getNextTask() + sut.setNextPreferredRunDate(for: task4) + executionLog.append((70, task4)) + #expect(task4 == .ordersAndDashboardSync) + + // Execution 5: Orders at T=105min (5min late from T=100) + timeProvider.advance(by: 35 * 60) + let task5 = sut.getNextTask() + sut.setNextPreferredRunDate(for: task5) + executionLog.append((105, task5)) + #expect(task5 == .ordersAndDashboardSync) + + // Execution 6: POS at T=118min (exactly on time from T=118) + timeProvider.advance(by: 13 * 60) + let task6 = sut.getNextTask() + sut.setNextPreferredRunDate(for: task6) + executionLog.append((118, task6)) + #expect(task6 == .posCatalogSync) + + // Then - Verify next preferred dates are correctly calculated + let ordersNext = sut.preferredRunDate(for: .ordersAndDashboardSync) + let posNext = sut.preferredRunDate(for: .posCatalogSync) + + // Orders: T=105 + 30 = T=135 + #expect(ordersNext == startTime.addingTimeInterval(135 * 60)) + // POS: T=118 + 60 = T=178 + #expect(posNext == startTime.addingTimeInterval(178 * 60)) + + // Next task should be Orders (T=135 < T=178) + #expect(sut.getNextTask() == .ordersAndDashboardSync) + } + + @Test func task_with_overdue_preferred_date_is_selected_first() { + // Given - Both tasks overdue, but orders more overdue + let startTime = Date(timeIntervalSince1970: 1000) + timeProvider.set(now: startTime) + sut.setDefaultPreferredTaskDates() + + // Advance time so both tasks are overdue + timeProvider.advance(by: 90 * 60) // 90 minutes later + + // When + let nextTask = sut.getNextTask() + + // Then - Orders should be selected (preferred at T+30, more overdue than POS at T+60) + #expect(nextTask == .ordersAndDashboardSync) + } + + @Test func preferredRunDate_creates_date_on_first_access() { + // Given - No preferred dates set + let startTime = Date(timeIntervalSince1970: 2000) + timeProvider.set(now: startTime) + + // When - Access preferred date without setting defaults + let ordersDate = sut.preferredRunDate(for: .ordersAndDashboardSync) + + // Then - Should create date = now + period + #expect(ordersDate == startTime.addingTimeInterval(30 * 60)) + + // When - Access again + let ordersDate2 = sut.preferredRunDate(for: .ordersAndDashboardSync) + + // Then - Should return same date (cached) + #expect(ordersDate2 == ordersDate) + } + + @Test func setNextPreferredRunDate_updates_from_current_time() { + // Given + let startTime = Date(timeIntervalSince1970: 0) + timeProvider.set(now: startTime) + sut.setDefaultPreferredTaskDates() + + let initialDate = sut.preferredRunDate(for: .ordersAndDashboardSync) + #expect(initialDate == startTime.addingTimeInterval(30 * 60)) + + // When - Advance time and update preferred date + timeProvider.advance(by: 45 * 60) + sut.setNextPreferredRunDate(for: .ordersAndDashboardSync) + + // Then - New preferred date should be from new current time + let newDate = sut.preferredRunDate(for: .ordersAndDashboardSync) + #expect(newDate == startTime.addingTimeInterval((45 + 30) * 60)) + } +} + +// MARK: - Mocks + +private class MockTimeProvider: TimeProvider { + private var currentTime: Date + + init(startTime: Date = Date(timeIntervalSince1970: 0)) { + self.currentTime = startTime + } + + func set(now date: Date) { + currentTime = date + } + + func advance(by interval: TimeInterval) { + currentTime = currentTime.addingTimeInterval(interval) + } + + func now() -> Date { + currentTime + } + + func scheduleTimer(timeInterval: TimeInterval, target: Any, selector: Selector) -> Timer { + fatalError("not implemented") + } +}