Skip to content

Commit 4194f0f

Browse files
authored
[Woo POS][Local Catalog] Add a periodic sync when the app is in the foreground (#16246)
2 parents f5bca54 + 2dd0372 commit 4194f0f

File tree

5 files changed

+562
-30
lines changed

5 files changed

+562
-30
lines changed
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import Foundation
2+
import UIKit
3+
import CocoaLumberjackSwift
4+
import Yosemite
5+
import Experiments
6+
7+
/// Periodically syncs POS catalog while the app is in the foreground.
8+
/// Triggers `catalogCoordinator.performSmartSync()` every hour when active.
9+
///
10+
final actor ForegroundPOSCatalogSyncDispatcher {
11+
private enum Constants {
12+
static let syncInterval: TimeInterval = 60 * 60 // 1 hour
13+
static let initialSyncDelay: TimeInterval = 5 // 5 seconds after becoming active
14+
static let leeway: Int = 5 // 5 seconds leeway to give a system more flexibility in managing resources
15+
}
16+
17+
private let interval: TimeInterval
18+
private let notificationCenter: NotificationCenter
19+
private let timerProvider: DispatchTimerProviding
20+
private let featureFlagService: FeatureFlagService
21+
private let storeProvider: POSCatalogStoreProviding
22+
private let isAppActive: () async -> Bool
23+
private var observers: [NSObjectProtocol] = []
24+
private var timer: DispatchTimerProtocol?
25+
26+
private var isRunning: Bool {
27+
syncSiteID != nil
28+
}
29+
private var syncSiteID: Int64?
30+
31+
init(interval: TimeInterval = Constants.syncInterval,
32+
notificationCenter: NotificationCenter = .default,
33+
timerProvider: DispatchTimerProviding = DefaultDispatchTimerProvider(),
34+
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService,
35+
storeProvider: POSCatalogStoreProviding = DefaultPOSCatalogStoreProvider(),
36+
isAppActive: @escaping () async -> Bool = UIApplication.isApplicationActive) {
37+
self.interval = interval
38+
self.notificationCenter = notificationCenter
39+
self.timerProvider = timerProvider
40+
self.featureFlagService = featureFlagService
41+
self.storeProvider = storeProvider
42+
self.isAppActive = isAppActive
43+
}
44+
45+
func start() async {
46+
guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) else {
47+
return
48+
}
49+
50+
if syncSiteID != nil, syncSiteID != storeProvider.defaultStoreID {
51+
DDLogInfo("🔄 ForegroundPOSCatalogSyncDispatcher: Site has changed, resetting the sync")
52+
stop()
53+
}
54+
55+
guard !isRunning else { return }
56+
57+
DDLogInfo("🔄 ForegroundPOSCatalogSyncDispatcher: Starting foreground sync dispatcher for site \(storeProvider.defaultStoreID ?? 0)")
58+
59+
let activeObserver = notificationCenter.addObserver(
60+
forName: UIApplication.didBecomeActiveNotification,
61+
object: nil,
62+
queue: .main
63+
) { [weak self] _ in
64+
Task {
65+
await self?.startTimer()
66+
}
67+
}
68+
69+
let backgroundObserver = notificationCenter.addObserver(
70+
forName: UIApplication.didEnterBackgroundNotification,
71+
object: nil,
72+
queue: .main
73+
) { [weak self] _ in
74+
Task {
75+
await self?.stopTimer()
76+
}
77+
}
78+
79+
let defaultSiteObserver = notificationCenter.addObserver(
80+
forName: .StoresManagerDidUpdateDefaultSite,
81+
object: nil,
82+
queue: .main
83+
) { [weak self] _ in
84+
Task {
85+
await self?.stop()
86+
}
87+
}
88+
89+
observers = [activeObserver, backgroundObserver, defaultSiteObserver]
90+
91+
if await isAppActive() {
92+
startTimer()
93+
}
94+
95+
syncSiteID = storeProvider.defaultStoreID
96+
}
97+
98+
func stop() {
99+
guard isRunning else { return }
100+
101+
DDLogInfo("🛑 ForegroundPOSCatalogSyncDispatcher: Stopping foreground sync dispatcher for site \(syncSiteID ?? 0)")
102+
observers.forEach(notificationCenter.removeObserver)
103+
observers.removeAll()
104+
stopTimer()
105+
syncSiteID = nil
106+
}
107+
108+
private func startTimer() {
109+
guard timer == nil else { return }
110+
111+
DDLogInfo("⏱️ ForegroundPOSCatalogSyncDispatcher: Starting timer (interval: \(Int(interval))s, initial delay: \(Int(Constants.initialSyncDelay))s)")
112+
113+
let queue = DispatchQueue(label: "com.automattic.woocommerce.posCatalogForegroundSync.timer", qos: .utility)
114+
let timer = timerProvider.makeTimer(queue: queue)
115+
timer.schedule(deadline: .now() + Constants.initialSyncDelay, repeating: interval, leeway: .seconds(Constants.leeway))
116+
timer.setEventHandler { [weak self] in
117+
Task {
118+
await self?.performSync()
119+
}
120+
}
121+
timer.resume()
122+
self.timer = timer
123+
}
124+
125+
private func stopTimer() {
126+
guard timer != nil else { return }
127+
DDLogInfo("⏱️ ForegroundPOSCatalogSyncDispatcher: Stopping timer")
128+
timer?.cancel()
129+
timer = nil
130+
}
131+
132+
private func performSync() {
133+
guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) else {
134+
DDLogInfo("📋 ForegroundPOSCatalogSyncDispatcher: Feature flag disabled, skipping sync")
135+
stop()
136+
return
137+
}
138+
139+
guard let siteID = storeProvider.defaultStoreID else {
140+
DDLogInfo("📋 ForegroundPOSCatalogSyncDispatcher: No default store, skipping sync")
141+
stop()
142+
return
143+
}
144+
145+
guard let coordinator = storeProvider.posCatalogSyncCoordinator else {
146+
DDLogInfo("📋 ForegroundPOSCatalogSyncDispatcher: Coordinator unavailable, skipping sync")
147+
stop()
148+
return
149+
}
150+
151+
DDLogInfo("🔄 ForegroundPOSCatalogSyncDispatcher: Starting sync for site \(siteID)")
152+
153+
Task.detached(priority: .utility) {
154+
do {
155+
try await coordinator.performSmartSync(for: siteID)
156+
DDLogInfo("✅ ForegroundPOSCatalogSyncDispatcher: Sync completed for site \(siteID)")
157+
} catch let error as POSCatalogSyncError {
158+
switch error {
159+
case .syncAlreadyInProgress:
160+
DDLogInfo("ℹ️ ForegroundPOSCatalogSyncDispatcher: Sync already in progress for site \(siteID)")
161+
case .negativeMaxAge:
162+
DDLogError("⛔️ ForegroundPOSCatalogSyncDispatcher: Invalid max age for site \(siteID)")
163+
}
164+
} catch {
165+
DDLogError("⛔️ ForegroundPOSCatalogSyncDispatcher: Sync failed for site \(siteID): \(error)")
166+
}
167+
}
168+
}
169+
}
170+
171+
// MARK: - Helpers
172+
173+
/// Simplified protocol for dispatch source timers, only exposing methods actually used.
174+
protocol DispatchTimerProtocol {
175+
func schedule(deadline: DispatchTime, repeating: Double, leeway: DispatchTimeInterval)
176+
func setEventHandler(handler: (() -> Void)?)
177+
func resume()
178+
func cancel()
179+
}
180+
181+
extension DispatchSourceTimer {
182+
func asTimer() -> DispatchTimerProtocol {
183+
DispatchTimerWrapper(timer: self)
184+
}
185+
}
186+
187+
private struct DispatchTimerWrapper: DispatchTimerProtocol {
188+
let timer: DispatchSourceTimer
189+
190+
func schedule(deadline: DispatchTime, repeating: Double, leeway: DispatchTimeInterval) {
191+
timer.schedule(deadline: deadline, repeating: repeating, leeway: leeway)
192+
}
193+
194+
func setEventHandler(handler: (() -> Void)?) {
195+
timer.setEventHandler(handler: handler)
196+
}
197+
198+
func resume() {
199+
timer.resume()
200+
}
201+
202+
func cancel() {
203+
timer.cancel()
204+
}
205+
}
206+
207+
/// Protocol for creating dispatch timers, enabling testability.
208+
protocol DispatchTimerProviding {
209+
func makeTimer(queue: DispatchQueue) -> DispatchTimerProtocol
210+
}
211+
212+
/// Default implementation using system DispatchSource.
213+
struct DefaultDispatchTimerProvider: DispatchTimerProviding {
214+
func makeTimer(queue: DispatchQueue) -> DispatchTimerProtocol {
215+
DispatchSource.makeTimerSource(queue: queue).asTimer()
216+
}
217+
}
218+
219+
extension UIApplication {
220+
static func isApplicationActive() async -> Bool {
221+
shared.applicationState == .active
222+
}
223+
}
224+
225+
/// Protocol for accessing store ID and POS catalog sync coordinator.
226+
protocol POSCatalogStoreProviding {
227+
var defaultStoreID: Int64? { get }
228+
var posCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? { get }
229+
}
230+
231+
/// Default implementation using StoresManager.
232+
struct DefaultPOSCatalogStoreProvider: POSCatalogStoreProviding {
233+
private let stores: StoresManager
234+
235+
init(stores: StoresManager = ServiceLocator.stores) {
236+
self.stores = stores
237+
}
238+
239+
var defaultStoreID: Int64? {
240+
stores.sessionManager.defaultStoreID
241+
}
242+
243+
var posCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? {
244+
stores.posCatalogSyncCoordinator
245+
}
246+
}

