Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import Foundation
import enum Alamofire.AFError
import KeychainAccess

#if canImport(UIKit)
import UIKit
#endif

public enum ApplicationPasswordUseCaseError: Error {
case duplicateName
case applicationPasswordsDisabled
Expand Down Expand Up @@ -29,25 +33,6 @@ public protocol ApplicationPasswordUseCase {
func deletePassword() async throws
}

/// A wrapper for the `UIDevice` `model` and `identifierForVendor` properties.
///
/// This is necessary because `UIDevice` is part of UIKit which we cannot use when targeting watchOS.
/// So, to keep this package compatible with watchOS, we need to abstract UIKit away and delegate it to the consumers to provide us
/// with the device information.
///
/// This approach is feasible because only the `applicationPasswordName` method in
/// `DefaultApplicationPasswordUseCase` needs access to the information and watchOS does not need to create application
/// passwords. We can therefore pass a `nil` value to it to satisfy the compilation without issues for the user experience.
public struct DeviceModelIdentifierInfo {
let model: String
let identifierForVendor: String

public init(model: String, identifierForVendor: String) {
self.model = model
self.identifierForVendor = identifierForVendor
}
}

final public class DefaultApplicationPasswordUseCase: ApplicationPasswordUseCase {
/// Site Address
///
Expand All @@ -65,31 +50,27 @@ final public class DefaultApplicationPasswordUseCase: ApplicationPasswordUseCase
///
private let storage: ApplicationPasswordStorage

private let deviceModelIdentifierInfo: DeviceModelIdentifierInfo?

/// Used to name the password in wpadmin.
///
private var applicationPasswordName: String {
get {
guard let deviceModelIdentifierInfo else {
return "" // This is not needed on watchOS as the watch does not create application passwords.
}

let bundleIdentifier = Bundle.main.bundleIdentifier ?? "Unknown"
return "\(bundleIdentifier).ios-app-client.\(deviceModelIdentifierInfo.model).\(deviceModelIdentifierInfo.identifierForVendor)"
}
#if !os(watchOS)
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "Unknown"
let model = UIDevice.current.model
let identifierForVendor = UIDevice.current.identifierForVendor?.uuidString ?? ""
return "\(bundleIdentifier).ios-app-client.\(model).\(identifierForVendor)"
#else
fatalError("Unexpected error: Application password should not be generated through watch app")
#endif
}

public init(username: String,
password: String,
siteAddress: String,
deviceModelIdentifierInfo: DeviceModelIdentifierInfo? = nil,
network: Network? = nil,
keychain: Keychain = Keychain(service: WooConstants.keychainServiceName)) throws {
self.siteAddress = siteAddress
self.username = username
self.storage = ApplicationPasswordStorage(keychain: keychain)
self.deviceModelIdentifierInfo = deviceModelIdentifierInfo

if let network {
self.network = network
Expand Down Expand Up @@ -154,7 +135,7 @@ final public class DefaultApplicationPasswordUseCase: ApplicationPasswordUseCase
if let uuidFromLocalPassword {
return uuidFromLocalPassword
} else {
return try await self.fetchUUIDForApplicationPassword(await applicationPasswordName)
return try await self.fetchUUIDForApplicationPassword(applicationPasswordName)
}
}()
try await deleteApplicationPassword(uuidToBeDeleted)
Expand All @@ -167,7 +148,7 @@ private extension DefaultApplicationPasswordUseCase {
/// - Returns: Generated `ApplicationPassword`
///
func createApplicationPassword() async throws -> ApplicationPassword {
let passwordName = await applicationPasswordName
let passwordName = applicationPasswordName

let parameters = [ParameterKey.name: passwordName]
let request = RESTRequest(siteURL: siteAddress, method: .post, path: Path.applicationPasswords, parameters: parameters)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -752,8 +752,7 @@ private extension AuthenticationManager {
guard let useCase = try? DefaultApplicationPasswordUseCase(
username: siteCredentials.username,
password: siteCredentials.password,
siteAddress: siteCredentials.siteURL,
deviceModelIdentifierInfo: UIDevice.current.deviceModelIdentifierInfo
siteAddress: siteCredentials.siteURL
) else {
return assertionFailure("⛔️ Error creating application password use case")
}
Expand Down
1 change: 1 addition & 0 deletions WooCommerce/Classes/Extensions/UserDefaults+Woo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ extension UserDefaults {

// Background Task Refresh
case latestBackgroundOrderSyncDate
case lastBackgroundRefreshCompletionTime

// Blaze Local notification
case blazeNoCampaignReminderOpened
Expand Down
1 change: 0 additions & 1 deletion WooCommerce/Classes/System/SessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@ final class SessionManager: SessionManagerProtocol {
return try? DefaultApplicationPasswordUseCase(username: username,
password: password,
siteAddress: siteAddress,
deviceModelIdentifierInfo: UIDevice.current.deviceModelIdentifierInfo,
keychain: keychain)
case let .applicationPassword(_, _, siteAddress):
return OneTimeApplicationPasswordUseCase(siteAddress: siteAddress, keychain: keychain)
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import UIKit
import Foundation
import BackgroundTasks
import Network

final class BackgroundTaskRefreshDispatcher {

Expand Down Expand Up @@ -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

Expand All @@ -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 {
Expand All @@ -93,7 +115,7 @@ final class BackgroundTaskRefreshDispatcher {
ServiceLocator.analytics.track(event: .BackgroundUpdates.dataSyncError(BackgroundError.expired))
refreshTasks.cancel()
}
}
}
}

private extension BackgroundTaskRefreshDispatcher {
Expand All @@ -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"
}
}
}
Loading
Loading