Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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 let isAppActive: () -> Bool
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,
isAppActive: @escaping () -> Bool = { UIApplication.shared.applicationState == .active }) {
self.interval = interval
self.notificationCenter = notificationCenter
self.timerProvider = timerProvider
self.featureFlagService = featureFlagService
self.stores = stores
self.isAppActive = isAppActive
}

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 isAppActive() {
startTimer()
}

syncSiteID = stores.sessionManager.defaultStoreID
}

func stop() {
guard isRunning else { return }

DDLogInfo("🛑 ForegroundPOSCatalogSyncDispatcher: Stopping foreground sync dispatcher")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider if passing the siteID in 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.

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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()
}
}
32 changes: 3 additions & 29 deletions WooCommerce/Classes/ViewRelated/MainTabBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ final class MainTabBarController: UITabBarController {

private var posTabVisibilityChecker: POSTabVisibilityCheckerProtocol?
private var posEligibilityCheckTask: Task<Void, Never>?
private var posCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?
private lazy var posSyncDispatcher = ForegroundPOSCatalogSyncDispatcher()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing this needs to be lazy because the POS tab visibility and eligibility? Otherwise we would be paying for initializing the sync dispatcher despite the merchant not being able to use POS.


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

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

Expand Down Expand Up @@ -817,10 +815,6 @@ private extension MainTabBarController {
navigateToContent: { _ in })]
}

// Configure POS catalog sync coordinator for local catalog syncing
// Get POS catalog sync coordinator (will be nil if feature flag disabled or not authenticated)
posCatalogSyncCoordinator = ServiceLocator.posCatalogSyncCoordinator

