diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsEvent+BackgroudUpdates.swift b/WooCommerce/Classes/Analytics/WooAnalyticsEvent+BackgroudUpdates.swift index d50e4a4aba3..6439d82e579 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsEvent+BackgroudUpdates.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsEvent+BackgroudUpdates.swift @@ -1,14 +1,51 @@ import Foundation +import WooFoundation extension WooAnalyticsEvent { enum BackgroundUpdates { private enum Keys { static let timeTaken = "time_taken" + static let backgroundTimeGranted = "background_time_granted" + static let networkType = "network_type" + static let isExpensiveConnection = "is_expensive_connection" + static let isLowDataMode = "is_low_data_mode" + static let isPowered = "is_powered" + static let batteryLevel = "battery_level" + static let isLowPowerMode = "is_low_power_mode" + static let timeSinceLastRun = "time_since_last_run" } - static func dataSynced(timeTaken: TimeInterval) -> WooAnalyticsEvent { - WooAnalyticsEvent(statName: .backgroundDataSynced, properties: [Keys.timeTaken: timeTaken]) + static func dataSynced( + timeTaken: TimeInterval, + backgroundTimeGranted: TimeInterval?, + networkType: String, + isExpensiveConnection: Bool, + isLowDataMode: Bool, + isPowered: Bool, + batteryLevel: Float, + isLowPowerMode: Bool, + timeSinceLastRun: TimeInterval? + ) -> WooAnalyticsEvent { + var properties: [String: WooAnalyticsEventPropertyType] = [ + Keys.timeTaken: Int64(timeTaken), + Keys.networkType: networkType, + Keys.isExpensiveConnection: isExpensiveConnection, + Keys.isLowDataMode: isLowDataMode, + Keys.isPowered: isPowered, + Keys.batteryLevel: Float64(batteryLevel), + Keys.isLowPowerMode: isLowPowerMode + ] + + if let backgroundTimeGranted = backgroundTimeGranted { + properties[Keys.backgroundTimeGranted] = Int64(backgroundTimeGranted) + } + + if let timeSinceLastRun = timeSinceLastRun { + properties[Keys.timeSinceLastRun] = Int64(timeSinceLastRun) + } + + return WooAnalyticsEvent(statName: .backgroundDataSynced, properties: properties) } static func dataSyncError(_ error: Error) -> WooAnalyticsEvent { diff --git a/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift b/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift index 95f9fd27f87..52e2a86af76 100644 --- a/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift +++ b/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift @@ -50,6 +50,7 @@ extension UserDefaults { // Background Task Refresh case latestBackgroundOrderSyncDate + case lastBackgroundRefreshCompletionTime // Blaze Local notification case blazeNoCampaignReminderOpened diff --git a/WooCommerce/Classes/Tools/BackgroundTasks/BackgroundTaskRefreshDispatcher.swift b/WooCommerce/Classes/Tools/BackgroundTasks/BackgroundTaskRefreshDispatcher.swift index 4f1cab73b73..043df4ab1c8 100644 --- a/WooCommerce/Classes/Tools/BackgroundTasks/BackgroundTaskRefreshDispatcher.swift +++ b/WooCommerce/Classes/Tools/BackgroundTasks/BackgroundTaskRefreshDispatcher.swift @@ -1,6 +1,7 @@ import UIKit import Foundation import BackgroundTasks +import Network final class BackgroundTaskRefreshDispatcher { @@ -60,6 +61,7 @@ final class BackgroundTaskRefreshDispatcher { // Launch all refresh tasks in parallel. let refreshTasks = Task { do { + async let systemInfo = BackgroundTaskSystemInfo() let startTime = Date.now @@ -78,7 +80,27 @@ final class BackgroundTaskRefreshDispatcher { } let timeTaken = round(Date.now.timeIntervalSince(startTime)) - ServiceLocator.analytics.track(event: .BackgroundUpdates.dataSynced(timeTaken: timeTaken)) + + var timeSinceLastRun: TimeInterval? = nil + if let lastRunTime = UserDefaults.standard[.lastBackgroundRefreshCompletionTime] as? Date { + timeSinceLastRun = round(lastRunTime.timeIntervalSinceNow.magnitude) + } + + await ServiceLocator.analytics.track(event: .BackgroundUpdates.dataSynced( + timeTaken: timeTaken, + backgroundTimeGranted: systemInfo.backgroundTimeGranted, + networkType: systemInfo.networkType, + isExpensiveConnection: systemInfo.isExpensiveConnection, + isLowDataMode: systemInfo.isLowDataMode, + isPowered: systemInfo.isPowered, + batteryLevel: systemInfo.batteryLevel, + isLowPowerMode: systemInfo.isLowPowerMode, + timeSinceLastRun: timeSinceLastRun + )) + + // Save date, for use in analytics next time we refresh + UserDefaults.standard[.lastBackgroundRefreshCompletionTime] = Date.now + backgroundTask.setTaskCompleted(success: true) } catch { @@ -93,7 +115,7 @@ final class BackgroundTaskRefreshDispatcher { ServiceLocator.analytics.track(event: .BackgroundUpdates.dataSyncError(BackgroundError.expired)) refreshTasks.cancel() } - } + } } private extension BackgroundTaskRefreshDispatcher { @@ -109,3 +131,87 @@ extension BackgroundTaskRefreshDispatcher { case expired } } + +// MARK: - System Information Helper + +private struct NetworkInfo { + let type: String + let isExpensive: Bool + let isLowDataMode: Bool +} + +private struct BackgroundTaskSystemInfo { + let backgroundTimeGranted: TimeInterval? + private let networkInfo: NetworkInfo + let isPowered: Bool + let batteryLevel: Float + let isLowPowerMode: Bool + + // Computed properties for clean external access + var networkType: String { networkInfo.type } + var isExpensiveConnection: Bool { networkInfo.isExpensive } + var isLowDataMode: Bool { networkInfo.isLowDataMode } + + @MainActor + init() async { + // Background time granted (nil if foreground/unlimited) + let backgroundTime = UIApplication.shared.backgroundTimeRemaining + self.backgroundTimeGranted = backgroundTime < Double.greatestFiniteMagnitude ? backgroundTime : nil + + // Network info + self.networkInfo = await Self.getNetworkInfo() + + // Power and battery info + let device = UIDevice.current + device.isBatteryMonitoringEnabled = true + + self.isPowered = device.batteryState == .charging || device.batteryState == .full + self.batteryLevel = device.batteryLevel + self.isLowPowerMode = ProcessInfo.processInfo.isLowPowerModeEnabled + + device.isBatteryMonitoringEnabled = false + } + + private static func getNetworkInfo() async -> NetworkInfo { + return await withCheckedContinuation { continuation in + let monitor = NWPathMonitor() + + monitor.pathUpdateHandler = { path in + continuation.resume(returning: NetworkInfo(path: path)) + monitor.cancel() + } + + let queue = DispatchQueue(label: "network.monitor.queue") + monitor.start(queue: queue) + } + } +} + +private extension NetworkInfo { + init(path: NWPath) { + guard path.status == .satisfied else { + self.type = "no_connection" + self.isExpensive = false + self.isLowDataMode = false + return + } + + self.type = Self.networkType(from: path) + self.isExpensive = path.isExpensive + self.isLowDataMode = path.isConstrained + } + + private static func networkType(from path: NWPath) -> String { + if path.usesInterfaceType(.wifi) { + return "wifi" + } else if path.usesInterfaceType(.cellular) { + return "cellular" + } else if path.usesInterfaceType(.wiredEthernet) { + return "ethernet" + } else if path.usesInterfaceType(.loopback) { + return "loopback" + } else { + return "other" + } + } +}