WooCommerce/Classes/ViewRelated/MainTabBarController.swift

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ final class MainTabBarController: UITabBarController {
151151

152152
private var posTabVisibilityChecker: POSTabVisibilityCheckerProtocol?
153153
private var posEligibilityCheckTask: Task<Void, Never>?
154-
private var posCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?
154+
private lazy var posSyncDispatcher = ForegroundPOSCatalogSyncDispatcher()
155155

156156
/// periphery: ignore - keeping strong ref of the checker to keep its async task alive
157157
private var bookingsEligibilityChecker: BookingsTabEligibilityCheckerProtocol?
@@ -721,10 +721,8 @@ private extension MainTabBarController {
721721
updateTabViewControllers(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible)
722722
viewModel.loadHubMenuTabBadge()
723723

724-
// Trigger POS catalog sync if tab is visible and feature flag is enabled
725-
if isPOSTabVisible, ServiceLocator.featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) {
726-
await triggerPOSCatalogSyncIfNeeded(for: siteID)
727-
}
724+
// Begin foreground synchronization if POS tab becomes visible
725+
await isPOSTabVisible ? posSyncDispatcher.start() : posSyncDispatcher.stop()
728726
}
729727
}
730728

@@ -817,10 +815,6 @@ private extension MainTabBarController {
817815
navigateToContent: { _ in })]
818816
}
819817

