diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsEvent+BackgroudUpdates.swift b/WooCommerce/Classes/Analytics/WooAnalyticsEvent+BackgroudUpdates.swift index d50e4a4aba3..37018185a6c 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsEvent+BackgroudUpdates.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsEvent+BackgroudUpdates.swift @@ -27,4 +27,61 @@ extension WooAnalyticsEvent { WooAnalyticsEvent(statName: .backgroundUpdatesDisabled, properties: [:]) } } + + enum POSCatalogSync { + + private enum Keys { + static let duration = "duration" + static let source = "source" + static let taskType = "task_type" + } + + // Scheduling events + static func fullSyncScheduled() -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .posFullCatalogSyncScheduled, properties: [:]) + } + + static func incrementalSyncScheduled() -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .posIncrementalSyncScheduled, properties: [:]) + } + + static func schedulingError(_ error: Error, taskType: String) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .posCatalogSyncSchedulingError, properties: [Keys.taskType: taskType], error: error) + } + + // Sync completion events + static func fullSyncCompleted(source: String, duration: TimeInterval? = nil) -> WooAnalyticsEvent { + var properties: [String: String] = [Keys.source: source] + if let duration = duration { + properties[Keys.duration] = String(duration) + } + return WooAnalyticsEvent(statName: .posFullCatalogSyncCompleted, properties: properties) + } + + static func incrementalSyncCompleted(duration: TimeInterval) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .posIncrementalSyncCompleted, properties: [Keys.duration: duration]) + } + + // Sync error events + static func fullSyncError(_ error: Error, source: String) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .posFullCatalogSyncError, properties: [Keys.source: source], error: error) + } + + static func incrementalSyncError(_ error: Error) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .posIncrementalSyncError, properties: [:], error: error) + } + + // Task management events + static func taskExpired(taskType: String) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .posCatalogSyncTaskExpired, properties: [Keys.taskType: taskType]) + } + + static func syncRecovered() -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .posCatalogSyncRecovered, properties: [:]) + } + + static func recoveryError(_ error: Error) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .posCatalogSyncRecoveryError, properties: [:], error: error) + } + } } diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift b/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift index 392dc89f3aa..58e998bd52f 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift @@ -1270,6 +1270,18 @@ enum WooAnalyticsStat: String { case pushNotificationOrderBackgroundSynced = "push_notification_order_background_synced" case pushNotificationOrderBackgroundSyncError = "push_notification_order_background_sync_error" case backgroundUpdatesDisabled = "background_updates_disabled" + + // MARK: POS Catalog Sync events + case posFullCatalogSyncScheduled = "pos_full_catalog_sync_scheduled" + case posIncrementalSyncScheduled = "pos_incremental_sync_scheduled" + case posCatalogSyncSchedulingError = "pos_catalog_sync_scheduling_error" + case posFullCatalogSyncCompleted = "pos_full_catalog_sync_completed" + case posIncrementalSyncCompleted = "pos_incremental_sync_completed" + case posFullCatalogSyncError = "pos_full_catalog_sync_error" + case posIncrementalSyncError = "pos_incremental_sync_error" + case posCatalogSyncTaskExpired = "pos_catalog_sync_task_expired" + case posCatalogSyncRecovered = "pos_catalog_sync_recovered" + case posCatalogSyncRecoveryError = "pos_catalog_sync_recovery_error" // MARK: Point of Sale events case pointOfSaleTabSelected = "main_tab_pos_selected" diff --git a/WooCommerce/Classes/AppDelegate.swift b/WooCommerce/Classes/AppDelegate.swift index cb6bf43d957..c2281bed348 100644 --- a/WooCommerce/Classes/AppDelegate.swift +++ b/WooCommerce/Classes/AppDelegate.swift @@ -58,7 +58,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { /// Handles events to background refresh the app. /// - private let appRefreshHandler = BackgroundTaskRefreshDispatcher() + internal let appRefreshHandler = BackgroundTaskRefreshDispatcher() + + /// Manages POS catalog background synchronization. + /// + internal let posCatalogSyncManager = POSCatalogSyncBackgroundTaskManager() private var subscriptions: Set = [] @@ -127,6 +131,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Register for background app refresh events. appRefreshHandler.registerSystemTaskIdentifier() + + // Register POS catalog sync background tasks. + posCatalogSyncManager.registerBackgroundTasks() + + // Schedule initial POS catalog syncs. + posCatalogSyncManager.scheduleFullCatalogSync() + posCatalogSyncManager.scheduleIncrementalSync() + + // Recover any incomplete POS catalog syncs from previous app sessions. + posCatalogSyncManager.recoverIncompletesyncs() return true } diff --git a/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift b/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift index 95f9fd27f87..7970b3d69ad 100644 --- a/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift +++ b/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift @@ -68,6 +68,8 @@ extension UserDefaults { // Hide stores from store picker case hiddenStoreIDs + + case lastPOSIncrementalSyncTimestamp } } diff --git a/WooCommerce/Classes/Tools/BackgroundTasks/POSCatalogSyncBackgroundTaskManager.swift b/WooCommerce/Classes/Tools/BackgroundTasks/POSCatalogSyncBackgroundTaskManager.swift new file mode 100644 index 00000000000..875e8bdd2bb --- /dev/null +++ b/WooCommerce/Classes/Tools/BackgroundTasks/POSCatalogSyncBackgroundTaskManager.swift @@ -0,0 +1,619 @@ +import Foundation +import BackgroundTasks +import Yosemite +import UIKit + +/// Manages POS catalog background synchronization tasks. +/// Handles both full catalog downloads (BGProcessingTask) and incremental syncs (BGAppRefreshTask). +/// +final class POSCatalogSyncBackgroundTaskManager { + + // MARK: - Task Identifiers + + /// Full catalog download using BGProcessingTask - for large downloads that can wait for optimal conditions + static let fullCatalogSyncIdentifier = "com.automattic.woocommerce.pos.fullCatalogSync" + + /// Incremental sync using BGAppRefreshTask - for quick updates when app is backgrounded + static let incrementalSyncIdentifier = "com.automattic.woocommerce.pos.incrementalSync" + + // MARK: - Dependencies + + private let stores: StoresManager + private let urlSession: URLSession + + // MARK: - State Management + + /// State of ongoing sync operations - persisted to handle app lifecycle changes + private var syncState: POSSyncState { + get { + if let data = UserDefaults.standard.data(forKey: "pos_sync_state"), + let state = try? JSONDecoder().decode(POSSyncState.self, from: data) { + return state + } + return POSSyncState() + } + set { + if let data = try? JSONEncoder().encode(newValue) { + UserDefaults.standard.set(data, forKey: "pos_sync_state") + } + } + } + + // MARK: - Initialization + + init(stores: StoresManager = ServiceLocator.stores, urlSession: URLSession? = nil) { + self.stores = stores + + // Create background URLSession configuration for reliable downloads + let config = URLSessionConfiguration.background(withIdentifier: "com.automattic.woocommerce.pos.backgroundSync") + config.isDiscretionary = true // System can delay for optimal conditions + config.allowsCellularAccess = false // WiFi only for large downloads + config.sessionSendsLaunchEvents = true // Wake app when download completes + + self.urlSession = urlSession ?? URLSession(configuration: config, delegate: nil, delegateQueue: nil) + } + + // MARK: - Registration + + /// Registers all POS catalog sync tasks with the system + func registerBackgroundTasks() { + // Only register if not running tests + guard !Self.isRunningTests() else { return } + + DDLogInfo("πŸ“± Registering POS catalog background tasks...") + + // Register full catalog sync (BGProcessingTask) + let fullSyncRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.fullCatalogSyncIdentifier, using: nil) { task in + guard let processingTask = task as? BGProcessingTask else { + DDLogError("⛔️ Failed to cast to BGProcessingTask for full catalog sync") + task.setTaskCompleted(success: false) + return + } + self.handleFullCatalogSync(task: processingTask) + } + DDLogInfo("πŸ“± Full catalog sync registration: \(fullSyncRegistered ? "βœ… Success" : "❌ Failed")") + + // Register incremental sync (BGAppRefreshTask) + let incrementalSyncRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.incrementalSyncIdentifier, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { + DDLogError("⛔️ Failed to cast to BGAppRefreshTask for incremental sync") + task.setTaskCompleted(success: false) + return + } + self.handleIncrementalSync(task: refreshTask) + } + DDLogInfo("πŸ“± Incremental sync registration: \(incrementalSyncRegistered ? "βœ… Success" : "❌ Failed")") + + DDLogInfo("πŸ“± Successfully registered POS catalog background tasks") + } + + // MARK: - Scheduling + + /// Schedules a full catalog sync (BGProcessingTask) + /// Should be called weekly or daily, when device is idle with WiFi + func scheduleFullCatalogSync() { + guard !Self.isRunningTests() else { + DDLogInfo("πŸ“± Skipping full sync scheduling - running tests") + return + } + + DDLogInfo("πŸ“± Attempting to schedule full catalog sync...") + + // Cancel any existing request first + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.fullCatalogSyncIdentifier) + DDLogInfo("πŸ“± Cancelled existing full sync task if any") + + let request = BGProcessingTaskRequest(identifier: Self.fullCatalogSyncIdentifier) + request.requiresNetworkConnectivity = true + request.requiresExternalPower = false // Can run on battery but system will prefer charging + request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) // No earlier than 30 minutes + + DDLogInfo("πŸ“± Full sync request created:") + DDLogInfo("πŸ“± ID: \(request.identifier)") + DDLogInfo("πŸ“± Earliest: \(request.earliestBeginDate?.description ?? "immediately")") + DDLogInfo("πŸ“± Requires network: \(request.requiresNetworkConnectivity)") + DDLogInfo("πŸ“± Requires power: \(request.requiresExternalPower)") + DDLogInfo("πŸ“± Current time: \(Date().description)") + + do { + try BGTaskScheduler.shared.submit(request) + DDLogInfo("πŸ“± βœ… Successfully submitted full POS catalog sync") + DDLogInfo("πŸ“± Full sync task config: network=\(request.requiresNetworkConnectivity), power=\(request.requiresExternalPower)") + + // Verify it was actually queued by checking immediately + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + BGTaskScheduler.shared.getPendingTaskRequests { taskRequests in + let ourTask = taskRequests.first { $0.identifier == Self.fullCatalogSyncIdentifier } + DDLogInfo("πŸ“± Full sync task verification: \(ourTask != nil ? "βœ… Found in pending queue" : "❌ NOT found in pending queue")") + } + } + + ServiceLocator.analytics.track(event: .POSCatalogSync.fullSyncScheduled()) + } catch { + DDLogError("⛔️ Failed to schedule full POS catalog sync: \(error)") + DDLogError("⛔️ Error type: \(type(of: error)), localizedDescription: \(error.localizedDescription)") + if let bgError = error as? BGTaskScheduler.Error { + DDLogError("⛔️ BGTaskScheduler error code: \(bgError.code)") + } + ServiceLocator.analytics.track(event: .POSCatalogSync.schedulingError(error, taskType: "full")) + } + } + + /// Schedules an incremental sync (BGAppRefreshTask) + /// Should be called more frequently for quick updates + func scheduleIncrementalSync() { + guard !Self.isRunningTests() else { + DDLogInfo("πŸ“± Skipping incremental sync scheduling - running tests") + return + } + + DDLogInfo("πŸ“± Attempting to schedule incremental sync...") + + // Cancel any existing request first + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.incrementalSyncIdentifier) + DDLogInfo("πŸ“± Cancelled existing incremental sync task if any") + + let request = BGAppRefreshTaskRequest(identifier: Self.incrementalSyncIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // No earlier than 5 minutes + + DDLogInfo("πŸ“± Incremental sync request created: ID=\(request.identifier), earliest=\(request.earliestBeginDate?.description ?? "now")") + + do { + try BGTaskScheduler.shared.submit(request) + DDLogInfo("πŸ“± βœ… Successfully submitted incremental POS catalog sync") + + // Verify it was actually queued by checking immediately + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + BGTaskScheduler.shared.getPendingTaskRequests { taskRequests in + let ourTask = taskRequests.first { $0.identifier == Self.incrementalSyncIdentifier } + DDLogInfo("πŸ“± Incremental sync task verification: \(ourTask != nil ? "βœ… Found in pending queue" : "❌ NOT found in pending queue")") + } + } + + ServiceLocator.analytics.track(event: .POSCatalogSync.incrementalSyncScheduled()) + } catch { + DDLogError("⛔️ Failed to schedule incremental POS catalog sync: \(error)") + DDLogError("⛔️ Error type: \(type(of: error)), localizedDescription: \(error.localizedDescription)") + if let bgError = error as? BGTaskScheduler.Error { + DDLogError("⛔️ BGTaskScheduler error code: \(bgError.code)") + } + ServiceLocator.analytics.track(event: .POSCatalogSync.schedulingError(error, taskType: "incremental")) + } + } + + // MARK: - Foreground Sync Support + + /// Starts a full catalog sync in the foreground that will continue in background + /// Returns a task that can be monitored for progress + func startForegroundFullSync() -> Task { + DDLogInfo("πŸ“± Starting foreground POS catalog sync that will continue in background...") + + return Task { + var currentSyncState = self.syncState + currentSyncState.isFullSyncInProgress = true + currentSyncState.lastFullSyncStartTime = Date() + self.syncState = currentSyncState + + do { + try await self.performFullCatalogSync() + + // Update successful completion state + var completedState = self.syncState + completedState.isFullSyncInProgress = false + completedState.lastFullSyncCompletionTime = Date() + self.syncState = completedState + + DDLogInfo("πŸ“± Foreground full POS catalog sync completed successfully") + ServiceLocator.analytics.track(event: .POSCatalogSync.fullSyncCompleted(source: "foreground")) + + } catch { + // Update error state but keep sync as in progress for potential resume + var errorState = self.syncState + errorState.lastFullSyncError = error.localizedDescription + self.syncState = errorState + + DDLogError("⛔️ Foreground full POS catalog sync failed: \(error)") + ServiceLocator.analytics.track(event: .POSCatalogSync.fullSyncError(error, source: "foreground")) + throw error + } + } + } + + // MARK: - App Launch Recovery + + /// Should be called on app launch to recover from incomplete syncs + func recoverIncompletesyncs() { + let currentState = syncState + + // Check for incomplete full sync that needs processing + if currentState.isFullSyncInProgress, + let downloadPath = currentState.pendingFullCatalogPath, + FileManager.default.fileExists(atPath: downloadPath) { + + DDLogInfo("πŸ“± Recovering incomplete POS catalog sync from app launch") + + Task { + do { + try await self.processDownloadedCatalog(at: URL(fileURLWithPath: downloadPath)) + + // Update completion state + var recoveredState = self.syncState + recoveredState.isFullSyncInProgress = false + recoveredState.pendingFullCatalogPath = nil + recoveredState.lastFullSyncCompletionTime = Date() + self.syncState = recoveredState + + DDLogInfo("πŸ“± Successfully recovered incomplete POS catalog sync") + ServiceLocator.analytics.track(event: .POSCatalogSync.syncRecovered()) + + } catch { + DDLogError("⛔️ Failed to recover incomplete POS catalog sync: \(error)") + ServiceLocator.analytics.track(event: .POSCatalogSync.recoveryError(error)) + } + } + } + } +} + +// MARK: - Background Task Handlers + +private extension POSCatalogSyncBackgroundTaskManager { + + /// Handles full catalog sync using BGProcessingTask + func handleFullCatalogSync(task: BGProcessingTask) { + DDLogInfo("πŸ“± Starting full POS catalog sync in background...") + + // Schedule next full sync - do this after task completion to avoid conflicts + defer { scheduleFullCatalogSync() } + + let syncTask = Task { + do { + let startTime = Date() + try await self.performFullCatalogSync() + + let duration = Date().timeIntervalSince(startTime) + DDLogInfo("πŸ“± Full POS catalog sync completed in \(duration) seconds") + ServiceLocator.analytics.track(event: .POSCatalogSync.fullSyncCompleted(source: "background", duration: duration)) + + task.setTaskCompleted(success: true) + + } catch { + DDLogError("⛔️ Full POS catalog sync failed: \(error)") + ServiceLocator.analytics.track(event: .POSCatalogSync.fullSyncError(error, source: "background")) + task.setTaskCompleted(success: false) + } + } + + // Handle task expiration + task.expirationHandler = { + DDLogInfo("πŸ“± Full POS catalog sync task expired, cancelling...") + ServiceLocator.analytics.track(event: .POSCatalogSync.taskExpired(taskType: "full")) + syncTask.cancel() + } + } + + /// Handles incremental sync using BGAppRefreshTask + func handleIncrementalSync(task: BGAppRefreshTask) { + DDLogInfo("πŸ“± Starting incremental POS catalog sync in background...") + + // Schedule next incremental sync - do this after task completion to avoid conflicts + defer { scheduleIncrementalSync() } + + let syncTask = Task { + do { + let startTime = Date() + try await self.performIncrementalSync() + + let duration = Date().timeIntervalSince(startTime) + DDLogInfo("πŸ“± Incremental POS catalog sync completed in \(duration) seconds") + ServiceLocator.analytics.track(event: .POSCatalogSync.incrementalSyncCompleted(duration: duration)) + + task.setTaskCompleted(success: true) + + } catch { + DDLogError("⛔️ Incremental POS catalog sync failed: \(error)") + ServiceLocator.analytics.track(event: .POSCatalogSync.incrementalSyncError(error)) + task.setTaskCompleted(success: false) + } + } + + // Handle task expiration + task.expirationHandler = { + DDLogInfo("πŸ“± Incremental POS catalog sync task expired, cancelling...") + ServiceLocator.analytics.track(event: .POSCatalogSync.taskExpired(taskType: "incremental")) + syncTask.cancel() + } + } +} + +// MARK: - Sync Implementation Stubs + +private extension POSCatalogSyncBackgroundTaskManager { + + /// Performs the full catalog download and processing + /// Downloads the 80MB JSON catalog from staging site + func performFullCatalogSync() async throws { + guard let siteID = stores.sessionManager.defaultStoreID else { + throw POSCatalogSyncError.noActiveSite + } + + let startTime = Date() + DDLogInfo("πŸ“± [FULL-SYNC] Starting full catalog download for siteID: \(siteID)") + DDLogInfo("πŸ“± [FULL-SYNC] Available background execution time at start: \(formatBackgroundTime(UIApplication.shared.backgroundTimeRemaining))") + + // Update state to indicate download in progress + var currentState = syncState + currentState.isFullSyncInProgress = true + currentState.lastFullSyncStartTime = Date() + syncState = currentState + + do { + // Step 1: Download the 80MB JSON file + let downloadStartTime = Date() + let catalogURL = URL(string: "https://poslarge.mystagingwebsite.com/wp-content/uploads/pos-catalog.json")! + + DDLogInfo("πŸ“± [FULL-SYNC] Starting download from: \(catalogURL.absoluteString)") + + let (data, response) = try await URLSession.shared.data(from: catalogURL) + let downloadDuration = Date().timeIntervalSince(downloadStartTime) + let fileSizeMB = Double(data.count) / (1024 * 1024) + + DDLogInfo("πŸ“± [FULL-SYNC] Downloaded \(String(format: "%.1f", fileSizeMB))MB in \(String(format: "%.2f", downloadDuration)) seconds") + DDLogInfo("πŸ“± [FULL-SYNC] Background time remaining after download: \(formatBackgroundTime(UIApplication.shared.backgroundTimeRemaining))") + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw POSCatalogSyncError.downloadFailed(URLError(.badServerResponse)) + } + + // Step 2: Parse JSON - simulate realistic parsing time + let parseStartTime = Date() + DDLogInfo("πŸ“± [FULL-SYNC] Starting JSON parsing...") + + // On older hardware, parsing 80MB JSON could take 10-20 seconds + // We'll simulate this since we don't actually need the parsed data yet + do { + let jsonObject = try JSONSerialization.jsonObject(with: data) + let parseDuration = Date().timeIntervalSince(parseStartTime) + DDLogInfo("πŸ“± [FULL-SYNC] JSON parsed in \(String(format: "%.2f", parseDuration)) seconds") + DDLogInfo("πŸ“± [FULL-SYNC] Background time remaining after parsing: \(formatBackgroundTime(UIApplication.shared.backgroundTimeRemaining))") + + // Simulate realistic processing time + try await simulateDatabaseInsertion() + + } catch { + DDLogError("⛔️ JSON parsing failed: \(error)") + throw POSCatalogSyncError.processingFailed(error) + } + + // Step 3: Update completion state + var completedState = syncState + completedState.isFullSyncInProgress = false + completedState.lastFullSyncCompletionTime = Date() + completedState.pendingFullCatalogPath = nil + syncState = completedState + + let totalDuration = Date().timeIntervalSince(startTime) + DDLogInfo("πŸ“± [FULL-SYNC] Full catalog sync completed in \(String(format: "%.2f", totalDuration)) seconds") + DDLogInfo("πŸ“± [FULL-SYNC] Background time remaining at completion: \(formatBackgroundTime(UIApplication.shared.backgroundTimeRemaining))") + + } catch { + DDLogError("⛔️ Full catalog sync failed: \(error)") + + // Update error state + var errorState = syncState + errorState.lastFullSyncError = error.localizedDescription + syncState = errorState + + throw error + } + } + + /// Simulates realistic database insertion time for older hardware + /// Real implementation would insert parsed catalog data into Core Data + private func simulateDatabaseInsertion() async throws { + let insertStartTime = Date() + DDLogInfo("πŸ“± [FULL-SYNC] Starting database insertion simulation...") + + // Simulate inserting thousands of products/variations/coupons + // On older hardware with thousands of items, this could take 20-40 seconds + // We'll simulate a shorter time to see what actually gets done + for batch in 1...10 { + // Each batch represents processing ~1000 items + let batchStartTime = Date() + + // Check if we still have background time + let remainingTime = UIApplication.shared.backgroundTimeRemaining + DDLogInfo("πŸ“± [FULL-SYNC] Processing batch \(batch)/10, background time remaining: \(formatBackgroundTime(remainingTime))") + + if remainingTime < 5.0 { + DDLogWarn("⚠️ Background time running low, may not complete all processing") + } + + // Simulate processing time per batch (2-4 seconds per 1000 items on older hardware) + try await Task.sleep(for: .seconds(2.5)) + + let batchDuration = Date().timeIntervalSince(batchStartTime) + DDLogInfo("πŸ“± [FULL-SYNC] Batch \(batch) completed in \(String(format: "%.2f", batchDuration)) seconds") + + // Stop if we're running out of time + if UIApplication.shared.backgroundTimeRemaining < 3.0 { + DDLogWarn("⚠️ Stopping processing due to low background time remaining") + break + } + } + + let totalInsertDuration = Date().timeIntervalSince(insertStartTime) + DDLogInfo("πŸ“± [FULL-SYNC] Database insertion simulation completed in \(String(format: "%.2f", totalInsertDuration)) seconds") + } + + /// Performs incremental sync using PointOfSaleItemService + /// Fetches a few pages of products to update recent changes + func performIncrementalSync() async throws { + guard let siteID = stores.sessionManager.defaultStoreID else { + throw POSCatalogSyncError.noActiveSite + } + + let startTime = Date() + let lastSync = UserDefaults.standard[.lastPOSIncrementalSyncTimestamp] as? Date ?? Date.distantPast + + DDLogInfo("πŸ“± [INCREMENTAL-SYNC] Starting incremental sync for siteID: \(siteID), last sync: \(lastSync)") + DDLogInfo("πŸ“± [INCREMENTAL-SYNC] Available background execution time at start: \(formatBackgroundTime(UIApplication.shared.backgroundTimeRemaining))") + + do { + // Create POS item service for fetching products + let currencySettings = ServiceLocator.currencySettings + let itemService = PointOfSaleItemService(currencySettings: currencySettings) + + // Create fetch strategy factory and then get the default strategy + let strategyFactory = PointOfSaleItemFetchStrategyFactory( + siteID: siteID, + credentials: stores.sessionManager.defaultCredentials + ) + let analytics = POSItemFetchAnalytics(itemType: .product) + let fetchStrategy = strategyFactory.defaultStrategy(analytics: analytics) + + var totalItemsFetched = 0 + let maxPages = 3 // Limit to 3 pages for incremental sync to stay within background time limits + + // Fetch a few pages of products to get recent updates + for pageNumber in 1...maxPages { + let pageStartTime = Date() + let remainingTime = UIApplication.shared.backgroundTimeRemaining + + DDLogInfo("πŸ“± [INCREMENTAL-SYNC] Fetching page \(pageNumber)/\(maxPages), background time remaining: \(formatBackgroundTime(remainingTime))") + + if remainingTime < 10.0 { + DDLogWarn("⚠️ [INCREMENTAL-SYNC] Background time running low, stopping incremental sync at page \(pageNumber)") + break + } + + do { + let pagedItems = try await itemService.providePointOfSaleItems( + pageNumber: pageNumber, + fetchStrategy: fetchStrategy + ) + + let pageDuration = Date().timeIntervalSince(pageStartTime) + totalItemsFetched += pagedItems.items.count + + DDLogInfo("πŸ“± [INCREMENTAL-SYNC] Page \(pageNumber): fetched \(pagedItems.items.count) items in \(String(format: "%.2f", pageDuration)) seconds") + DDLogInfo("πŸ“± [INCREMENTAL-SYNC] Background time remaining after page \(pageNumber): \(formatBackgroundTime(UIApplication.shared.backgroundTimeRemaining))") + + // Simulate database update time (would normally update Core Data here) + if !pagedItems.items.isEmpty { + try await simulateIncrementalDatabaseUpdate(itemCount: pagedItems.items.count) + } + + // Stop if no more pages + if !pagedItems.hasMorePages { + DDLogInfo("πŸ“± [INCREMENTAL-SYNC] Reached end of available pages at page \(pageNumber)") + break + } + + } catch { + DDLogError("⛔️ Failed to fetch page \(pageNumber): \(error)") + // Continue with next page rather than failing completely + } + } + + // Update last sync timestamp + UserDefaults.standard[.lastPOSIncrementalSyncTimestamp] = Date() + + let totalDuration = Date().timeIntervalSince(startTime) + DDLogInfo("πŸ“± [INCREMENTAL-SYNC] Incremental sync completed: \(totalItemsFetched) items in \(String(format: "%.2f", totalDuration)) seconds") + DDLogInfo("πŸ“± [INCREMENTAL-SYNC] Background time remaining at completion: \(formatBackgroundTime(UIApplication.shared.backgroundTimeRemaining))") + + } catch { + DDLogError("⛔️ Incremental sync failed: \(error)") + throw error + } + } + + /// Simulates database update time for incremental sync + /// Real implementation would update existing records or insert new ones + private func simulateIncrementalDatabaseUpdate(itemCount: Int) async throws { + let updateStartTime = Date() + + // Simulate time to update database records (much faster than full insert) + // Estimate 0.1 seconds per 100 items for incremental updates + let estimatedSeconds = Double(itemCount) / 1000.0 + let actualSeconds = max(0.1, min(estimatedSeconds, 2.0)) // Between 0.1 and 2 seconds + + try await Task.sleep(for: .seconds(actualSeconds)) + + let updateDuration = Date().timeIntervalSince(updateStartTime) + DDLogInfo("πŸ“± [INCREMENTAL-SYNC] Updated \(itemCount) items in database in \(String(format: "%.2f", updateDuration)) seconds") + } + + /// Processes a downloaded catalog file (for recovery scenarios) + func processDownloadedCatalog(at url: URL) async throws { + DDLogInfo("πŸ“± Processing downloaded catalog at: \(url.path)") + + // TODO: Implement catalog processing + // This would: + // 1. Decompress the JSON file + // 2. Parse catalog data + // 3. Insert/update database records + // 4. Clean up temporary file + + throw POSCatalogSyncError.notImplemented("Catalog processing implementation needed") + } +} + +// MARK: - State Management + +/// Represents the current state of POS catalog synchronization +private struct POSSyncState: Codable { + var isFullSyncInProgress: Bool = false + var lastFullSyncStartTime: Date? + var lastFullSyncCompletionTime: Date? + var lastFullSyncError: String? + var pendingFullCatalogPath: String? // Path to downloaded but unprocessed catalog + + var lastIncrementalSyncTime: Date? + var lastIncrementalSyncError: String? +} + +// MARK: - Error Types + +enum POSCatalogSyncError: LocalizedError { + case noActiveSite + case notImplemented(String) + case downloadFailed(Error) + case processingFailed(Error) + + var errorDescription: String? { + switch self { + case .noActiveSite: + return "No active site available for sync" + case .notImplemented(let message): + return message + case .downloadFailed(let error): + return "Download failed: \(error.localizedDescription)" + case .processingFailed(let error): + return "Processing failed: \(error.localizedDescription)" + } + } +} + +// MARK: - Helper Extensions + +private extension POSCatalogSyncBackgroundTaskManager { + static func isRunningTests() -> Bool { + return NSClassFromString("XCTestCase") != nil + } + + static func isRunningDebugBuild() -> Bool { +#if DEBUG + return true +#else + return false +#endif + } + + /// Formats background time remaining for logging, avoiding huge numbers in foreground + func formatBackgroundTime(_ time: TimeInterval) -> String { + return time < Double.greatestFiniteMagnitude ? "\(String(format: "%.1f", time)) seconds" : "unlimited (foreground)" + } +} diff --git a/WooCommerce/Classes/Tools/BackgroundTasks/POSCatalogSyncController.swift b/WooCommerce/Classes/Tools/BackgroundTasks/POSCatalogSyncController.swift new file mode 100644 index 00000000000..fb284bd1339 --- /dev/null +++ b/WooCommerce/Classes/Tools/BackgroundTasks/POSCatalogSyncController.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Provides easy access to POS catalog sync operations from anywhere in the app. +/// This class serves as a bridge between the UI and the background sync manager. +/// +final class POSCatalogSyncController { + + /// Shared instance for app-wide access + static let shared = POSCatalogSyncController() + + private init() {} + + /// Reference to the background sync manager from AppDelegate + private var syncManager: POSCatalogSyncBackgroundTaskManager? { + return AppDelegate.shared.posCatalogSyncManager + } + + // MARK: - Public Interface + + /// Checks if a full catalog sync is currently in progress + var isFullSyncInProgress: Bool { + // This would check the sync state - implementation depends on exposing state from the manager + return false // TODO: Implement by exposing state from POSCatalogSyncBackgroundTaskManager + } + + /// Starts a foreground full catalog sync that continues in background + /// Returns a task that can be used to monitor progress or cancellation + @discardableResult + func startForegroundFullSync() -> Task? { + guard let syncManager = syncManager else { + DDLogError("⛔️ POS catalog sync manager not available") + return nil + } + + DDLogInfo("πŸ“± Starting foreground POS catalog sync via controller...") + return syncManager.startForegroundFullSync() + } + + /// Manually triggers an incremental sync (typically not needed as it's scheduled automatically) + func triggerIncrementalSync() { + guard let syncManager = syncManager else { + DDLogError("⛔️ POS catalog sync manager not available") + return + } + + syncManager.scheduleIncrementalSync() + DDLogInfo("πŸ“± Manually triggered incremental POS catalog sync") + } + + /// Re-schedules background syncs (useful after user settings changes) + func rescheduleBackgroundSyncs() { + guard let syncManager = syncManager else { + DDLogError("⛔️ POS catalog sync manager not available") + return + } + + syncManager.scheduleFullCatalogSync() + syncManager.scheduleIncrementalSync() + DDLogInfo("πŸ“± Rescheduled POS catalog background syncs") + } +} + +// MARK: - Usage Example +/* + + // In a POS view controller when user first logs in: + if POSCatalogSyncController.shared.isFullSyncInProgress == false { + let syncTask = POSCatalogSyncController.shared.startForegroundFullSync() + // Show progress UI and monitor syncTask as needed + } + + // In settings when user changes sync preferences: + POSCatalogSyncController.shared.rescheduleBackgroundSyncs() + + */ diff --git a/WooCommerce/Classes/Tools/BackgroundTasks/POSCatalogSyncExample.swift b/WooCommerce/Classes/Tools/BackgroundTasks/POSCatalogSyncExample.swift new file mode 100644 index 00000000000..a3a8c707b9c --- /dev/null +++ b/WooCommerce/Classes/Tools/BackgroundTasks/POSCatalogSyncExample.swift @@ -0,0 +1,225 @@ +// +// POSCatalogSyncExample.swift +// WooCommerce +// +// Example code showing how to use the POS Catalog Sync system +// This file demonstrates the features and can be used for testing +// + +import Foundation +import UIKit +import BackgroundTasks + +// MARK: - Usage Examples + +/* + + ## Testing the Background Sync System + + To test the background sync functionality: + + 1. **Full Catalog Sync (BGProcessingTask)** + - Will attempt to download 80MB JSON from https://poslarge.mystagingwebsite.com/wp-content/uploads/pos-catalog.json + - Logs available background execution time at each step + - Simulates realistic database processing times + - Demonstrates how much can be completed within background time limits + + 2. **Incremental Sync (BGAppRefreshTask)** + - Uses PointOfSaleItemService to fetch a few pages of products + - Shows timing for API calls vs database updates + - Stops early if background time is running low + + ## Key Logging to Watch For: + + ``` + πŸ“± Available background execution time at start: X.X seconds + πŸ“± Downloaded 80.0MB in 12.34 seconds + πŸ“± Background time remaining after download: X.X seconds + πŸ“± JSON parsed in 5.67 seconds + πŸ“± Background time remaining after parsing: X.X seconds + πŸ“± Processing batch 3/10, background time remaining: 8.9 seconds + ⚠️ Background time running low, may not complete all processing + πŸ“± Full catalog sync completed in 45.67 seconds + πŸ“± Background time remaining at completion: 1.2 seconds + ``` + + ## Expected Results: + + - **BGProcessingTask**: Should get ~30-60 seconds typically, may complete 3-5 processing batches + - **BGAppRefreshTask**: Should get ~15-30 seconds, may complete 1-3 pages of incremental sync + - Both will log exactly how much time they receive and use + + ## Manual Testing: + + To manually test background sync behavior: + + ```swift + // Test foreground sync that continues in background + let syncTask = POSCatalogSyncController.shared.startForegroundFullSync() + + // Monitor the task and check logs for timing information + Task { + do { + try await syncTask?.value + print("βœ… Sync completed successfully") + } catch { + print("❌ Sync failed: \(error)") + } + } + + // Test scheduling + let syncManager = AppDelegate.shared.posCatalogSyncManager + syncManager.scheduleFullCatalogSync() + syncManager.scheduleIncrementalSync() + ``` + + ## Background Task Debugging: + + In Xcode, you can simulate background task execution: + 1. Set breakpoints in handleFullCatalogSync() or handleIncrementalSync() + 2. Run app in simulator + 3. Debug -> Simulate Background App Refresh + 4. Or use: e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.automattic.woocommerce.pos.fullCatalogSync"] + + This helps verify the background tasks are properly registered and can be invoked. + + */ + +// MARK: - Development Helper + +#if DEBUG +struct POSCatalogSyncDevelopmentHelper { + + /// Logs the current background sync state for debugging + static func logCurrentSyncState() { + // This could access the sync state if we exposed it + DDLogInfo("πŸ”§ [DEV] Current POS sync state check requested") + + // Log system conditions that might affect background tasks + logSystemConditions() + + // Log last sync times from UserDefaults + let lastIncremental = UserDefaults.standard.object(forKey: "lastPOSIncrementalSyncTimestamp") as? Date + DDLogInfo("πŸ”§ [DEV] Last incremental sync: \(lastIncremental?.description ?? "never")") + + // Log background refresh status + let refreshStatus = UIApplication.shared.backgroundRefreshStatus + switch refreshStatus { + case .available: + DDLogInfo("πŸ”§ [DEV] Background App Refresh: Available βœ…") + case .denied: + DDLogInfo("πŸ”§ [DEV] Background App Refresh: Denied ❌") + case .restricted: + DDLogInfo("πŸ”§ [DEV] Background App Refresh: Restricted ⚠️") + @unknown default: + DDLogInfo("πŸ”§ [DEV] Background App Refresh: Unknown") + } + + // Log remaining background time (only meaningful during background execution) + let remainingTime = UIApplication.shared.backgroundTimeRemaining + if remainingTime < Double.greatestFiniteMagnitude { + DDLogInfo("πŸ”§ [DEV] Background time remaining: \(String(format: "%.1f", remainingTime)) seconds") + } else { + DDLogInfo("πŸ”§ [DEV] App is running in foreground") + } + + // Log pending background tasks + logPendingBackgroundTasks() + } + + /// Logs system conditions that might affect background task scheduling + private static func logSystemConditions() { + DDLogInfo("πŸ”§ [DEV] === System Conditions Check ===") + + // Check Low Power Mode + if ProcessInfo.processInfo.isLowPowerModeEnabled { + DDLogInfo("πŸ”§ [DEV] ⚠️ Low Power Mode: ENABLED (severely limits background tasks)") + } else { + DDLogInfo("πŸ”§ [DEV] βœ… Low Power Mode: Disabled") + } + + // Check device state + let device = UIDevice.current + DDLogInfo("πŸ”§ [DEV] Battery level: \(device.batteryLevel * 100)%") + + switch device.batteryState { + case .charging, .full: + DDLogInfo("πŸ”§ [DEV] βœ… Battery: Charging/Full (good for BGProcessingTask)") + case .unplugged: + DDLogInfo("πŸ”§ [DEV] ⚠️ Battery: Unplugged (BGProcessingTask may be limited)") + case .unknown: + DDLogInfo("πŸ”§ [DEV] Battery state: Unknown") + @unknown default: + DDLogInfo("πŸ”§ [DEV] Battery state: Unknown") + } + + // Check app usage for BGProcessingTask scheduling + DDLogInfo("πŸ”§ [DEV] App state: \(UIApplication.shared.applicationState == .active ? "Active" : "Background")") + + // Check for conditions that affect BGProcessingTask specifically + if ProcessInfo.processInfo.isLowPowerModeEnabled { + DDLogInfo("πŸ”§ [DEV] 🚨 BGProcessingTask will likely be rejected due to Low Power Mode") + } + + if device.batteryState == .unplugged && device.batteryLevel < 0.5 { + DDLogInfo("πŸ”§ [DEV] ⚠️ BGProcessingTask may be rejected: unplugged + low battery") + } + + DDLogInfo("πŸ”§ [DEV] === End System Conditions ===") + } + + /// Logs all pending background tasks for debugging + private static func logPendingBackgroundTasks() { + DDLogInfo("πŸ”§ [DEV] Checking pending background tasks...") + + BGTaskScheduler.shared.getPendingTaskRequests { taskRequests in + DispatchQueue.main.async { + DDLogInfo("πŸ”§ [DEV] Total pending tasks: \(taskRequests.count)") + + for request in taskRequests { + let earliestDate = request.earliestBeginDate?.description ?? "immediately" + DDLogInfo("πŸ”§ [DEV] Pending task: \(request.identifier), earliest: \(earliestDate)") + + if let processingRequest = request as? BGProcessingTaskRequest { + DDLogInfo("πŸ”§ [DEV] Type: BGProcessingTask, network: \(processingRequest.requiresNetworkConnectivity), power: \(processingRequest.requiresExternalPower)") + } else if request is BGAppRefreshTaskRequest { + DDLogInfo("πŸ”§ [DEV] Type: BGAppRefreshTask") + } + } + + // Check specifically for our tasks + let ourFullSyncTask = taskRequests.first { $0.identifier == POSCatalogSyncBackgroundTaskManager.fullCatalogSyncIdentifier } + let ourIncrementalTask = taskRequests.first { $0.identifier == POSCatalogSyncBackgroundTaskManager.incrementalSyncIdentifier } + + DDLogInfo("πŸ”§ [DEV] Our full sync task pending: \(ourFullSyncTask != nil ? "βœ…" : "❌")") + DDLogInfo("πŸ”§ [DEV] Our incremental sync task pending: \(ourIncrementalTask != nil ? "βœ…" : "❌")") + } + } + } + + /// Manually triggers sync operations for testing + static func triggerManualSync() { + DDLogInfo("πŸ”§ [DEV] Manually triggering POS catalog sync...") + + let controller = POSCatalogSyncController.shared + + // Start a foreground sync for immediate testing + let syncTask = controller.startForegroundFullSync() + + Task { + do { + try await syncTask?.value + DDLogInfo("πŸ”§ [DEV] Manual sync completed successfully") + } catch { + DDLogError("πŸ”§ [DEV] Manual sync failed: \(error)") + } + } + } + + /// Schedules fresh background tasks for testing + static func rescheduleBackgroundTasks() { + DDLogInfo("πŸ”§ [DEV] Rescheduling background tasks...") + POSCatalogSyncController.shared.rescheduleBackgroundSyncs() + } +} +#endif diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift index da2c77b3390..2b90540ab08 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift @@ -26,6 +26,144 @@ struct BetaFeaturesConfiguration: View { TitleAndToggleRow(title: feature.title, isOn: viewModel.isOn(feature: feature)) } } + +#if DEBUG + Section(footer: Text(Localization.posSyncTestingDescription)) { + Button(action: { + viewModel.triggerPOSFullSync() + }) { + HStack { + Text(Localization.triggerFullSync) + .foregroundColor(.primary) + Spacer() + Image(systemName: "arrow.down.circle") + .foregroundColor(.blue) + } + } + + Button(action: { + viewModel.triggerPOSIncrementalSync() + }) { + HStack { + Text(Localization.triggerIncrementalSync) + .foregroundColor(.primary) + Spacer() + Image(systemName: "arrow.clockwise.circle") + .foregroundColor(.blue) + } + } + + Button(action: { + viewModel.logPOSSyncStatus() + }) { + HStack { + Text(Localization.logSyncStatus) + .foregroundColor(.primary) + Spacer() + Image(systemName: "info.circle") + .foregroundColor(.green) + } + } + + Button(action: { + viewModel.forceScheduleFullSync() + }) { + HStack { + Text(Localization.forceScheduleFullSync) + .foregroundColor(.primary) + Spacer() + Image(systemName: "calendar.badge.plus") + .foregroundColor(.orange) + } + } + + Button(action: { + viewModel.forceScheduleIncrementalSync() + }) { + HStack { + Text(Localization.forceScheduleIncrementalSync) + .foregroundColor(.primary) + Spacer() + Image(systemName: "calendar.badge.clock") + .foregroundColor(.orange) + } + } + + Button(action: { + viewModel.forceScheduleMainAppRefresh() + }) { + HStack { + Text(Localization.forceScheduleMainAppRefresh) + .foregroundColor(.primary) + Spacer() + Image(systemName: "calendar.circle") + .foregroundColor(.orange) + } + } + + Button(action: { + viewModel.forceScheduleAllTasks() + }) { + HStack { + Text(Localization.forceScheduleAllTasks) + .foregroundColor(.primary) + Spacer() + Image(systemName: "calendar.badge.exclamationmark") + .foregroundColor(.red) + } + } + } + + Section(footer: Text(Localization.cancelTasksDescription)) { + Button(action: { + viewModel.cancelAllBackgroundTasks() + }) { + HStack { + Text(Localization.cancelAllTasks) + .foregroundColor(.primary) + Spacer() + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + } + + Button(action: { + viewModel.cancelFullSyncTask() + }) { + HStack { + Text(Localization.cancelFullSyncTask) + .foregroundColor(.primary) + Spacer() + Image(systemName: "xmark.circle") + .foregroundColor(.orange) + } + } + + Button(action: { + viewModel.cancelIncrementalSyncTask() + }) { + HStack { + Text(Localization.cancelIncrementalSyncTask) + .foregroundColor(.primary) + Spacer() + Image(systemName: "xmark.circle") + .foregroundColor(.orange) + } + } + + Button(action: { + viewModel.cancelMainAppRefreshTask() + }) { + HStack { + Text(Localization.cancelMainAppRefreshTask) + .foregroundColor(.primary) + Spacer() + Image(systemName: "xmark.circle") + .foregroundColor(.orange) + } + } + } +#endif } .background(Color(.listForeground(modal: false))) .listStyle(.grouped) @@ -35,6 +173,60 @@ struct BetaFeaturesConfiguration: View { private enum Localization { static let title = NSLocalizedString("Experimental Features", comment: "Experimental features navigation title") + +#if DEBUG + static let posSyncTestingDescription = NSLocalizedString( + "POS Catalog Sync Testing - Trigger background sync operations and view detailed logs to understand timing and behavior.", + comment: "Description for POS catalog sync testing section in beta features") + + static let triggerFullSync = NSLocalizedString( + "Trigger Full Catalog Sync", + comment: "Button to trigger a full POS catalog sync for testing") + + static let triggerIncrementalSync = NSLocalizedString( + "Trigger Incremental Sync", + comment: "Button to trigger an incremental POS catalog sync for testing") + + static let logSyncStatus = NSLocalizedString( + "Log Current Sync Status", + comment: "Button to log current POS sync status for debugging") + + static let forceScheduleFullSync = NSLocalizedString( + "Force Schedule Full Sync", + comment: "Button to force schedule full catalog sync task for debugging") + + static let forceScheduleIncrementalSync = NSLocalizedString( + "Force Schedule Incremental Sync", + comment: "Button to force schedule incremental catalog sync task for debugging") + + static let forceScheduleMainAppRefresh = NSLocalizedString( + "Force Schedule Main App Refresh", + comment: "Button to force schedule main app refresh task for debugging") + + static let forceScheduleAllTasks = NSLocalizedString( + "Force Schedule ALL Tasks", + comment: "Button to force schedule all background tasks at once for debugging") + + static let cancelTasksDescription = NSLocalizedString( + "Cancel Background Tasks - Remove scheduled tasks from the iOS background task queue for testing purposes.", + comment: "Description for cancel background tasks section in beta features") + + static let cancelAllTasks = NSLocalizedString( + "Cancel ALL Tasks", + comment: "Button to cancel all scheduled background tasks") + + static let cancelFullSyncTask = NSLocalizedString( + "Cancel Full Sync Task", + comment: "Button to cancel full catalog sync background task") + + static let cancelIncrementalSyncTask = NSLocalizedString( + "Cancel Incremental Sync Task", + comment: "Button to cancel incremental sync background task") + + static let cancelMainAppRefreshTask = NSLocalizedString( + "Cancel Main App Refresh Task", + comment: "Button to cancel main app refresh background task") +#endif } struct BetaFeaturesConfiguration_Previews: PreviewProvider { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift index 73d9c30d9d0..e339c82ef8d 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift @@ -1,6 +1,7 @@ import SwiftUI import protocol Experiments.FeatureFlagService import struct Storage.GeneralAppSettingsStorage +import BackgroundTasks final class BetaFeaturesConfigurationViewModel: ObservableObject { @Published private(set) var availableFeatures: [BetaFeature] = [] @@ -13,8 +14,8 @@ final class BetaFeaturesConfigurationViewModel: ObservableObject { self.featureFlagService = featureFlagService availableFeatures = BetaFeature.allCases.filter { betaFeature in switch betaFeature { - case .viewAddOns: - return true + case .viewAddOns: + return true } } } @@ -22,4 +23,69 @@ final class BetaFeaturesConfigurationViewModel: ObservableObject { func isOn(feature: BetaFeature) -> Binding { appSettings.betaFeatureEnabledBinding(feature) } + +#if DEBUG + // MARK: - POS Catalog Sync Testing + + func triggerPOSFullSync() { + DDLogInfo("πŸ”§ [DEV] [FULL-SYNC] Triggering POS full catalog sync from beta features menu...") + POSCatalogSyncDevelopmentHelper.triggerManualSync() + } + + func triggerPOSIncrementalSync() { + DDLogInfo("πŸ”§ [DEV] [INCREMENTAL-SYNC] Triggering POS incremental sync from beta features menu...") + POSCatalogSyncController.shared.triggerIncrementalSync() + } + + func logPOSSyncStatus() { + DDLogInfo("πŸ”§ [DEV] [STATUS] Logging POS sync status from beta features menu...") + POSCatalogSyncDevelopmentHelper.logCurrentSyncState() + } + + func forceScheduleFullSync() { + DDLogInfo("πŸ”§ [DEV] [FORCE-SCHEDULE] Forcing full sync task to be scheduled...") + AppDelegate.shared.posCatalogSyncManager.scheduleFullCatalogSync() + } + + func forceScheduleIncrementalSync() { + DDLogInfo("πŸ”§ [DEV] [FORCE-SCHEDULE] Forcing incremental sync task to be scheduled...") + AppDelegate.shared.posCatalogSyncManager.scheduleIncrementalSync() + } + + func forceScheduleMainAppRefresh() { + DDLogInfo("πŸ”§ [DEV] [FORCE-SCHEDULE] Forcing main app refresh task to be scheduled...") + AppDelegate.shared.appRefreshHandler.scheduleAppRefresh() + } + + func forceScheduleAllTasks() { + DDLogInfo("πŸ”§ [DEV] [FORCE-SCHEDULE] Forcing all background tasks to be scheduled...") + AppDelegate.shared.appRefreshHandler.scheduleAppRefresh() + AppDelegate.shared.posCatalogSyncManager.scheduleFullCatalogSync() + AppDelegate.shared.posCatalogSyncManager.scheduleIncrementalSync() + } + + // MARK: - Cancel Background Tasks + + func cancelFullSyncTask() { + DDLogInfo("πŸ”§ [DEV] [CANCEL] Cancelling full sync task...") + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: POSCatalogSyncBackgroundTaskManager.fullCatalogSyncIdentifier) + } + + func cancelIncrementalSyncTask() { + DDLogInfo("πŸ”§ [DEV] [CANCEL] Cancelling incremental sync task...") + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: POSCatalogSyncBackgroundTaskManager.incrementalSyncIdentifier) + } + + func cancelMainAppRefreshTask() { + DDLogInfo("πŸ”§ [DEV] [CANCEL] Cancelling main app refresh task...") + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundTaskRefreshDispatcher.taskIdentifier) + } + + func cancelAllBackgroundTasks() { + DDLogInfo("πŸ”§ [DEV] [CANCEL] Cancelling ALL background tasks...") + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundTaskRefreshDispatcher.taskIdentifier) + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: POSCatalogSyncBackgroundTaskManager.fullCatalogSyncIdentifier) + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: POSCatalogSyncBackgroundTaskManager.incrementalSyncIdentifier) + } +#endif } diff --git a/WooCommerce/Resources/Info.plist b/WooCommerce/Resources/Info.plist index 4be39f02441..b3b7fd9365f 100644 --- a/WooCommerce/Resources/Info.plist +++ b/WooCommerce/Resources/Info.plist @@ -5,7 +5,11 @@ BGTaskSchedulerPermittedIdentifiers com.automattic.woocommerce.refresh + com.automattic.woocommerce.pos.fullCatalogSync + com.automattic.woocommerce.pos.incrementalSync + BuildConfigurationName + $(CONFIGURATION) CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -79,10 +83,10 @@ NSLocationWhenInUseUsageDescription Location access is required in order to accept payments. - NSPhotoLibraryUsageDescription - To save photos from camera for Product images, or to add photos or videos to your Products or support tickets. NSMicrophoneUsageDescription Woo uses your microphone to let you capture audio when recording videos for your store’s media library. + NSPhotoLibraryUsageDescription + To save photos from camera for Product images, or to add photos or videos to your Products or support tickets. NSUserActivityTypes UIAppFonts @@ -129,6 +133,7 @@ fetch remote-notification bluetooth-central + processing UILaunchStoryboardName LaunchScreen @@ -151,7 +156,5 @@ UIViewControllerBasedStatusBarAppearance - BuildConfigurationName - $(CONFIGURATION) diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index d065e0c6ac9..a927c9199fb 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -934,6 +934,9 @@ 20C3DB262E1E69CF00CF7D3B /* PointOfSaleBarcodeScannerSetupModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C3DB202E1E69CF00CF7D3B /* PointOfSaleBarcodeScannerSetupModels.swift */; }; 20C3DB272E1E69CF00CF7D3B /* PointOfSaleBarcodeScannerSetupFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C3DB1D2E1E69CF00CF7D3B /* PointOfSaleBarcodeScannerSetupFlow.swift */; }; 20C3DB292E1E6FBA00CF7D3B /* PointOfSaleFlowButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C3DB282E1E6FBA00CF7D3B /* PointOfSaleFlowButtonsView.swift */; }; + 20C6D8402E4B981C0000B386 /* POSCatalogSyncBackgroundTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C6D83E2E4B981C0000B386 /* POSCatalogSyncBackgroundTaskManager.swift */; }; + 20C6D8412E4B981C0000B386 /* POSCatalogSyncController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C6D83F2E4B981C0000B386 /* POSCatalogSyncController.swift */; }; + 20C6D8432E4B98F30000B386 /* POSCatalogSyncExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C6D8422E4B98F30000B386 /* POSCatalogSyncExample.swift */; }; 20C6E7512CDE4AEA00CD124C /* ItemListState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C6E7502CDE4AEA00CD124C /* ItemListState.swift */; }; 20C909962D3151FA0013BCCF /* ItemListBaseItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C909952D3151FA0013BCCF /* ItemListBaseItem.swift */; }; 20CC1EDB2AFA8381006BD429 /* InPersonPaymentsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20CC1EDA2AFA8381006BD429 /* InPersonPaymentsMenu.swift */; }; @@ -2762,9 +2765,9 @@ DEA357132ADCC4C9006380BA /* BlazeCampaignListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA357122ADCC4C9006380BA /* BlazeCampaignListViewModelTests.swift */; }; DEA64C532E40B04700791018 /* OrderDetailsProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA64C522E40B04000791018 /* OrderDetailsProduct.swift */; }; DEA64C552E41A2D000791018 /* Product+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA64C542E41A2CA00791018 /* Product+Helpers.swift */; }; + DEA65B372E41A65600791018 /* ProductListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA65B362E41A65100791018 /* ProductListItem.swift */; }; DEA66A192E41DECF00791018 /* ShippingLabelProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA66A182E41DEC200791018 /* ShippingLabelProduct.swift */; }; DEA66A1B2E41E0C000791018 /* BlazeCampaignProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA66A1A2E41E0B800791018 /* BlazeCampaignProduct.swift */; }; - DEA65B372E41A65600791018 /* ProductListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA65B362E41A65100791018 /* ProductListItem.swift */; }; DEA6BCAF2BC6A9B10017D671 /* StoreStatsChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA6BCAE2BC6A9B10017D671 /* StoreStatsChart.swift */; }; DEA6BCB12BC6AA040017D671 /* StoreStatsChartViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA6BCB02BC6AA040017D671 /* StoreStatsChartViewModel.swift */; }; DEA88F502AA9D0100037273B /* AddEditProductCategoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA88F4F2AA9D0100037273B /* AddEditProductCategoryViewModel.swift */; }; @@ -4133,6 +4136,9 @@ 20C3DB202E1E69CF00CF7D3B /* PointOfSaleBarcodeScannerSetupModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleBarcodeScannerSetupModels.swift; sourceTree = ""; }; 20C3DB212E1E69CF00CF7D3B /* PointOfSaleBarcodeScannerSetupViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleBarcodeScannerSetupViews.swift; sourceTree = ""; }; 20C3DB282E1E6FBA00CF7D3B /* PointOfSaleFlowButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleFlowButtonsView.swift; sourceTree = ""; }; + 20C6D83E2E4B981C0000B386 /* POSCatalogSyncBackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSCatalogSyncBackgroundTaskManager.swift; sourceTree = ""; }; + 20C6D83F2E4B981C0000B386 /* POSCatalogSyncController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSCatalogSyncController.swift; sourceTree = ""; }; + 20C6D8422E4B98F30000B386 /* POSCatalogSyncExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSCatalogSyncExample.swift; sourceTree = ""; }; 20C6E7502CDE4AEA00CD124C /* ItemListState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListState.swift; sourceTree = ""; }; 20C909952D3151FA0013BCCF /* ItemListBaseItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListBaseItem.swift; sourceTree = ""; }; 20CC1EDA2AFA8381006BD429 /* InPersonPaymentsMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsMenu.swift; sourceTree = ""; }; @@ -5965,9 +5971,9 @@ DEA357122ADCC4C9006380BA /* BlazeCampaignListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaignListViewModelTests.swift; sourceTree = ""; }; DEA64C522E40B04000791018 /* OrderDetailsProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsProduct.swift; sourceTree = ""; }; DEA64C542E41A2CA00791018 /* Product+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+Helpers.swift"; sourceTree = ""; }; + DEA65B362E41A65100791018 /* ProductListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListItem.swift; sourceTree = ""; }; DEA66A182E41DEC200791018 /* ShippingLabelProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelProduct.swift; sourceTree = ""; }; DEA66A1A2E41E0B800791018 /* BlazeCampaignProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaignProduct.swift; sourceTree = ""; }; - DEA65B362E41A65100791018 /* ProductListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListItem.swift; sourceTree = ""; }; DEA6BCAE2BC6A9B10017D671 /* StoreStatsChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreStatsChart.swift; sourceTree = ""; }; DEA6BCB02BC6AA040017D671 /* StoreStatsChartViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreStatsChartViewModel.swift; sourceTree = ""; }; DEA88F4F2AA9D0100037273B /* AddEditProductCategoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditProductCategoryViewModel.swift; sourceTree = ""; }; @@ -8824,6 +8830,9 @@ 26BCA0412C35EDBF000BE96C /* OrderListSyncBackgroundTask.swift */, 26F115AE2C49A9250019CD73 /* DashboardSyncBackgroundTask.swift */, 26DDA4A82C4839B8005FBEBF /* DashboardTimestampStore.swift */, + 20C6D83E2E4B981C0000B386 /* POSCatalogSyncBackgroundTaskManager.swift */, + 20C6D83F2E4B981C0000B386 /* POSCatalogSyncController.swift */, + 20C6D8422E4B98F30000B386 /* POSCatalogSyncExample.swift */, ); path = BackgroundTasks; sourceTree = ""; @@ -15853,6 +15862,8 @@ 02482A8B237BE8C7007E73ED /* LinkSettingsViewController.swift in Sources */, EEC099362BF3C68000FBCF6C /* MostActiveCouponsCard.swift in Sources */, CE227097228F152400C0626C /* WooBasicTableViewCell.swift in Sources */, + 20C6D8402E4B981C0000B386 /* POSCatalogSyncBackgroundTaskManager.swift in Sources */, + 20C6D8412E4B981C0000B386 /* POSCatalogSyncController.swift in Sources */, 02C27BCE282CB52F0065471A /* CardPresentPaymentReceiptEmailCoordinator.swift in Sources */, 20D5575D2DFADF5400D9EC8B /* BarcodeScannerContainer.swift in Sources */, 026826AA2BF59DF70036F959 /* CartView.swift in Sources */, @@ -16724,6 +16735,7 @@ DE7E5E7F2B4BC52C002E28D2 /* MultiSelectionList.swift in Sources */, DA0DBE2F2C4FC61D00DF14C0 /* POSFloatingControlView.swift in Sources */, DE02ABAD2B55288D008E0AC4 /* BlazeTargetLocationSearchView.swift in Sources */, + 20C6D8432E4B98F30000B386 /* POSCatalogSyncExample.swift in Sources */, 45BBFBC3274FDA6400213001 /* HubMenuViewController.swift in Sources */, 20FA73882CDCC3A900554BE3 /* OrderDetailsSyncStateController.swift in Sources */, 451B1747258BD7B600836277 /* AddAttributeOptionsViewModel.swift in Sources */,