Skip to content
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")
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()
}
}
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