-
Notifications
You must be signed in to change notification settings - Fork 121
[Woo POS][Local Catalog] Add a periodic sync when the app is in the foreground #16246
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
staskus
merged 8 commits into
trunk
from
woomob-1517-woo-poslocal-catalog-add-a-periodic-sync-when-the-app-is-in-foreground
Oct 16, 2025
Merged
Changes from 1 commit
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
3b54605
Create ForegroundPOSCatalogSyncDispatcher for running foreground POS …
staskus a453b17
Start POS foreground synchronization when POS tab becomes visible
staskus 2896586
Add ForegroundPOSCatalogSyncDispatcherTests
staskus ae450dc
Inject isAppActive block for testability
staskus 30c82b7
Merge branch 'trunk' into woomob-1517-woo-poslocal-catalog-add-a-peri…
staskus b015712
Add siteID to ForegroundPOSCatalogSyncDispatcher logs
staskus 3be4a8b
Turn ForegroundPOSCatalogSyncDispatcher into actor to guard against t…
staskus 2dd0372
Remove test flakiness by mocking stores
staskus File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
210 changes: 210 additions & 0 deletions
210
WooCommerce/Classes/Tools/BackgroundTasks/ForegroundPOSCatalogSyncDispatcher.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,210 @@ | ||
| import Foundation | ||
| import UIKit | ||
| import CocoaLumberjackSwift | ||
| import Yosemite | ||
| import Experiments | ||
|
|
||
| /// Periodically syncs POS catalog while the app is in the foreground. | ||
| /// Triggers `catalogCoordinator.performSmartSync()` every hour when active. | ||
| /// | ||
| final class ForegroundPOSCatalogSyncDispatcher { | ||
| private enum Constants { | ||
| static let syncInterval: TimeInterval = 60 * 60 // 1 hour | ||
| static let initialSyncDelay: TimeInterval = 5 // 5 seconds after becoming active | ||
| static let leeway: Int = 5 // 5 seconds leeway to give a system more flexibility in managing resources | ||
| } | ||
|
|
||
| private let interval: TimeInterval | ||
| private let notificationCenter: NotificationCenter | ||
| private let timerProvider: DispatchTimerProviding | ||
| private let featureFlagService: FeatureFlagService | ||
| private let stores: StoresManager | ||
| private var observers: [NSObjectProtocol] = [] | ||
| private var timer: DispatchTimerProtocol? | ||
|
|
||
| private var isRunning: Bool { | ||
| syncSiteID != nil | ||
| } | ||
| private var syncSiteID: Int64? | ||
|
|
||
| init(interval: TimeInterval = Constants.syncInterval, | ||
| notificationCenter: NotificationCenter = .default, | ||
| timerProvider: DispatchTimerProviding = DefaultDispatchTimerProvider(), | ||
| featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, | ||
| stores: StoresManager = ServiceLocator.stores) { | ||
| self.interval = interval | ||
| self.notificationCenter = notificationCenter | ||
| self.timerProvider = timerProvider | ||
| self.featureFlagService = featureFlagService | ||
| self.stores = stores | ||
| } | ||
|
|
||
| deinit { | ||
| stop() | ||
| } | ||
|
|
||
| func start() { | ||
| guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) else { | ||
| return | ||
| } | ||
|
|
||
| if syncSiteID != stores.sessionManager.defaultStoreID { | ||
| DDLogInfo("🔄 ForegroundPOSCatalogSyncDispatcher: Site has changed, resetting the sync") | ||
| stop() | ||
| } | ||
|
|
||
| guard !isRunning else { return } | ||
|
|
||
| DDLogInfo("🔄 ForegroundPOSCatalogSyncDispatcher: Starting foreground sync dispatcher") | ||
|
|
||
| let activeObserver = notificationCenter.addObserver( | ||
| forName: UIApplication.didBecomeActiveNotification, | ||
| object: nil, | ||
| queue: .main | ||
| ) { [weak self] _ in | ||
| self?.startTimer() | ||
| } | ||
|
|
||
| let backgroundObserver = notificationCenter.addObserver( | ||
| forName: UIApplication.didEnterBackgroundNotification, | ||
| object: nil, | ||
| queue: .main | ||
| ) { [weak self] _ in | ||
| self?.stopTimer() | ||
| } | ||
|
|
||
| let defaultSiteObserver = notificationCenter.addObserver( | ||
| forName: .StoresManagerDidUpdateDefaultSite, | ||
| object: nil, | ||
| queue: .main | ||
| ) { [weak self] _ in | ||
| self?.stop() | ||
| } | ||
|
|
||
| observers = [activeObserver, backgroundObserver, defaultSiteObserver] | ||
|
|
||
| if UIApplication.shared.applicationState == .active { | ||
| startTimer() | ||
| } | ||
|
|
||
| syncSiteID = stores.sessionManager.defaultStoreID | ||
| } | ||
|
|
||
| func stop() { | ||
| guard isRunning else { return } | ||
|
|
||
| DDLogInfo("🛑 ForegroundPOSCatalogSyncDispatcher: Stopping foreground sync dispatcher") | ||
| observers.forEach(notificationCenter.removeObserver) | ||
| observers.removeAll() | ||
| stopTimer() | ||
| syncSiteID = nil | ||
| } | ||
|
|
||
| private func startTimer() { | ||
| guard timer == nil else { return } | ||
|
|
||
| DDLogInfo("⏱️ ForegroundPOSCatalogSyncDispatcher: Starting timer (interval: \(Int(interval))s, initial delay: \(Int(Constants.initialSyncDelay))s)") | ||
|
|
||
| let queue = DispatchQueue(label: "com.automattic.woocommerce.posCatalogForegroundSync.timer", qos: .utility) | ||
| let timer = timerProvider.makeTimer(queue: queue) | ||
| timer.schedule(deadline: .now() + Constants.initialSyncDelay, repeating: interval, leeway: .seconds(Constants.leeway)) | ||
| timer.setEventHandler { [weak self] in | ||
| self?.performSync() | ||
| } | ||
| timer.resume() | ||
| self.timer = timer | ||
| } | ||
|
|
||
| private func stopTimer() { | ||
| guard timer != nil else { return } | ||
| DDLogInfo("⏱️ ForegroundPOSCatalogSyncDispatcher: Stopping timer") | ||
| timer?.cancel() | ||
| timer = nil | ||
| } | ||
|
|
||
| private func performSync() { | ||
| guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) else { | ||
| DDLogInfo("📋 ForegroundPOSCatalogSyncDispatcher: Feature flag disabled, skipping sync") | ||
| stop() | ||
| return | ||
| } | ||
|
|
||
| guard let siteID = stores.sessionManager.defaultStoreID else { | ||
| DDLogInfo("📋 ForegroundPOSCatalogSyncDispatcher: No default store, skipping sync") | ||
| stop() | ||
| return | ||
| } | ||
|
|
||
| guard let coordinator = stores.posCatalogSyncCoordinator else { | ||
| DDLogInfo("📋 ForegroundPOSCatalogSyncDispatcher: Coordinator unavailable, skipping sync") | ||
| stop() | ||
| return | ||
| } | ||
|
|
||
| DDLogInfo("🔄 ForegroundPOSCatalogSyncDispatcher: Starting sync for site \(siteID)") | ||
|
|
||
| Task.detached(priority: .utility) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good use of detached and priority queue! |
||
| do { | ||
| try await coordinator.performSmartSync(for: siteID) | ||
| DDLogInfo("✅ ForegroundPOSCatalogSyncDispatcher: Sync completed for site \(siteID)") | ||
| } catch let error as POSCatalogSyncError { | ||
| switch error { | ||
| case .syncAlreadyInProgress: | ||
| DDLogInfo("ℹ️ ForegroundPOSCatalogSyncDispatcher: Sync already in progress for site \(siteID)") | ||
| case .negativeMaxAge: | ||
| DDLogError("⛔️ ForegroundPOSCatalogSyncDispatcher: Invalid max age for site \(siteID)") | ||
| } | ||
| } catch { | ||
| DDLogError("⛔️ ForegroundPOSCatalogSyncDispatcher: Sync failed for site \(siteID): \(error)") | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Helpers | ||
|
|
||
| /// Simplified protocol for dispatch source timers, only exposing methods actually used. | ||
| protocol DispatchTimerProtocol { | ||
| func schedule(deadline: DispatchTime, repeating: Double, leeway: DispatchTimeInterval) | ||
| func setEventHandler(handler: (() -> Void)?) | ||
| func resume() | ||
| func cancel() | ||
| } | ||
|
|
||
| extension DispatchSourceTimer { | ||
| func asTimer() -> DispatchTimerProtocol { | ||
| DispatchTimerWrapper(timer: self) | ||
| } | ||
| } | ||
|
|
||
| private struct DispatchTimerWrapper: DispatchTimerProtocol { | ||
| let timer: DispatchSourceTimer | ||
|
|
||
| func schedule(deadline: DispatchTime, repeating: Double, leeway: DispatchTimeInterval) { | ||
| timer.schedule(deadline: deadline, repeating: repeating, leeway: leeway) | ||
| } | ||
|
|
||
| func setEventHandler(handler: (() -> Void)?) { | ||
| timer.setEventHandler(handler: handler) | ||
| } | ||
|
|
||
| func resume() { | ||
| timer.resume() | ||
| } | ||
|
|
||
| func cancel() { | ||
| timer.cancel() | ||
| } | ||
| } | ||
|
|
||
| /// Protocol for creating dispatch timers, enabling testability. | ||
| protocol DispatchTimerProviding { | ||
| func makeTimer(queue: DispatchQueue) -> DispatchTimerProtocol | ||
| } | ||
|
|
||
| /// Default implementation using system DispatchSource. | ||
| struct DefaultDispatchTimerProvider: DispatchTimerProviding { | ||
| func makeTimer(queue: DispatchQueue) -> DispatchTimerProtocol { | ||
| DispatchSource.makeTimerSource(queue: queue).asTimer() | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider if passing the
siteIDin the DDLog here would be useful: When I switched from a POS-eligible store to a non-eligible one the only notice logged was that the timer stopped. Perhaps not necessary as we would see the siteID changing from context of other events.