820-
// Configure POS catalog sync coordinator for local catalog syncing
821-
// Get POS catalog sync coordinator (will be nil if feature flag disabled or not authenticated)
822-
posCatalogSyncCoordinator = ServiceLocator.posCatalogSyncCoordinator
823-
824818
// Configure hub menu tab coordinator once per logged in session potentially with multiple sites.
825819
if hubMenuTabCoordinator == nil {
826820
let hubTabCoordinator = createHubMenuTabCoordinator()
@@ -852,26 +846,6 @@ private extension MainTabBarController {
852846
OrdersSplitViewWrapperController(siteID: siteID)
853847
}
854848

855-
func triggerPOSCatalogSyncIfNeeded(for siteID: Int64) async {
856-
guard let coordinator = posCatalogSyncCoordinator else {
857-
return
858-
}
859-
860-
// Check if sync is needed (older than 24 hours)
861-
let maxAge: TimeInterval = 24 * 60 * 60
862-
863-
// Perform background sync
864-
Task.detached {
865-
do {
866-
_ = try await coordinator.performFullSyncIfApplicable(for: siteID, maxAge: maxAge)
867-
} catch POSCatalogSyncError.syncAlreadyInProgress {
868-
DDLogInfo("ℹ️ POS catalog sync already in progress for site \(siteID), skipping")
869-
} catch {
870-
DDLogError("⚠️ POS catalog sync failed: \(error)")
871-
}
872-
}
873-
}
874-
875849
func createBookingsViewController(siteID: Int64) -> UIViewController {
876850
BookingsTabViewHostingController(siteID: siteID)
877851
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
011D39712D0A324200DB1445 /* LocationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D39702D0A324100DB1445 /* LocationServiceTests.swift */; };
2929
011D7A332CEC877A0007C187 /* CardPresentModalNonRetryableErrorEmailSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D7A322CEC87770007C187 /* CardPresentModalNonRetryableErrorEmailSent.swift */; };
3030
011D7A352CEC87B70007C187 /* CardPresentModalErrorEmailSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D7A342CEC87B60007C187 /* CardPresentModalErrorEmailSent.swift */; };
31+
012E13272E9FF58B00BAC338 /* ForegroundPOSCatalogSyncDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012E13262E9FF58B00BAC338 /* ForegroundPOSCatalogSyncDispatcher.swift */; };
32+
012E13292E9FFC2900BAC338 /* ForegroundPOSCatalogSyncDispatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012E13282E9FFC2900BAC338 /* ForegroundPOSCatalogSyncDispatcherTests.swift */; };
3133
01309A812DC4F45300B77527 /* CardPresentModalCardInserted.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01309A802DC4F44700B77527 /* CardPresentModalCardInserted.swift */; };
3234
013D2FB42CFEFEC600845D75 /* TapToPayCardReaderMerchantEducationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013D2FB32CFEFEA800845D75 /* TapToPayCardReaderMerchantEducationPresenter.swift */; };
3335
013D2FB62CFF54BB00845D75 /* TapToPayEducationStepsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013D2FB52CFF54B600845D75 /* TapToPayEducationStepsFactory.swift */; };
@@ -1342,9 +1344,9 @@
13421344
57CFCD2A2488496F003F51EC /* PrimarySectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 57CFCD292488496F003F51EC /* PrimarySectionHeaderView.xib */; };
13431345
57F2C6CD246DECC10074063B /* SummaryTableViewCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F2C6CC246DECC10074063B /* SummaryTableViewCellViewModelTests.swift */; };
13441346
57F42E40253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F42E3F253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift */; };
1345-
68051E1E2E9DFE5500228196 /* POSNotificationSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68051E1D2E9DFE5100228196 /* POSNotificationSchedulerTests.swift */; };
13461347
640DA3482E97DE4F00317FB2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640DA3472E97DE4F00317FB2 /* SceneDelegate.swift */; };
13471348
64D355A52E99048E005F53F7 /* TestingSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D355A42E99048E005F53F7 /* TestingSceneDelegate.swift */; };
1349+
68051E1E2E9DFE5500228196 /* POSNotificationSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68051E1D2E9DFE5100228196 /* POSNotificationSchedulerTests.swift */; };
13481350
680BA59A2A4C377900F5559D /* UpgradeViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680BA5992A4C377900F5559D /* UpgradeViewState.swift */; };
13491351
680E36B52BD8B9B900E8BCEA /* OrderSubscriptionTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 680E36B42BD8B9B900E8BCEA /* OrderSubscriptionTableViewCell.xib */; };
13501352
680E36B72BD8C49F00E8BCEA /* OrderSubscriptionTableViewCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680E36B62BD8C49F00E8BCEA /* OrderSubscriptionTableViewCellViewModel.swift */; };
@@ -2957,6 +2959,8 @@
29572959
011D39702D0A324100DB1445 /* LocationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationServiceTests.swift; sourceTree = "<group>"; };
29582960
011D7A322CEC87770007C187 /* CardPresentModalNonRetryableErrorEmailSent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalNonRetryableErrorEmailSent.swift; sourceTree = "<group>"; };
29592961
011D7A342CEC87B60007C187 /* CardPresentModalErrorEmailSent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalErrorEmailSent.swift; sourceTree = "<group>"; };
2962+
012E13262E9FF58B00BAC338 /* ForegroundPOSCatalogSyncDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForegroundPOSCatalogSyncDispatcher.swift; sourceTree = "<group>"; };
2963+
012E13282E9FFC2900BAC338 /* ForegroundPOSCatalogSyncDispatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForegroundPOSCatalogSyncDispatcherTests.swift; sourceTree = "<group>"; };
29602964
01309A802DC4F44700B77527 /* CardPresentModalCardInserted.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalCardInserted.swift; sourceTree = "<group>"; };
29612965
013D2FB32CFEFEA800845D75 /* TapToPayCardReaderMerchantEducationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayCardReaderMerchantEducationPresenter.swift; sourceTree = "<group>"; };
29622966
013D2FB52CFF54B600845D75 /* TapToPayEducationStepsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayEducationStepsFactory.swift; sourceTree = "<group>"; };
@@ -7776,6 +7780,7 @@
77767780
26BCA03E2C35E965000BE96C /* BackgroundTasks */ = {
77777781
isa = PBXGroup;
77787782
children = (
7783+
012E13262E9FF58B00BAC338 /* ForegroundPOSCatalogSyncDispatcher.swift */,
77797784
01CA99F02E9EB6AB008DA881 /* BackgroundTaskSchedule.swift */,
77807785
26BCA03F2C35E9A9000BE96C /* BackgroundTaskRefreshDispatcher.swift */,
77817786
26BCA0412C35EDBF000BE96C /* OrderListSyncBackgroundTask.swift */,
@@ -9498,6 +9503,7 @@
94989503
D8A8C4F22268288F001C72BF /* AddManualCustomTrackingViewModelTests.swift */,
94999504
D83F5938225B424B00626E75 /* AddManualTrackingViewModelTests.swift */,
95009505
CECC759823D6160000486676 /* AggregateDataHelperTests.swift */,
9506+
012E13282E9FFC2900BAC338 /* ForegroundPOSCatalogSyncDispatcherTests.swift */,
95019507
01CA99F22E9EB948008DA881 /* BackgroundTaskScheduleTests.swift */,
95029508
CEEC9B6521E7C5200055EEF0 /* AppRatingManagerTests.swift */,
95039509
02BA23BF22EE9DAF009539E7 /* AsyncDictionaryTests.swift */,
@@ -15223,6 +15229,7 @@
1522315229
CE1EC8EC20B8A3FF009762BF /* LeftImageTableViewCell.swift in Sources */,
1522415230
DE8C946E264699B600C94823 /* PluginListViewModel.swift in Sources */,
1522515231
021125992578D9C20075AD2A /* ShippingLabelPrintingInstructionsView.swift in Sources */,
15232+
012E13272E9FF58B00BAC338 /* ForegroundPOSCatalogSyncDispatcher.swift in Sources */,
1522615233
EEBB81712D8C0839008D6CE5 /* CollapsibleShipmentItemCard.swift in Sources */,
1522715234
D449C51C26DE6B5000D75B02 /* IconListItem.swift in Sources */,
1522815235
EE9D03182B89E2B10077CED1 /* OrderStatusEnum+Analytics.swift in Sources */,
@@ -15750,6 +15757,7 @@
1575015757
64D355A52E99048E005F53F7 /* TestingSceneDelegate.swift in Sources */,
1575115758
CE55F2D82B23961B005D53D7 /* CollapsibleProductCardPriceSummaryViewModelTests.swift in Sources */,
1575215759
025678052575EA1B009D7E6C /* ProductDetailsCellViewModelTests.swift in Sources */,
15760+
012E13292E9FFC2900BAC338 /* ForegroundPOSCatalogSyncDispatcherTests.swift in Sources */,
1575315761
0211252E25773FB00075AD2A /* MockAggregateOrderItem.swift in Sources */,
1575415762
D88100D3257DD060008DE6F2 /* WordPressComSiteInfoWooTests.swift in Sources */,
1575515763
B53B898920D450AF00EDB467 /* SessionManagerTests.swift in Sources */,

WooCommerce/WooCommerceTests/Mocks/MockStoresManager.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,22 @@ final class MockStoresManager: DefaultStoresManager {
1919
///
2020
var shouldDispatchActionsForReal = false
2121

22+
/// Optional test coordinator for POS catalog sync
23+
///
24+
var testPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?
25+
2226
/// Accept a concrete implementation (in addition to the pre-existing Protocol-based initializer)
2327
///
2428
init(sessionManager: SessionManager) {
2529
super.init(sessionManager: sessionManager)
2630
}
2731

32+
// MARK: - Overridden Properties
33+
34+
override var posCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? {
35+
testPOSCatalogSyncCoordinator
36+
}
37+
2838
// MARK: - Overridden Methods
2939

3040
override func dispatch(_ action: Action) {

0 commit comments

Comments
 (0)