// Configure hub menu tab coordinator once per logged in session potentially with multiple sites.
if hubMenuTabCoordinator == nil {
let hubTabCoordinator = createHubMenuTabCoordinator()
Expand Down Expand Up @@ -852,26 +846,6 @@ private extension MainTabBarController {
OrdersSplitViewWrapperController(siteID: siteID)
}

func triggerPOSCatalogSyncIfNeeded(for siteID: Int64) async {
guard let coordinator = posCatalogSyncCoordinator else {
return
}

// Check if sync is needed (older than 24 hours)
let maxAge: TimeInterval = 24 * 60 * 60

// Perform background sync
Task.detached {
do {
_ = try await coordinator.performFullSyncIfApplicable(for: siteID, maxAge: maxAge)
} catch POSCatalogSyncError.syncAlreadyInProgress {
DDLogInfo("ℹ️ POS catalog sync already in progress for site \(siteID), skipping")
} catch {
DDLogError("⚠️ POS catalog sync failed: \(error)")
}
}
}

func createBookingsViewController(siteID: Int64) -> UIViewController {
BookingsTabViewHostingController(siteID: siteID)
}
Expand Down
10 changes: 9 additions & 1 deletion WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
011D39712D0A324200DB1445 /* LocationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D39702D0A324100DB1445 /* LocationServiceTests.swift */; };
011D7A332CEC877A0007C187 /* CardPresentModalNonRetryableErrorEmailSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D7A322CEC87770007C187 /* CardPresentModalNonRetryableErrorEmailSent.swift */; };
011D7A352CEC87B70007C187 /* CardPresentModalErrorEmailSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D7A342CEC87B60007C187 /* CardPresentModalErrorEmailSent.swift */; };
012E13272E9FF58B00BAC338 /* ForegroundPOSCatalogSyncDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012E13262E9FF58B00BAC338 /* ForegroundPOSCatalogSyncDispatcher.swift */; };
012E13292E9FFC2900BAC338 /* ForegroundPOSCatalogSyncDispatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012E13282E9FFC2900BAC338 /* ForegroundPOSCatalogSyncDispatcherTests.swift */; };
01309A812DC4F45300B77527 /* CardPresentModalCardInserted.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01309A802DC4F44700B77527 /* CardPresentModalCardInserted.swift */; };
013D2FB42CFEFEC600845D75 /* TapToPayCardReaderMerchantEducationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013D2FB32CFEFEA800845D75 /* TapToPayCardReaderMerchantEducationPresenter.swift */; };
013D2FB62CFF54BB00845D75 /* TapToPayEducationStepsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013D2FB52CFF54B600845D75 /* TapToPayEducationStepsFactory.swift */; };
Expand Down Expand Up @@ -1342,9 +1344,9 @@
57CFCD2A2488496F003F51EC /* PrimarySectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 57CFCD292488496F003F51EC /* PrimarySectionHeaderView.xib */; };
57F2C6CD246DECC10074063B /* SummaryTableViewCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F2C6CC246DECC10074063B /* SummaryTableViewCellViewModelTests.swift */; };
57F42E40253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F42E3F253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift */; };
68051E1E2E9DFE5500228196 /* POSNotificationSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68051E1D2E9DFE5100228196 /* POSNotificationSchedulerTests.swift */; };
640DA3482E97DE4F00317FB2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640DA3472E97DE4F00317FB2 /* SceneDelegate.swift */; };
64D355A52E99048E005F53F7 /* TestingSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D355A42E99048E005F53F7 /* TestingSceneDelegate.swift */; };
68051E1E2E9DFE5500228196 /* POSNotificationSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68051E1D2E9DFE5100228196 /* POSNotificationSchedulerTests.swift */; };
680BA59A2A4C377900F5559D /* UpgradeViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680BA5992A4C377900F5559D /* UpgradeViewState.swift */; };
680E36B52BD8B9B900E8BCEA /* OrderSubscriptionTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 680E36B42BD8B9B900E8BCEA /* OrderSubscriptionTableViewCell.xib */; };
680E36B72BD8C49F00E8BCEA /* OrderSubscriptionTableViewCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680E36B62BD8C49F00E8BCEA /* OrderSubscriptionTableViewCellViewModel.swift */; };
Expand Down Expand Up @@ -2957,6 +2959,8 @@
011D39702D0A324100DB1445 /* LocationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationServiceTests.swift; sourceTree = "<group>"; };
011D7A322CEC87770007C187 /* CardPresentModalNonRetryableErrorEmailSent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalNonRetryableErrorEmailSent.swift; sourceTree = "<group>"; };
011D7A342CEC87B60007C187 /* CardPresentModalErrorEmailSent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalErrorEmailSent.swift; sourceTree = "<group>"; };
012E13262E9FF58B00BAC338 /* ForegroundPOSCatalogSyncDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForegroundPOSCatalogSyncDispatcher.swift; sourceTree = "<group>"; };
012E13282E9FFC2900BAC338 /* ForegroundPOSCatalogSyncDispatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForegroundPOSCatalogSyncDispatcherTests.swift; sourceTree = "<group>"; };
01309A802DC4F44700B77527 /* CardPresentModalCardInserted.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalCardInserted.swift; sourceTree = "<group>"; };
013D2FB32CFEFEA800845D75 /* TapToPayCardReaderMerchantEducationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayCardReaderMerchantEducationPresenter.swift; sourceTree = "<group>"; };
013D2FB52CFF54B600845D75 /* TapToPayEducationStepsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayEducationStepsFactory.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -7776,6 +7780,7 @@
26BCA03E2C35E965000BE96C /* BackgroundTasks */ = {
isa = PBXGroup;
children = (
012E13262E9FF58B00BAC338 /* ForegroundPOSCatalogSyncDispatcher.swift */,
01CA99F02E9EB6AB008DA881 /* BackgroundTaskSchedule.swift */,
26BCA03F2C35E9A9000BE96C /* BackgroundTaskRefreshDispatcher.swift */,
26BCA0412C35EDBF000BE96C /* OrderListSyncBackgroundTask.swift */,
Expand Down Expand Up @@ -9498,6 +9503,7 @@
D8A8C4F22268288F001C72BF /* AddManualCustomTrackingViewModelTests.swift */,
D83F5938225B424B00626E75 /* AddManualTrackingViewModelTests.swift */,
CECC759823D6160000486676 /* AggregateDataHelperTests.swift */,
012E13282E9FFC2900BAC338 /* ForegroundPOSCatalogSyncDispatcherTests.swift */,
01CA99F22E9EB948008DA881 /* BackgroundTaskScheduleTests.swift */,
CEEC9B6521E7C5200055EEF0 /* AppRatingManagerTests.swift */,
02BA23BF22EE9DAF009539E7 /* AsyncDictionaryTests.swift */,
Expand Down Expand Up @@ -15223,6 +15229,7 @@
CE1EC8EC20B8A3FF009762BF /* LeftImageTableViewCell.swift in Sources */,
DE8C946E264699B600C94823 /* PluginListViewModel.swift in Sources */,
021125992578D9C20075AD2A /* ShippingLabelPrintingInstructionsView.swift in Sources */,
012E13272E9FF58B00BAC338 /* ForegroundPOSCatalogSyncDispatcher.swift in Sources */,
EEBB81712D8C0839008D6CE5 /* CollapsibleShipmentItemCard.swift in Sources */,
D449C51C26DE6B5000D75B02 /* IconListItem.swift in Sources */,
EE9D03182B89E2B10077CED1 /* OrderStatusEnum+Analytics.swift in Sources */,
Expand Down Expand Up @@ -15750,6 +15757,7 @@
64D355A52E99048E005F53F7 /* TestingSceneDelegate.swift in Sources */,
CE55F2D82B23961B005D53D7 /* CollapsibleProductCardPriceSummaryViewModelTests.swift in Sources */,
025678052575EA1B009D7E6C /* ProductDetailsCellViewModelTests.swift in Sources */,
012E13292E9FFC2900BAC338 /* ForegroundPOSCatalogSyncDispatcherTests.swift in Sources */,
0211252E25773FB00075AD2A /* MockAggregateOrderItem.swift in Sources */,
D88100D3257DD060008DE6F2 /* WordPressComSiteInfoWooTests.swift in Sources */,
B53B898920D450AF00EDB467 /* SessionManagerTests.swift in Sources */,
Expand Down
10 changes: 10 additions & 0 deletions WooCommerce/WooCommerceTests/Mocks/MockStoresManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,22 @@ final class MockStoresManager: DefaultStoresManager {
///
var shouldDispatchActionsForReal = false

/// Optional test coordinator for POS catalog sync
///
var testPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?

/// Accept a concrete implementation (in addition to the pre-existing Protocol-based initializer)
///
init(sessionManager: SessionManager) {
super.init(sessionManager: sessionManager)
}

// MARK: - Overridden Properties

override var posCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? {
testPOSCatalogSyncCoordinator
}

// MARK: - Overridden Methods

override func dispatch(_ action: Action) {
Expand Down
Loading