diff --git a/Framework/Sources/AtomicDictionary.swift b/Framework/Sources/AtomicDictionary.swift index dbcd1f3..4eb046c 100644 --- a/Framework/Sources/AtomicDictionary.swift +++ b/Framework/Sources/AtomicDictionary.swift @@ -14,26 +14,24 @@ class AtomicDictionary { attributes: .concurrent) func setValue(_ value: Value, forKey key: Key) { - queue.async(flags: .barrier) { - self.dictionary[key] = value + // Synchronous barriers make mutations visible on return. Code already executing on this + // queue must not call a mutating method recursively because dispatch_sync cannot re-enter. + queue.sync(flags: .barrier) { + dictionary[key] = value } } func value(forKey key: Key) -> Value? { - var result: Value? queue.sync { - result = dictionary[key] + dictionary[key] } - return result } @discardableResult func removeValue(forKey key: Key) -> Value? { - var result: Value? - queue.sync { - result = dictionary.removeValue(forKey: key) + queue.sync(flags: .barrier) { + dictionary.removeValue(forKey: key) } - return result } } diff --git a/Framework/Sources/DailySchedule.swift b/Framework/Sources/DailySchedule.swift index 3252f44..31f10bf 100644 --- a/Framework/Sources/DailySchedule.swift +++ b/Framework/Sources/DailySchedule.swift @@ -17,55 +17,209 @@ struct SystemSUKClock: SUKClock { } } +struct SchedulingStateContext { + let userDefaults: SUKUserDefaults + let key: String + + var storageKey: String { + userDefaults.storageKey(forKey: key) + } +} + protocol SUKSchedulingStateStore { - func set(_ value: Int, forKey key: String) - func integer(forKey key: String) -> Int + func set(_ value: Int, for context: SchedulingStateContext) + func integer(for context: SchedulingStateContext) -> Int + func removeValue(for context: SchedulingStateContext) } struct UserDefaultsSchedulingStateStore: SUKSchedulingStateStore { - func set(_ value: Int, forKey key: String) { - SUKUserDefaults.standard.set(value, forKey: key) + func set(_ value: Int, for context: SchedulingStateContext) { + context.userDefaults.set(value, forKey: context.key) + } + + func integer(for context: SchedulingStateContext) -> Int { + context.userDefaults.integer(forKey: context.key) } - func integer(forKey key: String) -> Int { - SUKUserDefaults.standard.integer(forKey: key) + func removeValue(for context: SchedulingStateContext) { + context.userDefaults.removeObject(forKey: context.key) } } struct InMemorySchedulingStateStore: SUKSchedulingStateStore { - func set(_ value: Int, forKey key: String) { - sharedDictionary.setValue(value, forKey: key) + func set(_ value: Int, for context: SchedulingStateContext) { + sharedDictionary.setValue(value, forKey: context.storageKey) + } + + func integer(for context: SchedulingStateContext) -> Int { + sharedDictionary.value(forKey: context.storageKey) as? Int ?? 0 + } + + func removeValue(for context: SchedulingStateContext) { + sharedDictionary.removeValue(forKey: context.storageKey) } +} + +private final class SchedulingExecutionTokenBox: NSObject { + let token: SchedulingExecutionToken + + init(_ token: SchedulingExecutionToken) { + self.token = token + } +} + +enum SchedulingExecutionScope { + private static let threadDictionaryKey = + "jp.hituzi.SwiftyUpdateKit.schedulingExecutionToken" + + // A thread-local bridge keeps the parameterless open condition methods source-compatible. + // Overrides must call super synchronously on the same thread; a thread hop cannot carry this + // token and therefore cannot participate in reset invalidation. + static var currentToken: SchedulingExecutionToken? { + (Thread.current.threadDictionary[threadDictionaryKey] + as? SchedulingExecutionTokenBox)?.token + } + + static func withToken(_ token: SchedulingExecutionToken, action: () throws -> T) rethrows + -> T + { + let threadDictionary = Thread.current.threadDictionary + let previousValue = threadDictionary[threadDictionaryKey] + threadDictionary[threadDictionaryKey] = SchedulingExecutionTokenBox(token) + defer { + if let previousValue { + threadDictionary[threadDictionaryKey] = previousValue + } else { + threadDictionary.removeObject(forKey: threadDictionaryKey) + } + } - func integer(forKey key: String) -> Int { - sharedDictionary.value(forKey: key) as? Int ?? 0 + return try action() } } +struct SchedulingExecutionToken { + // The environment and generation survive queue hops; reset increments the generation so work + // created earlier becomes stale without consulting the mutable global runtime configuration. + fileprivate let identifier: UUID + fileprivate let environment: SUKUserDefaults.Environment + fileprivate let generation: UInt64 + fileprivate let executionKey: SchedulingExecutionKey? + let userDefaults: SUKUserDefaults +} + +enum SchedulingExecutionDecision { + case started(SchedulingExecutionToken) + case inProgress + case invalidated(SchedulingExecutionToken) +} + protocol SchedulingExecutionGating: AnyObject { - func beginExecution(forKey key: String) -> Bool - func finishExecution(forKey key: String) + func token(for userDefaults: SUKUserDefaults) -> SchedulingExecutionToken + func beginExecution(for context: SchedulingStateContext, + preflightToken: SchedulingExecutionToken) -> SchedulingExecutionDecision + func isCurrent(_ token: SchedulingExecutionToken) -> Bool + func performStateAccessIfCurrent(_ token: SchedulingExecutionToken, + action: () -> Void) -> Bool + func finishExecution(_ token: SchedulingExecutionToken) } final class SchedulingExecutionGate: SchedulingExecutionGating { + // Generation checks and their state access must remain in one critical section so reset cannot + // interleave a stale write. Actions must not synchronously re-enter this non-recursive lock. private let lock = NSLock() - private var runningKeys: Set = [] + private var generations: [SUKUserDefaults.Environment: UInt64] = [:] + // The identifier lets a stale completion finish safely without removing a replacement that + // started with the same environment and storage key after reset. + private var runningExecutions: [SchedulingExecutionKey: UUID] = [:] - func beginExecution(forKey key: String) -> Bool { + func token(for userDefaults: SUKUserDefaults) -> SchedulingExecutionToken { lock.lock() defer { lock.unlock() } - return runningKeys.insert(key).inserted + return makeToken(userDefaults: userDefaults, executionKey: nil) } - func finishExecution(forKey key: String) { + func beginExecution(for context: SchedulingStateContext, + preflightToken: SchedulingExecutionToken) -> SchedulingExecutionDecision + { lock.lock() defer { lock.unlock() } - runningKeys.remove(key) + guard preflightToken.environment == context.userDefaults.env, + generations[preflightToken.environment, default: 0] == preflightToken.generation + else { return .invalidated(preflightToken) } + + let executionKey = SchedulingExecutionKey(environment: context.userDefaults.env, + storageKey: context.storageKey) + let token = SchedulingExecutionToken(identifier: UUID(), + environment: preflightToken.environment, + generation: preflightToken.generation, + executionKey: executionKey, + userDefaults: preflightToken.userDefaults) + + guard runningExecutions[executionKey] == nil else { return .inProgress } + + runningExecutions[executionKey] = token.identifier + return .started(token) + } + + func isCurrent(_ token: SchedulingExecutionToken) -> Bool { + lock.lock() + defer { lock.unlock() } + + return generations[token.environment, default: 0] == token.generation + } + + func performStateAccessIfCurrent(_ token: SchedulingExecutionToken, + action: () -> Void) -> Bool + { + lock.lock() + defer { lock.unlock() } + + guard generations[token.environment, default: 0] == token.generation else { return false } + action() + return true + } + + func finishExecution(_ token: SchedulingExecutionToken) { + guard let executionKey = token.executionKey else { return } + + lock.lock() + defer { lock.unlock() } + + guard runningExecutions[executionKey] == token.identifier else { return } + runningExecutions.removeValue(forKey: executionKey) + } + + func reset(for userDefaults: SUKUserDefaults, action: () -> Void) { + lock.lock() + defer { lock.unlock() } + + // Invalidate existing work before clearing state while the same lock excludes new work. + generations[userDefaults.env, default: 0] &+= 1 + runningExecutions = runningExecutions.filter { key, _ in + key.environment != userDefaults.env + } + action() + } + + private func makeToken(userDefaults: SUKUserDefaults, + executionKey: SchedulingExecutionKey?) -> SchedulingExecutionToken + { + SchedulingExecutionToken(identifier: UUID(), + environment: userDefaults.env, + generation: generations[userDefaults.env, default: 0], + executionKey: executionKey, + userDefaults: userDefaults) } } +private struct SchedulingExecutionKey: Hashable { + let environment: SUKUserDefaults.Environment + let storageKey: String +} + let sharedSchedulingExecutionGate = SchedulingExecutionGate() struct DailySchedule { @@ -86,25 +240,73 @@ struct DailySchedule { } func shouldRun() -> Bool { - stateStore.integer(forKey: key) < clock.currentDate() + shouldRun(in: currentContext) } func hasRecordedDate() -> Bool { - stateStore.integer(forKey: key) != 0 + stateStore.integer(for: currentContext) != 0 } func recordCurrentDate() { + if let token = SchedulingExecutionScope.currentToken { + _ = recordCurrentDate(for: token) + } else { + recordCurrentDate(in: currentContext) + } + } + + func executionToken(in userDefaults: SUKUserDefaults) -> SchedulingExecutionToken { + executionGate.token(for: userDefaults) + } + + func beginExecution(in userDefaults: SUKUserDefaults, + preflightToken: SchedulingExecutionToken) -> SchedulingExecutionDecision + { + executionGate.beginExecution(for: context(for: userDefaults), + preflightToken: preflightToken) + } + + func recordCurrentDate(for token: SchedulingExecutionToken) -> Bool { + executionGate.performStateAccessIfCurrent(token) { + recordCurrentDate(in: context(for: token.userDefaults)) + } + } + + func isCurrent(_ token: SchedulingExecutionToken) -> Bool { + executionGate.isCurrent(token) + } + + func performStateAccessIfCurrent(_ token: SchedulingExecutionToken, + action: () -> Void) -> Bool + { + executionGate.performStateAccessIfCurrent(token, action: action) + } + + func finishExecution(_ token: SchedulingExecutionToken) { + executionGate.finishExecution(token) + } + + private func shouldRun(in context: SchedulingStateContext) -> Bool { + stateStore.integer(for: context) < clock.currentDate() + } + + private func recordCurrentDate(in context: SchedulingStateContext) { let currentDate = clock.currentDate() - guard stateStore.integer(forKey: key) < currentDate else { return } + guard stateStore.integer(for: context) < currentDate else { return } - stateStore.set(currentDate, forKey: key) + stateStore.set(currentDate, for: context) } - func beginExecution() -> Bool { - executionGate.beginExecution(forKey: key) + private func context(for userDefaults: SUKUserDefaults) -> SchedulingStateContext { + SchedulingStateContext(userDefaults: userDefaults, key: key) } - func finishExecution() { - executionGate.finishExecution(forKey: key) + private var currentContext: SchedulingStateContext { + context(for: SchedulingExecutionScope.currentToken?.userDefaults + ?? SUKUserDefaults.standard) } } + +protocol DailyScheduleBacked: AnyObject { + var dailySchedule: DailySchedule { get } +} diff --git a/Framework/Sources/ReleaseNotes.swift b/Framework/Sources/ReleaseNotes.swift index d4a8e60..d3847a3 100644 --- a/Framework/Sources/ReleaseNotes.swift +++ b/Framework/Sources/ReleaseNotes.swift @@ -17,8 +17,10 @@ struct ReleaseNotes: Codable { /// A user ID. let userID: String - static func first(forUserID userID: String) -> ReleaseNotes { - let dict = dictionary() + static func first(forUserID userID: String, + userDefaults: SUKUserDefaults = SUKUserDefaults.standard) -> ReleaseNotes + { + let dict = dictionary(userDefaults: userDefaults) if let releaseNotes = dict[userID] { return releaseNotes } else { @@ -26,15 +28,18 @@ struct ReleaseNotes: Codable { } } - static func update(_ appVersion: String, forUserID userID: String) { - var dict = dictionary() + static func update(_ appVersion: String, + forUserID userID: String, + userDefaults: SUKUserDefaults = SUKUserDefaults.standard) + { + var dict = dictionary(userDefaults: userDefaults) let releaseNotes = ReleaseNotes(latest: appVersion, userID: userID) dict[userID] = releaseNotes - setDictionary(dict) + setDictionary(dict, userDefaults: userDefaults) } - private static func dictionary() -> [String: ReleaseNotes] { - if let string = SUKUserDefaults.standard.string(forKey: SwiftyUpdateKitLatestAppVersionKey), + private static func dictionary(userDefaults: SUKUserDefaults) -> [String: ReleaseNotes] { + if let string = userDefaults.string(forKey: SwiftyUpdateKitLatestAppVersionKey), let data = Data(base64Encoded: string), let dictionary = try? JSONDecoder().decode([String: ReleaseNotes].self, from: data) { @@ -44,10 +49,12 @@ struct ReleaseNotes: Codable { } } - private static func setDictionary(_ dictionary: [String: ReleaseNotes]) { + private static func setDictionary(_ dictionary: [String: ReleaseNotes], + userDefaults: SUKUserDefaults) + { if let data = try? JSONEncoder().encode(dictionary) { let string = data.base64EncodedString() - SUKUserDefaults.standard.set(string, forKey: SwiftyUpdateKitLatestAppVersionKey) + userDefaults.set(string, forKey: SwiftyUpdateKitLatestAppVersionKey) } } } diff --git a/Framework/Sources/RequestReviewCondition.swift b/Framework/Sources/RequestReviewCondition.swift index b0441a7..347f081 100644 --- a/Framework/Sources/RequestReviewCondition.swift +++ b/Framework/Sources/RequestReviewCondition.swift @@ -28,6 +28,14 @@ public protocol ReviewRequestAttemptRecording: AnyObject { func recordReviewRequestAttempt() } +protocol ReviewRequestExecutionControlling: AnyObject { + func reviewRequestPreflightToken(in userDefaults: SUKUserDefaults) -> SchedulingExecutionToken + func beginReviewRequest(in userDefaults: SUKUserDefaults, + preflightToken: SchedulingExecutionToken) -> SchedulingExecutionDecision + func isCurrentReviewRequest(_ token: SchedulingExecutionToken) -> Bool + func finishReviewRequest(_ token: SchedulingExecutionToken) +} + /// Always asks a user for a review. open class RequestReviewConditionAlways: RequestReviewCondition { public init() {} @@ -153,3 +161,53 @@ open class RequestReviewConditionLaunchingAndDailySkipFirstDay: RequestReviewCon schedule.recordCurrentDate() } } + +extension ReviewRequestExecutionControlling where Self: DailyScheduleBacked { + func reviewRequestPreflightToken(in userDefaults: SUKUserDefaults) -> SchedulingExecutionToken { + dailySchedule.executionToken(in: userDefaults) + } + + func beginReviewRequest(in userDefaults: SUKUserDefaults, + preflightToken: SchedulingExecutionToken) -> SchedulingExecutionDecision + { + dailySchedule.beginExecution(in: userDefaults, preflightToken: preflightToken) + } + + func isCurrentReviewRequest(_ token: SchedulingExecutionToken) -> Bool { + dailySchedule.isCurrent(token) + } + + func finishReviewRequest(_ token: SchedulingExecutionToken) { + dailySchedule.finishExecution(token) + } +} + +extension RequestReviewConditionDaily: DailyScheduleBacked, ReviewRequestExecutionControlling { + var dailySchedule: DailySchedule { + schedule + } +} + +extension RequestReviewConditionDailySkipFirstDay: DailyScheduleBacked, + ReviewRequestExecutionControlling +{ + var dailySchedule: DailySchedule { + schedule + } +} + +extension RequestReviewConditionLaunchingAndDaily: DailyScheduleBacked, + ReviewRequestExecutionControlling +{ + var dailySchedule: DailySchedule { + schedule + } +} + +extension RequestReviewConditionLaunchingAndDailySkipFirstDay: DailyScheduleBacked, + ReviewRequestExecutionControlling +{ + var dailySchedule: DailySchedule { + schedule + } +} diff --git a/Framework/Sources/SUK.swift b/Framework/Sources/SUK.swift index e7a18a6..eb0e01c 100644 --- a/Framework/Sources/SUK.swift +++ b/Framework/Sources/SUK.swift @@ -24,15 +24,83 @@ public typealias UpdateHandler = (_ newVersion: String?, _ releaseNotes: String? public typealias NewReleaseHandler = (_ newVersion: String?, _ releaseNotes: String?, _ firstUpdated: Bool) -> Void +typealias SUKUpdateAlertPresenter = (_ config: SwiftyUpdateKitConfig, + _ updateAction: @escaping () -> Void) -> Void +typealias SUKAppStoreURLOpener = (_ url: URL) -> Void + +private struct VersionCheckOperationContext { + let config: SwiftyUpdateKitConfig + let logger: Log? + let userDefaults: SUKUserDefaults + let token: SchedulingExecutionToken + let executionController: VersionCheckExecutionControlling? + + func recordSuccessfulVersionCheck(_ condition: VersionCheckCondition) -> Bool { + guard isCurrent() else { return false } + + if let recordingCondition = condition as? VersionCheckSuccessRecording { + SchedulingExecutionScope.withToken(token) { + recordingCondition.recordSuccessfulVersionCheck() + } + } + + return isCurrent() + } + + func isCurrent() -> Bool { + if let executionController { + return executionController.isCurrentVersionCheck(token) + } + + return sharedSchedulingExecutionGate.isCurrent(token) + } + + func performStateAccessIfCurrent(_ action: () -> Void) -> Bool { + if let executionController { + return executionController.performVersionCheckStateAccessIfCurrent(token, + action: action) + } + + return sharedSchedulingExecutionGate.performStateAccessIfCurrent(token, action: action) + } + + func finish() { + executionController?.finishVersionCheck(token) + } + + func writeLog(_ message: String) { + logf(message, logger) + } +} + +private struct ReviewRequestOperationContext { + let token: SchedulingExecutionToken + let executionController: ReviewRequestExecutionControlling? + + func isCurrent() -> Bool { + if let executionController { + return executionController.isCurrentReviewRequest(token) + } + + return sharedSchedulingExecutionGate.isCurrent(token) + } + + func finish() { + executionController?.finishReviewRequest(token) + } +} + /// SwiftyUpdateKit. public class SUK { /// SwiftyUpdateKit version. public static let version = "1.5.0" - private static var config: SwiftyUpdateKitConfig? - private static var log: Log? + private static let versionCheckInvalidatedLog = + "Cancels the version check because its scheduling context was invalidated." /// Initializes SwiftyUpdateKit. + /// Operations that are already queued or in progress keep the configuration and environment + /// captured when they started. Call `reset()` before reinitializing to invalidate them. /// /// - Parameters: /// - config: A configuration. @@ -40,10 +108,7 @@ public class SUK { public static func initialize(withConfig config: SwiftyUpdateKitConfig, log: Log? = nil) { - self.config = config - self.log = log - - SUKUserDefaults.setEnvironment(config.isDevelopment ? .development : .production) + sharedSUKRuntimeState.initialize(config: config, log: log) } /// Initializes SwiftyUpdateKit. @@ -89,41 +154,78 @@ public class SUK { /// Opens the App Store. public static func openAppStore() { DispatchQueue.main.async { - guard let config else { - logf("`applicationDidFinishLaunching(withConfig:)` method is not called yet.", log) + let runtimeContext = sharedSUKRuntimeState.snapshot() + guard let config = runtimeContext.config else { + logf("`applicationDidFinishLaunching(withConfig:)` method is not called yet.", + runtimeContext.log) return } let url = URL(string: config.storeURL)! - logf(url.absoluteString, log) - #if os(OSX) - NSWorkspace.shared.open(url) - #elseif os(iOS) - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - #endif + logf(url.absoluteString, runtimeContext.log) + openAppStoreURL(url) } } /// Shows the update alert for a user to install new app version. + /// The alert and its update action use the configuration captured when this method is called. public static func showUpdateAlert() { + let runtimeContext = sharedSUKRuntimeState.snapshot() + DispatchQueue.main.async { - guard let config else { - logf("`applicationDidFinishLaunching(withConfig:)` method is not called yet.", log) + guard let config = runtimeContext.config else { + logf("`applicationDidFinishLaunching(withConfig:)` method is not called yet.", + runtimeContext.log) return } - let alert = Alert(title: config.updateAlertTitle, - message: config.updateAlertMessage) - .addAction(config.updateButtonTitle) { Self.openAppStore() } + presentUpdateAlert(config) { + let url = URL(string: config.storeURL)! + logf(url.absoluteString, runtimeContext.log) + openAppStoreURL(url) + } + } + } - if let title = config.remindMeLaterButtonTitle, !title.isEmpty { - alert.addAction(title) + static func enqueueUpdateAlert(config: SwiftyUpdateKitConfig, + log: Log?, + isCurrent: @escaping () -> Bool, + presenter: @escaping SUKUpdateAlertPresenter, + openURL: @escaping SUKAppStoreURLOpener) + { + DispatchQueue.main.async { + guard isCurrent() else { return } + + presenter(config) { + let url = URL(string: config.storeURL)! + logf(url.absoluteString, log) + openURL(url) } + } + } + + private static func presentUpdateAlert(_ config: SwiftyUpdateKitConfig, + updateAction: @escaping () -> Void) + { + let alert = Alert(title: config.updateAlertTitle, + message: config.updateAlertMessage) + .addAction(config.updateButtonTitle, handler: updateAction) + + if let title = config.remindMeLaterButtonTitle, !title.isEmpty { + alert.addAction(title) + } - alert.showAsModal() + alert.showAsModal() + } + + private static func openAppStoreURL(_ url: URL) { + #if os(OSX) + NSWorkspace.shared.open(url) + #elseif os(iOS) + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) } + #endif } /// Shows the release notes to a user when new app version is installed. @@ -167,10 +269,8 @@ public class SUK { @available(iOS, deprecated: 16.0, message: "Use `requestReview(_:, in:)` instead.") @available(macOS, deprecated: 13.0, message: "Use `requestReview(_:, in:)` instead.") public static func requestReview(_ condition: RequestReviewCondition) { - DispatchQueue.main.async { - requestReviewIfNeeded(condition) { - SKStoreReviewController.requestReview() - } + enqueueReviewRequest(condition) { + SKStoreReviewController.requestReview() } } @@ -187,10 +287,8 @@ public class SUK { public static func requestReview(_ condition: RequestReviewCondition, in controller: NSViewController) { - DispatchQueue.main.async { - requestReviewIfNeeded(condition) { - AppStore.requestReview(in: controller) - } + enqueueReviewRequest(condition) { + AppStore.requestReview(in: controller) } } #endif @@ -206,10 +304,8 @@ public class SUK { /// interface. @available(iOS 16.0, *) public static func requestReview(_ condition: RequestReviewCondition, in scene: UIWindowScene) { - DispatchQueue.main.async { - requestReviewIfNeeded(condition) { - AppStore.requestReview(in: scene) - } + enqueueReviewRequest(condition) { + AppStore.requestReview(in: scene) } } @@ -222,25 +318,51 @@ public class SUK { /// - view: The view that StoreKit uses to present the rating and review request interface. @available(iOS 16.0, *) public static func requestReview(_ condition: RequestReviewCondition, in view: UIView) { + let runtimeContext = sharedSUKRuntimeState.snapshot() + let preflightToken = reviewRequestPreflightToken(condition, + userDefaults: runtimeContext.userDefaults) + DispatchQueue.main.async { if let scene = view.window?.windowScene { - requestReviewIfNeeded(condition) { - AppStore.requestReview(in: scene) - } + guard let context = prepareReviewRequest(condition, + preflightToken: preflightToken) + else { return } + + defer { context.finish() } + guard context.isCurrent() else { return } + AppStore.requestReview(in: scene) } } } #endif - /// Resets the status: stored date of version check condition, stored date of request review - /// condition, - /// and stored app version for the release notes. + /// Resets the status for the current environment: stored dates of version check and request + /// review conditions in persistent and in-memory storage, and the stored app version for the + /// release notes. + /// The reset completes synchronously and invalidates in-flight and queued scheduling work, + /// including update alerts that have not been presented yet. An alert already on screen keeps + /// its update action. /// For example, you may use this method during testing and development. public static func reset() { - let ud = SUKUserDefaults.standard - ud.removeObject(forKey: SwiftyUpdateKitLastVersionCheckDateKey) - ud.removeObject(forKey: SwiftyUpdateKitLastRequireReviewDateKey) - ud.removeObject(forKey: SwiftyUpdateKitLatestAppVersionKey) + let userDefaults = SUKUserDefaults.standard + let schedulingKeys = [SwiftyUpdateKitLastVersionCheckDateKey, + SwiftyUpdateKitLastRequireReviewDateKey] + let persistentStore = UserDefaultsSchedulingStateStore() + let inMemoryStore = InMemorySchedulingStateStore() + let contexts = schedulingKeys.map { + SchedulingStateContext(userDefaults: userDefaults, key: $0) + } + + sharedSchedulingExecutionGate.reset(for: userDefaults) { + for context in contexts { + persistentStore.removeValue(for: context) + inMemoryStore.removeValue(for: context) + } + + // Release-note updates use the same gate, so clearing this value inside the critical + // section prevents stale operations from restoring it after reset. + userDefaults.removeObject(forKey: SwiftyUpdateKitLatestAppVersionKey) + } } } @@ -252,7 +374,9 @@ extension SUK { noop: (() -> Void)?, lookup: AppStoreLookup) { - checkVersion(condition, update: update, lookup: lookup) { lookUpResult in + checkVersion(condition, update: update, lookup: lookup) { lookUpResult, context in + guard context.isCurrent() else { return } + guard let newRelease else { // Not need to show the new release. noop?() @@ -261,29 +385,35 @@ extension SUK { if let result = lookUpResult { // Use fetched lookUpResult. - checkNewRelease(result, newRelease: newRelease, forUserID: userID, noop: noop) + checkNewRelease(result, + context: context, + newRelease: newRelease, + forUserID: userID, + noop: noop) } else { - guard let config else { return } - - lookup.lookUp(with: config) { result in + lookup.lookUp(with: context.config) { result in switch result { case let .failure(error): // Ignore an error. - logf(error.localizedDescription, log) + context.writeLog(error.localizedDescription) DispatchQueue.main.async { + guard context.isCurrent() else { return } noop?() } case let .success(lookUpResults): guard let lookUpResult = lookUpResults.first else { // Ignore an error. - logf("lookUpResult does not exist in the response data.", log) + context + .writeLog("lookUpResult does not exist in the response data.") DispatchQueue.main.async { + guard context.isCurrent() else { return } noop?() } return } checkNewRelease(lookUpResult, + context: context, newRelease: newRelease, forUserID: userID, noop: noop) @@ -296,137 +426,316 @@ extension SUK { private static func checkVersion(_ condition: VersionCheckCondition, update: UpdateHandler?, lookup: AppStoreLookup, - next: @escaping (LookUpResult?) -> Void) + next: @escaping (LookUpResult?, VersionCheckOperationContext) + -> Void) { + let runtimeContext = sharedSUKRuntimeState.snapshot() + let executionController = condition as? VersionCheckExecutionControlling + let preflightToken = executionController? + .versionCheckPreflightToken(in: runtimeContext.userDefaults) + ?? sharedSchedulingExecutionGate.token(for: runtimeContext.userDefaults) + DispatchQueue.main.async { - guard let config else { - logf("`applicationDidFinishLaunching(withConfig:)` method is not called yet.", log) + let isPreflightCurrent = executionController? + .isCurrentVersionCheck(preflightToken) + ?? sharedSchedulingExecutionGate.isCurrent(preflightToken) + guard isPreflightCurrent else { return } + + guard let config = runtimeContext.config else { + logf("`applicationDidFinishLaunching(withConfig:)` method is not called yet.", + runtimeContext.log) return } - let executionController = condition as? VersionCheckExecutionControlling - if let executionController { - switch executionController.beginVersionCheck() { - case .started: - break - case .inProgress: - logf("Skips the version check because a lookup is already in progress.", - log) - return - case .notEligible: - logf("Skips the version check.", log) - DispatchQueue.main.async { - next(nil) - } - return + let userDefaults = runtimeContext.userDefaults + let preflightContext = VersionCheckOperationContext(config: config, + logger: runtimeContext.log, + userDefaults: userDefaults, + token: preflightToken, + executionController: + executionController) + let isEligible = SchedulingExecutionScope.withToken(preflightToken) { + condition.shouldCheckVersion() + } + + guard preflightContext.isCurrent() else { + preflightContext.writeLog(versionCheckInvalidatedLog) + return + } + + guard isEligible else { + preflightContext.writeLog("Skips the version check because its condition declined.") + DispatchQueue.main.async { + guard preflightContext.isCurrent() else { return } + next(nil, preflightContext) } + return + } + + let decision: SchedulingExecutionDecision + + if let executionController { + decision = executionController.beginVersionCheck(in: userDefaults, + preflightToken: preflightToken) } else { - guard condition.shouldCheckVersion() else { - logf("Skips the version check.", log) - DispatchQueue.main.async { - next(nil) - } - return - } + decision = .started(preflightToken) } - lookup.lookUp(with: config) { result in - switch result { - case let .failure(error): + switch decision { + case let .started(token): + let context = VersionCheckOperationContext(config: config, + logger: runtimeContext.log, + userDefaults: userDefaults, + token: token, + executionController: + executionController) + + performVersionLookup(condition, + update: update, + lookup: lookup, + context: context, + next: next) + case .inProgress: + logf("Skips the version check because a lookup is already in progress.", + runtimeContext.log) + case let .invalidated(token): + let context = VersionCheckOperationContext(config: config, + logger: runtimeContext.log, + userDefaults: userDefaults, + token: token, + executionController: + executionController) + context.finish() + context.writeLog(versionCheckInvalidatedLog) + } + } + } + + private static func performVersionLookup(_ condition: VersionCheckCondition, + update: UpdateHandler?, + lookup: AppStoreLookup, + context: VersionCheckOperationContext, + next: @escaping (LookUpResult?, + VersionCheckOperationContext) -> Void) + { + lookup.lookUp(with: context.config) { result in + switch result { + case let .failure(error): + // Ignore an error. + context.writeLog(error.localizedDescription) + context.finish() + case let .success(lookUpResults): + context.writeLog(lookUpResults.description) + guard let lookUpResult = lookUpResults.first, + let storeVersion = lookUpResult.version + else { // Ignore an error. - logf(error.localizedDescription, log) - executionController?.finishVersionCheck() - case let .success(lookUpResults): - logf(lookUpResults.description, log) - guard let lookUpResult = lookUpResults.first, - let storeVersion = lookUpResult.version - else { - // Ignore an error. - logf("version does not exist in the response data.", log) - executionController?.finishVersionCheck() - return - } + context.writeLog("version does not exist in the response data.") + context.finish() + return + } - (condition as? VersionCheckSuccessRecording)? - .recordSuccessfulVersionCheck() - executionController?.finishVersionCheck() + let isStillCurrent = context.recordSuccessfulVersionCheck(condition) + context.finish() + guard isStillCurrent else { return } - let isOld = config.versionCompare.compare(storeVersion, - with: config.version) + let isOld = context.config.versionCompare.compare(storeVersion, + with: context.config.version) + guard context.isCurrent() else { return } - if isOld { - logf("This app version is old.", log) - if update == nil { - // Use default update alert. - Self.showUpdateAlert() - } else { - DispatchQueue.main.async { - update?(lookUpResult.version, lookUpResult.releaseNotes) - } - } - } else { - // Latest - logf("This app version is already latest.", log) + if isOld { + context.writeLog("This app version is old.") + + if let update { DispatchQueue.main.async { - next(lookUpResult) + guard context.isCurrent() else { return } + update(lookUpResult.version, lookUpResult.releaseNotes) } + } else { + enqueueUpdateAlert(config: context.config, + log: context.logger, + isCurrent: { context.isCurrent() }, + presenter: { config, updateAction in + presentUpdateAlert(config, + updateAction: updateAction) + }, + openURL: { url in + openAppStoreURL(url) + }) } - } + } else { + // Latest + context.writeLog("This app version is already latest.") + DispatchQueue.main.async { + guard context.isCurrent() else { return } + next(lookUpResult, context) + } + } } } } - static func requestReviewIfNeeded(_ condition: RequestReviewCondition, - request: () -> Void) + /// Synchronously exercises review scheduling without invoking StoreKit from unit tests. + static func requestReviewIfNeededForTesting(_ condition: RequestReviewCondition, + request: () -> Void) { - guard condition.shouldRequestReview() else { return } - - (condition as? ReviewRequestAttemptRecording)?.recordReviewRequestAttempt() + let runtimeContext = sharedSUKRuntimeState.snapshot() + let preflightToken = reviewRequestPreflightToken(condition, + userDefaults: runtimeContext.userDefaults) + guard let context = prepareReviewRequest(condition, preflightToken: preflightToken) + else { return } + + defer { context.finish() } + guard context.isCurrent() else { return } request() } + static func enqueueReviewRequest(_ condition: RequestReviewCondition, + request: @escaping @MainActor () -> Void) + { + let runtimeContext = sharedSUKRuntimeState.snapshot() + let preflightToken = reviewRequestPreflightToken(condition, + userDefaults: runtimeContext.userDefaults) + + DispatchQueue.main.async { + guard let context = prepareReviewRequest(condition, + preflightToken: preflightToken) + else { return } + + defer { context.finish() } + guard context.isCurrent() else { return } + request() + } + } + + private static func prepareReviewRequest(_ condition: RequestReviewCondition, + preflightToken: SchedulingExecutionToken) + -> ReviewRequestOperationContext? + { + let userDefaults = preflightToken.userDefaults + let executionController = condition as? ReviewRequestExecutionControlling + let isPreflightCurrent = executionController? + .isCurrentReviewRequest(preflightToken) + ?? sharedSchedulingExecutionGate.isCurrent(preflightToken) + guard isPreflightCurrent else { return nil } + + let isEligible = SchedulingExecutionScope.withToken(preflightToken) { + condition.shouldRequestReview() + } + let isStillCurrent = executionController? + .isCurrentReviewRequest(preflightToken) + ?? sharedSchedulingExecutionGate.isCurrent(preflightToken) + guard isEligible, isStillCurrent else { return nil } + + let decision: SchedulingExecutionDecision + + if let executionController { + decision = executionController.beginReviewRequest(in: userDefaults, + preflightToken: preflightToken) + } else { + decision = .started(preflightToken) + } + + guard case let .started(token) = decision else { return nil } + + let context = ReviewRequestOperationContext(token: token, + executionController: executionController) + guard context.isCurrent() else { + context.finish() + return nil + } + + if let recordingCondition = condition as? ReviewRequestAttemptRecording { + SchedulingExecutionScope.withToken(token) { + recordingCondition.recordReviewRequestAttempt() + } + } + + guard context.isCurrent() else { + context.finish() + return nil + } + + return context + } + + private static func reviewRequestPreflightToken(_ condition: RequestReviewCondition, + userDefaults: SUKUserDefaults) + -> SchedulingExecutionToken + { + if let executionController = condition as? ReviewRequestExecutionControlling { + return executionController.reviewRequestPreflightToken(in: userDefaults) + } + + return sharedSchedulingExecutionGate.token(for: userDefaults) + } + private static func checkNewRelease(_ lookUpResult: LookUpResult, + context: VersionCheckOperationContext, newRelease: @escaping NewReleaseHandler, forUserID userID: String, noop: (() -> Void)?) { - guard let config else { return } + guard context.isCurrent() else { return } guard let storeVersion = lookUpResult.version else { - logf("version does not exist in the response data.", log) + context.writeLog("version does not exist in the response data.") return } - guard storeVersion == config.version else { - logf("Current app version is not equal to the version released on the App Store.", log) + guard storeVersion == context.config.version else { + let message = + "Current app version is not equal to the version released on the App Store." + context.writeLog(message) DispatchQueue.main.async { + guard context.isCurrent() else { return } noop?() } return } - guard let savedVersion = ReleaseNotes.first(forUserID: userID).latest else { + var savedVersion: String? + guard context.performStateAccessIfCurrent({ + savedVersion = ReleaseNotes.first(forUserID: userID, + userDefaults: context.userDefaults).latest + }) else { return } + + guard let savedVersion else { // First updated. - logf("A user has installed the app firstly.", log) - ReleaseNotes.update(storeVersion, forUserID: userID) + context.writeLog("A user has installed the app firstly.") + guard context.performStateAccessIfCurrent({ + ReleaseNotes.update(storeVersion, + forUserID: userID, + userDefaults: context.userDefaults) + }) else { return } DispatchQueue.main.async { + guard context.isCurrent() else { return } newRelease(storeVersion, lookUpResult.releaseNotes, true) } return } - guard config.versionCompare.compare(storeVersion, with: savedVersion) else { - logf("Saved app version is already latest.", log) + let isNewRelease = context.config.versionCompare.compare(storeVersion, with: savedVersion) + guard context.isCurrent() else { return } + + guard isNewRelease else { + context.writeLog("Saved app version is already latest.") DispatchQueue.main.async { + guard context.isCurrent() else { return } noop?() } return } - ReleaseNotes.update(storeVersion, forUserID: userID) + guard context.performStateAccessIfCurrent({ + ReleaseNotes.update(storeVersion, + forUserID: userID, + userDefaults: context.userDefaults) + }) else { return } DispatchQueue.main.async { + guard context.isCurrent() else { return } newRelease(storeVersion, lookUpResult.releaseNotes, false) } } diff --git a/Framework/Sources/SUKUserDefaults.swift b/Framework/Sources/SUKUserDefaults.swift index 7a3c7e9..038d076 100644 --- a/Framework/Sources/SUKUserDefaults.swift +++ b/Framework/Sources/SUKUserDefaults.swift @@ -9,7 +9,7 @@ import Foundation class SUKUserDefaults { - enum Environment { + enum Environment: Hashable { case production case development case test @@ -17,47 +17,42 @@ class SUKUserDefaults { let env: Environment - private init(env: Environment) { + fileprivate init(env: Environment) { self.env = env } - /// Shared instance. Default is for production. - private static var instance = SUKUserDefaults(env: .production) - /// Returns shared instance. static var standard: SUKUserDefaults { - instance + sharedSUKRuntimeState.snapshot().userDefaults } /// Recreate SUKUserDefaults instance for specified environment. static func setEnvironment(_ env: Environment) { - instance = SUKUserDefaults(env: env) + sharedSUKRuntimeState.setEnvironment(env) } func set(_ value: Int, forKey key: String) { - UserDefaults.standard.set(value, forKey: forEnv(key)) + UserDefaults.standard.set(value, forKey: storageKey(forKey: key)) } func set(_ value: String, forKey key: String) { - UserDefaults.standard.set(value, forKey: forEnv(key)) + UserDefaults.standard.set(value, forKey: storageKey(forKey: key)) } func integer(forKey key: String) -> Int { - UserDefaults.standard.integer(forKey: forEnv(key)) + UserDefaults.standard.integer(forKey: storageKey(forKey: key)) } func string(forKey key: String) -> String? { - UserDefaults.standard.string(forKey: forEnv(key)) + UserDefaults.standard.string(forKey: storageKey(forKey: key)) } func removeObject(forKey key: String) { - UserDefaults.standard.removeObject(forKey: forEnv(key)) + UserDefaults.standard.removeObject(forKey: storageKey(forKey: key)) } -} -private extension SUKUserDefaults { - /// Returns a key for current environment. - func forEnv(_ key: String) -> String { + /// Returns a storage key for the current environment. + func storageKey(forKey key: String) -> String { switch env { case .production: return key @@ -68,3 +63,44 @@ private extension SUKUserDefaults { } } } + +struct SUKRuntimeContext { + let config: SwiftyUpdateKitConfig? + let log: Log? + let userDefaults: SUKUserDefaults +} + +final class SUKRuntimeState { + private let lock = NSLock() + private var context = SUKRuntimeContext(config: nil, + log: nil, + userDefaults: SUKUserDefaults(env: .production)) + + func initialize(config: SwiftyUpdateKitConfig, log: Log?) { + lock.lock() + defer { lock.unlock() } + + context = SUKRuntimeContext(config: config, + log: log, + userDefaults: SUKUserDefaults(env: config + .isDevelopment ? .development : .production)) + } + + func setEnvironment(_ environment: SUKUserDefaults.Environment) { + lock.lock() + defer { lock.unlock() } + + context = SUKRuntimeContext(config: context.config, + log: context.log, + userDefaults: SUKUserDefaults(env: environment)) + } + + func snapshot() -> SUKRuntimeContext { + lock.lock() + defer { lock.unlock() } + + return context + } +} + +let sharedSUKRuntimeState = SUKRuntimeState() diff --git a/Framework/Sources/VersionCheckCondition.swift b/Framework/Sources/VersionCheckCondition.swift index fdb503a..dcaf64b 100644 --- a/Framework/Sources/VersionCheckCondition.swift +++ b/Framework/Sources/VersionCheckCondition.swift @@ -25,15 +25,14 @@ public protocol VersionCheckSuccessRecording: AnyObject { func recordSuccessfulVersionCheck() } -enum VersionCheckExecutionDecision { - case started - case inProgress - case notEligible -} - protocol VersionCheckExecutionControlling: AnyObject { - func beginVersionCheck() -> VersionCheckExecutionDecision - func finishVersionCheck() + func versionCheckPreflightToken(in userDefaults: SUKUserDefaults) -> SchedulingExecutionToken + func beginVersionCheck(in userDefaults: SUKUserDefaults, + preflightToken: SchedulingExecutionToken) -> SchedulingExecutionDecision + func isCurrentVersionCheck(_ token: SchedulingExecutionToken) -> Bool + func performVersionCheckStateAccessIfCurrent(_ token: SchedulingExecutionToken, + action: () -> Void) -> Bool + func finishVersionCheck(_ token: SchedulingExecutionToken) } /// Always checks the app version. @@ -110,28 +109,42 @@ open class VersionCheckConditionLaunchingAndDaily: VersionCheckCondition, } } -extension VersionCheckConditionDaily: VersionCheckExecutionControlling { - func beginVersionCheck() -> VersionCheckExecutionDecision { - guard shouldCheckVersion() else { return .notEligible } - guard schedule.beginExecution() else { return .inProgress } +extension VersionCheckExecutionControlling where Self: DailyScheduleBacked { + func versionCheckPreflightToken(in userDefaults: SUKUserDefaults) -> SchedulingExecutionToken { + dailySchedule.executionToken(in: userDefaults) + } - return .started + func beginVersionCheck(in userDefaults: SUKUserDefaults, + preflightToken: SchedulingExecutionToken) -> SchedulingExecutionDecision + { + dailySchedule.beginExecution(in: userDefaults, preflightToken: preflightToken) } - func finishVersionCheck() { - schedule.finishExecution() + func isCurrentVersionCheck(_ token: SchedulingExecutionToken) -> Bool { + dailySchedule.isCurrent(token) + } + + func performVersionCheckStateAccessIfCurrent(_ token: SchedulingExecutionToken, + action: () -> Void) -> Bool + { + dailySchedule.performStateAccessIfCurrent(token, action: action) } -} -extension VersionCheckConditionLaunchingAndDaily: VersionCheckExecutionControlling { - func beginVersionCheck() -> VersionCheckExecutionDecision { - guard shouldCheckVersion() else { return .notEligible } - guard schedule.beginExecution() else { return .inProgress } + func finishVersionCheck(_ token: SchedulingExecutionToken) { + dailySchedule.finishExecution(token) + } +} - return .started +extension VersionCheckConditionDaily: DailyScheduleBacked, VersionCheckExecutionControlling { + var dailySchedule: DailySchedule { + schedule } +} - func finishVersionCheck() { - schedule.finishExecution() +extension VersionCheckConditionLaunchingAndDaily: DailyScheduleBacked, + VersionCheckExecutionControlling +{ + var dailySchedule: DailySchedule { + schedule } } diff --git a/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift b/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift index 9363a48..39d9546 100644 --- a/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift +++ b/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift @@ -59,11 +59,678 @@ class SwiftyUpdateKitTests: XCTestCase { } } +final class ResetTests: XCTestCase { + override func setUpWithError() throws { + SUKUserDefaults.setEnvironment(.test) + SUK.reset() + } + + override func tearDownWithError() throws { + for environment in [SUKUserDefaults.Environment.production, .development, .test] { + SUKUserDefaults.setEnvironment(environment) + SUK.reset() + } + } + + func testResetRestoresAllVersionCheckConditions() { + let always = VersionCheckConditionAlways() + let disable = VersionCheckConditionDisable() + let daily = VersionCheckConditionDaily() + let launchingAndDaily = VersionCheckConditionLaunchingAndDaily() + + XCTAssertTrue(always.shouldCheckVersion()) + XCTAssertFalse(disable.shouldCheckVersion()) + XCTAssertTrue(daily.shouldCheckVersion()) + XCTAssertTrue(launchingAndDaily.shouldCheckVersion()) + + daily.recordSuccessfulVersionCheck() + launchingAndDaily.recordSuccessfulVersionCheck() + + XCTAssertFalse(daily.shouldCheckVersion()) + XCTAssertFalse(launchingAndDaily.shouldCheckVersion()) + + SUK.reset() + + XCTAssertTrue(always.shouldCheckVersion()) + XCTAssertFalse(disable.shouldCheckVersion()) + XCTAssertTrue(daily.shouldCheckVersion()) + XCTAssertTrue(launchingAndDaily.shouldCheckVersion()) + } + + func testResetPreservesStatelessReviewRequestConditions() { + let always = RequestReviewConditionAlways() + let disable = RequestReviewConditionDisable() + + XCTAssertTrue(always.shouldRequestReview()) + XCTAssertFalse(disable.shouldRequestReview()) + + SUK.reset() + + XCTAssertTrue(always.shouldRequestReview()) + XCTAssertFalse(disable.shouldRequestReview()) + } + + func testResetRestoresDailyReviewRequestCondition() { + let daily = RequestReviewConditionDaily() + + XCTAssertTrue(daily.shouldRequestReview()) + daily.recordReviewRequestAttempt() + XCTAssertFalse(daily.shouldRequestReview()) + + SUK.reset() + + XCTAssertTrue(daily.shouldRequestReview()) + } + + func testResetRestartsDailySkipFirstDayReviewRequestCondition() { + let userDefaults = SUKUserDefaults.standard + let stateContext = SchedulingStateContext(userDefaults: userDefaults, + key: SwiftyUpdateKitLastRequireReviewDateKey) + let persistentStore = UserDefaultsSchedulingStateStore() + let condition = RequestReviewConditionDailySkipFirstDay() + + XCTAssertFalse(condition.shouldRequestReview()) + XCTAssertNotEqual(persistentStore.integer(for: stateContext), 0) + + SUK.reset() + + XCTAssertEqual(persistentStore.integer(for: stateContext), 0) + XCTAssertFalse(condition.shouldRequestReview()) + } + + func testResetRestoresLaunchingAndDailyReviewRequestCondition() { + let condition = RequestReviewConditionLaunchingAndDaily() + + XCTAssertTrue(condition.shouldRequestReview()) + condition.recordReviewRequestAttempt() + XCTAssertFalse(condition.shouldRequestReview()) + + SUK.reset() + + XCTAssertTrue(condition.shouldRequestReview()) + } + + func testResetRestartsLaunchingAndDailySkipFirstDayReviewRequestCondition() { + let userDefaults = SUKUserDefaults.standard + let stateContext = SchedulingStateContext(userDefaults: userDefaults, + key: SwiftyUpdateKitLastRequireReviewDateKey) + let inMemoryStore = InMemorySchedulingStateStore() + let condition = RequestReviewConditionLaunchingAndDailySkipFirstDay() + + XCTAssertFalse(condition.shouldRequestReview()) + XCTAssertNotEqual(inMemoryStore.integer(for: stateContext), 0) + + SUK.reset() + + XCTAssertEqual(inMemoryStore.integer(for: stateContext), 0) + XCTAssertFalse(condition.shouldRequestReview()) + } + + func testResetRestoresReleaseNotesState() { + ReleaseNotes.update("1.2.3", forUserID: "Test") + XCTAssertEqual(ReleaseNotes.first(forUserID: "Test").latest, "1.2.3") + + SUK.reset() + + XCTAssertNil(ReleaseNotes.first(forUserID: "Test").latest) + } + + func testResetDoesNotRemoveStateFromAnotherEnvironment() { + let persistentStore = UserDefaultsSchedulingStateStore() + let inMemoryStore = InMemorySchedulingStateStore() + + SUKUserDefaults.setEnvironment(.production) + let productionUserDefaults = SUKUserDefaults.standard + let versionKey = SwiftyUpdateKitLastVersionCheckDateKey + let reviewKey = SwiftyUpdateKitLastRequireReviewDateKey + let productionVersionContext = SchedulingStateContext(userDefaults: productionUserDefaults, + key: versionKey) + let productionReviewContext = SchedulingStateContext(userDefaults: productionUserDefaults, + key: reviewKey) + persistentStore.set(20_260_819, for: productionVersionContext) + persistentStore.set(20_260_820, for: productionReviewContext) + inMemoryStore.set(20_260_821, for: productionVersionContext) + inMemoryStore.set(20_260_822, for: productionReviewContext) + ReleaseNotes.update("1.2.3", + forUserID: "Production", + userDefaults: productionUserDefaults) + + SUKUserDefaults.setEnvironment(.development) + let developmentUserDefaults = SUKUserDefaults.standard + let developmentVersionContext = + SchedulingStateContext(userDefaults: developmentUserDefaults, + key: versionKey) + let developmentReviewContext = SchedulingStateContext(userDefaults: developmentUserDefaults, + key: reviewKey) + persistentStore.set(20_260_823, for: developmentVersionContext) + persistentStore.set(20_260_824, for: developmentReviewContext) + inMemoryStore.set(20_260_825, for: developmentVersionContext) + inMemoryStore.set(20_260_826, for: developmentReviewContext) + ReleaseNotes.update("2.0.0", + forUserID: "Development", + userDefaults: developmentUserDefaults) + + SUK.reset() + + XCTAssertEqual(persistentStore.integer(for: developmentVersionContext), 0) + XCTAssertEqual(persistentStore.integer(for: developmentReviewContext), 0) + XCTAssertEqual(inMemoryStore.integer(for: developmentVersionContext), 0) + XCTAssertEqual(inMemoryStore.integer(for: developmentReviewContext), 0) + XCTAssertNil(ReleaseNotes.first(forUserID: "Development", + userDefaults: developmentUserDefaults).latest) + + SUKUserDefaults.setEnvironment(.production) + + XCTAssertEqual(persistentStore.integer(for: productionVersionContext), 20_260_819) + XCTAssertEqual(persistentStore.integer(for: productionReviewContext), 20_260_820) + XCTAssertEqual(inMemoryStore.integer(for: productionVersionContext), 20_260_821) + XCTAssertEqual(inMemoryStore.integer(for: productionReviewContext), 20_260_822) + XCTAssertEqual(ReleaseNotes.first(forUserID: "Production", + userDefaults: productionUserDefaults).latest, + "1.2.3") + } + + func testResetInvalidatesInFlightVersionCheckWithoutUnlockingReplacement() { + initializeSUKForSchedulingTests() + SUK.reset() + + let userDefaults = SUKUserDefaults.standard + let stateStore = InMemorySchedulingStateStore() + let stateContext = SchedulingStateContext(userDefaults: userDefaults, + key: SwiftyUpdateKitLastVersionCheckDateKey) + let lookup = ControlledAppStoreLookup() + + checkVersion(VersionCheckConditionLaunchingAndDaily(), lookup: lookup) + waitForMainQueue() + XCTAssertEqual(lookup.requestCount, 1) + + SUK.reset() + + checkVersion(VersionCheckConditionLaunchingAndDaily(), lookup: lookup) + waitForMainQueue() + XCTAssertEqual(lookup.requestCount, 2) + + lookup.completeNext(with: .success([.stub(version: "1.0.0")])) + waitForMainQueue() + + XCTAssertEqual(stateStore.integer(for: stateContext), 0) + XCTAssertNil(ReleaseNotes.first(forUserID: "Test", userDefaults: userDefaults).latest) + + checkVersion(VersionCheckConditionLaunchingAndDaily(), lookup: lookup) + waitForMainQueue() + XCTAssertEqual(lookup.requestCount, 2) + + lookup.completeNext(with: .success([.stub(version: "1.0.0")])) + waitForMainQueue() + + XCTAssertNotEqual(stateStore.integer(for: stateContext), 0) + XCTAssertEqual(ReleaseNotes.first(forUserID: "Test", userDefaults: userDefaults).latest, + "1.0.0") + } + + func testProductionAndDevelopmentInFlightChecksRemainIndependent() { + initializeSUKForSchedulingTests(development: false) + SUK.reset() + + let productionUserDefaults = SUKUserDefaults.standard + let lookup = ControlledAppStoreLookup() + + checkVersion(VersionCheckConditionDaily(), lookup: lookup) + waitForMainQueue() + XCTAssertEqual(lookup.requestCount, 1) + + initializeSUKForSchedulingTests(development: true) + SUK.reset() + + let developmentUserDefaults = SUKUserDefaults.standard + + checkVersion(VersionCheckConditionDaily(), lookup: lookup) + waitForMainQueue() + XCTAssertEqual(lookup.requestCount, 2) + + lookup.completeNext(with: .success([.stub(version: "1.0.0")])) + waitForMainQueue() + + XCTAssertNotEqual(productionUserDefaults + .integer(forKey: SwiftyUpdateKitLastVersionCheckDateKey), + 0) + XCTAssertEqual(ReleaseNotes.first(forUserID: "Test", + userDefaults: productionUserDefaults).latest, + "1.0.0") + XCTAssertEqual(developmentUserDefaults + .integer(forKey: SwiftyUpdateKitLastVersionCheckDateKey), + 0) + XCTAssertNil(ReleaseNotes.first(forUserID: "Test", + userDefaults: developmentUserDefaults).latest) + + lookup.completeNext(with: .success([.stub(version: "1.0.0")])) + waitForMainQueue() + + XCTAssertNotEqual(developmentUserDefaults + .integer(forKey: SwiftyUpdateKitLastVersionCheckDateKey), + 0) + XCTAssertEqual(ReleaseNotes.first(forUserID: "Test", + userDefaults: developmentUserDefaults).latest, + "1.0.0") + } +} + +final class SchedulingInvalidationTests: XCTestCase { + override func setUpWithError() throws { + SUKUserDefaults.setEnvironment(.test) + SUK.reset() + } + + override func tearDownWithError() throws { + for environment in [SUKUserDefaults.Environment.production, .development, .test] { + SUKUserDefaults.setEnvironment(environment) + SUK.reset() + } + } + + func testDailyVersionCheckInvokesSubclassOverrides() { + initializeSUKForSchedulingTests() + SUK.reset() + + let lookup = ControlledAppStoreLookup() + let ineligibleCondition = IneligibleDailyVersionCheckCondition() + + SUK.checkVersion(ineligibleCondition, + update: nil, + newRelease: nil, + forUserID: "Test", + noop: nil, + lookup: lookup) + waitForMainQueue() + + XCTAssertEqual(ineligibleCondition.eligibilityCallCount, 1) + XCTAssertEqual(lookup.requestCount, 0) + + SUK.reset() + + let recordingCondition = RecordingDailyVersionCheckCondition() + SUK.checkVersion(recordingCondition, + update: nil, + newRelease: nil, + forUserID: "Test", + noop: nil, + lookup: lookup) + waitForMainQueue() + XCTAssertEqual(lookup.requestCount, 1) + + lookup.completeNext(with: .success([.stub(version: "1.0.0")])) + waitForMainQueue() + + XCTAssertEqual(recordingCondition.recordingCallCount, 1) + XCTAssertNotEqual(SUKUserDefaults.standard + .integer(forKey: SwiftyUpdateKitLastVersionCheckDateKey), + 0) + } + + func testLaunchingAndDailyVersionCheckInvokesSubclassOverrides() { + initializeSUKForSchedulingTests() + SUK.reset() + + let lookup = ControlledAppStoreLookup() + let ineligibleCondition = IneligibleLaunchingAndDailyVersionCheckCondition() + + SUK.checkVersion(ineligibleCondition, + update: nil, + newRelease: nil, + forUserID: "Test", + noop: nil, + lookup: lookup) + waitForMainQueue() + + XCTAssertEqual(ineligibleCondition.eligibilityCallCount, 1) + XCTAssertEqual(lookup.requestCount, 0) + + SUK.reset() + + let recordingCondition = RecordingLaunchingAndDailyVersionCheckCondition() + SUK.checkVersion(recordingCondition, + update: nil, + newRelease: nil, + forUserID: "Test", + noop: nil, + lookup: lookup) + waitForMainQueue() + XCTAssertEqual(lookup.requestCount, 1) + + lookup.completeNext(with: .success([.stub(version: "1.0.0")])) + waitForMainQueue() + + let stateContext = SchedulingStateContext(userDefaults: SUKUserDefaults.standard, + key: SwiftyUpdateKitLastVersionCheckDateKey) + XCTAssertEqual(recordingCondition.recordingCallCount, 1) + XCTAssertNotEqual(InMemorySchedulingStateStore().integer(for: stateContext), 0) + } + + func testReentrantVersionComparisonResetDoesNotPublishStaleEffects() { + let comparisonCompleted = expectation(description: "Version comparison completed") + let comparator = ResettingVersionCompare { + comparisonCompleted.fulfill() + } + let config = schedulingTestConfig(version: "1.0.0", versionCompare: comparator) + SUK.initialize(withConfig: config) + SUK.reset() + + let userDefaults = SUKUserDefaults.standard + var callbackCount = 0 + let lookup = StubAppStoreLookup(result: .success([.stub(version: "2.0.0")])) + + SUK.checkVersion(VersionCheckConditionDaily(), + update: { _, _ in callbackCount += 1 }, + newRelease: { _, _, _ in callbackCount += 1 }, + forUserID: "Test", + noop: { callbackCount += 1 }, + lookup: lookup) + + wait(for: [comparisonCompleted], timeout: 1) + waitForMainQueue() + + XCTAssertEqual(callbackCount, 0) + XCTAssertEqual(userDefaults.integer(forKey: SwiftyUpdateKitLastVersionCheckDateKey), 0) + XCTAssertNil(ReleaseNotes.first(forUserID: "Test", userDefaults: userDefaults).latest) + } + + func testQueuedUpdateAlertIsCancelledAfterReset() { + let config = schedulingTestConfig(version: "1.0.0") + SUK.initialize(withConfig: config) + SUK.reset() + + let token = sharedSchedulingExecutionGate.token(for: SUKUserDefaults.standard) + var presentationCount = 0 + var openCount = 0 + + SUK.enqueueUpdateAlert(config: config, + log: nil, + isCurrent: { + sharedSchedulingExecutionGate.isCurrent(token) + }, + presenter: { _, _ in + presentationCount += 1 + }, + openURL: { _ in + openCount += 1 + }) + + SUK.reset() + waitForMainQueue() + + XCTAssertEqual(presentationCount, 0) + XCTAssertEqual(openCount, 0) + } + + func testQueuedUpdateAlertUsesCapturedConfigurationAndStoreURL() { + let originalStoreURL = "https://apps.apple.com/app/id1111111111" + let replacementStoreURL = "https://apps.apple.com/app/id2222222222" + let originalConfig = schedulingTestConfig(version: "1.0.0", + storeURL: originalStoreURL, + updateAlertTitle: "Original title", + development: false) + SUK.initialize(withConfig: originalConfig) + SUK.reset() + + let token = sharedSchedulingExecutionGate.token(for: SUKUserDefaults.standard) + var presentedConfig: SwiftyUpdateKitConfig? + var updateAction: (() -> Void)? + var openedURL: URL? + + SUK.enqueueUpdateAlert(config: originalConfig, + log: nil, + isCurrent: { + sharedSchedulingExecutionGate.isCurrent(token) + }, + presenter: { config, action in + presentedConfig = config + updateAction = action + }, + openURL: { url in + openedURL = url + }) + + SUK.initialize(withConfig: schedulingTestConfig(version: "2.0.0", + storeURL: replacementStoreURL, + updateAlertTitle: "Replacement title", + development: true)) + waitForMainQueue() + + XCTAssertEqual(presentedConfig?.updateAlertTitle, "Original title") + XCTAssertNil(openedURL) + + updateAction?() + + XCTAssertEqual(openedURL?.absoluteString, originalStoreURL) + } + + func testPresentedUpdateAlertActionStillOpensCapturedStoreURLAfterReset() { + let config = schedulingTestConfig(version: "1.0.0", + storeURL: "https://apps.apple.com/app/id1111111111") + SUK.initialize(withConfig: config) + SUK.reset() + + let token = sharedSchedulingExecutionGate.token(for: SUKUserDefaults.standard) + var updateAction: (() -> Void)? + var openedURL: URL? + + SUK.enqueueUpdateAlert(config: config, + log: nil, + isCurrent: { + sharedSchedulingExecutionGate.isCurrent(token) + }, + presenter: { _, action in + updateAction = action + }, + openURL: { url in + openedURL = url + }) + waitForMainQueue() + + XCTAssertNotNil(updateAction) + + SUK.reset() + updateAction?() + + XCTAssertEqual(openedURL?.absoluteString, + "https://apps.apple.com/app/id1111111111") + } + + func testReviewRequestInvokesSubclassOverrides() { + initializeSUKForSchedulingTests() + SUK.reset() + + let ineligibleCondition = IneligibleDailyReviewCondition() + var requestCount = 0 + SUK.requestReviewIfNeededForTesting(ineligibleCondition) { + requestCount += 1 + } + + XCTAssertEqual(ineligibleCondition.eligibilityCallCount, 1) + XCTAssertEqual(requestCount, 0) + + let recordingCondition = RecordingLaunchingAndDailyReviewCondition() + SUK.requestReviewIfNeededForTesting(recordingCondition) { + requestCount += 1 + } + + XCTAssertEqual(recordingCondition.recordingCallCount, 1) + XCTAssertEqual(requestCount, 1) + } + + func testResetInvalidatesPersistentReviewRecordingInProgress() { + initializeSUKForSchedulingTests() + SUK.reset() + + let started = DispatchSemaphore(value: 0) + let proceed = DispatchSemaphore(value: 0) + let requestCount = LockedCounter() + let operationCompleted = expectation(description: "Review operation completed") + let condition = BlockingDailyReviewCondition(recordingStarted: started, + continueRecording: proceed) + + DispatchQueue.global().async { + SUK.requestReviewIfNeededForTesting(condition) { + requestCount.increment() + } + operationCompleted.fulfill() + } + + XCTAssertEqual(started.wait(timeout: .now() + 1), .success) + SUK.reset() + proceed.signal() + wait(for: [operationCompleted], timeout: 1) + + XCTAssertEqual(requestCount.value, 0) + XCTAssertEqual(SUKUserDefaults.standard + .integer(forKey: SwiftyUpdateKitLastRequireReviewDateKey), + 0) + } + + func testResetInvalidatesInMemoryReviewRecordingInProgress() { + initializeSUKForSchedulingTests() + SUK.reset() + + let userDefaults = SUKUserDefaults.standard + let started = DispatchSemaphore(value: 0) + let proceed = DispatchSemaphore(value: 0) + let requestCount = LockedCounter() + let operationCompleted = expectation(description: "Review operation completed") + let condition = BlockingLaunchingAndDailyReviewCondition(recordingStarted: started, + continueRecording: proceed) + + DispatchQueue.global().async { + SUK.requestReviewIfNeededForTesting(condition) { + requestCount.increment() + } + operationCompleted.fulfill() + } + + XCTAssertEqual(started.wait(timeout: .now() + 1), .success) + SUK.reset() + proceed.signal() + wait(for: [operationCompleted], timeout: 1) + + let stateContext = SchedulingStateContext(userDefaults: userDefaults, + key: SwiftyUpdateKitLastRequireReviewDateKey) + XCTAssertEqual(requestCount.value, 0) + XCTAssertEqual(InMemorySchedulingStateStore().integer(for: stateContext), 0) + } + + func testConcurrentRuntimeSnapshotsKeepConfigurationLogAndEnvironmentTogether() { + let runtimeState = SUKRuntimeState() + let mismatches = AtomicDictionary() + let productionConfig = schedulingTestConfig(version: "production", development: false) + let developmentConfig = schedulingTestConfig(version: "development", development: true) + let productionLog: Log = { message in + if message as? String != "production" { + mismatches.setValue(true, forKey: 0) + } + } + let developmentLog: Log = { message in + if message as? String != "development" { + mismatches.setValue(true, forKey: 1) + } + } + + DispatchQueue.concurrentPerform(iterations: 1_000) { iteration in + if iteration.isMultiple(of: 2) { + runtimeState.initialize(config: productionConfig, log: productionLog) + } else { + runtimeState.initialize(config: developmentConfig, log: developmentLog) + } + + let snapshot = runtimeState.snapshot() + guard let config = snapshot.config else { + mismatches.setValue(true, forKey: 2) + return + } + + let expectedEnvironment: SUKUserDefaults.Environment = + config.isDevelopment ? .development : .production + if snapshot.userDefaults.env != expectedEnvironment { + mismatches.setValue(true, forKey: 3) + } + snapshot.log?(config.version) + } + + for key in 0 ... 3 { + XCTAssertNil(mismatches.value(forKey: key)) + } + } + + func testResetBeforeQueuedVersionCheckPreventsPersistentOperation() { + initializeSUKForSchedulingTests() + SUK.reset() + + let userDefaults = SUKUserDefaults.standard + let lookup = ControlledAppStoreLookup() + + checkVersion(VersionCheckConditionDaily(), lookup: lookup) + SUK.reset() + waitForMainQueue() + + XCTAssertEqual(lookup.requestCount, 0) + XCTAssertEqual(userDefaults.integer(forKey: SwiftyUpdateKitLastVersionCheckDateKey), 0) + } + + func testResetBeforeQueuedReviewRequestPreventsInMemoryOperation() { + initializeSUKForSchedulingTests() + SUK.reset() + + let userDefaults = SUKUserDefaults.standard + let stateContext = SchedulingStateContext(userDefaults: userDefaults, + key: SwiftyUpdateKitLastRequireReviewDateKey) + var requestCount = 0 + + SUK.enqueueReviewRequest(RequestReviewConditionLaunchingAndDaily()) { + requestCount += 1 + } + SUK.reset() + waitForMainQueue() + + XCTAssertEqual(requestCount, 0) + XCTAssertEqual(InMemorySchedulingStateStore().integer(for: stateContext), 0) + } +} + +final class AtomicDictionaryTests: XCTestCase { + func testConcurrentReadWriteAndRemove() { + let dictionary = AtomicDictionary() + + DispatchQueue.concurrentPerform(iterations: 1_000) { iteration in + let key = iteration % 16 + dictionary.setValue(iteration, forKey: key) + _ = dictionary.value(forKey: key) + dictionary.removeValue(forKey: key) + } + + dictionary.setValue(42, forKey: 0) + XCTAssertEqual(dictionary.value(forKey: 0), 42) + XCTAssertEqual(dictionary.removeValue(forKey: 0), 42) + XCTAssertNil(dictionary.value(forKey: 0)) + } +} + final class SchedulingConditionTests: XCTestCase { + override func setUpWithError() throws { + SUKUserDefaults.setEnvironment(.test) + SUK.reset() + } + + override func tearDownWithError() throws { + for environment in [SUKUserDefaults.Environment.production, .development, .test] { + SUKUserDefaults.setEnvironment(environment) + SUK.reset() + } + } + func testDailyVersionCheckRecordsOnlyAfterSuccess() { let clock = TestClock(currentDate: 20_260_819) let stateStore = TestSchedulingStateStore() let condition = VersionCheckConditionDaily(clock: clock, stateStore: stateStore) + let stateContext = SchedulingStateContext(userDefaults: SUKUserDefaults.standard, + key: SwiftyUpdateKitLastVersionCheckDateKey) XCTAssertTrue(condition.shouldCheckVersion()) XCTAssertTrue(condition.shouldCheckVersion()) @@ -73,8 +740,7 @@ final class SchedulingConditionTests: XCTestCase { condition.recordSuccessfulVersionCheck() XCTAssertEqual(stateStore.writeCount, 1) - XCTAssertEqual(stateStore.integer(forKey: SwiftyUpdateKitLastVersionCheckDateKey), - 20_260_819) + XCTAssertEqual(stateStore.integer(for: stateContext), 20_260_819) XCTAssertFalse(condition.shouldCheckVersion()) clock.date = 20_260_820 @@ -128,6 +794,8 @@ final class SchedulingConditionTests: XCTestCase { let condition = VersionCheckConditionDaily(clock: clock, stateStore: stateStore) let checkCompleted = expectation(description: "Check completed") initializeSUKForSchedulingTests() + let stateContext = SchedulingStateContext(userDefaults: SUKUserDefaults.standard, + key: SwiftyUpdateKitLastVersionCheckDateKey) let lookup = StubAppStoreLookup(result: .success([.stub(version: "1.0.0")])) @@ -142,8 +810,7 @@ final class SchedulingConditionTests: XCTestCase { wait(for: [checkCompleted], timeout: 1) XCTAssertEqual(stateStore.writeCount, 1) - XCTAssertEqual(stateStore.integer(forKey: SwiftyUpdateKitLastVersionCheckDateKey), - 20_260_819) + XCTAssertEqual(stateStore.integer(for: stateContext), 20_260_819) XCTAssertFalse(condition.shouldCheckVersion()) } @@ -197,6 +864,40 @@ final class SchedulingConditionTests: XCTestCase { lookup.completeNext(with: .failure(TestLookupError.failed)) } + func testIneligibleVersionCheckCallsNoopWhileAnotherLookupIsInProgress() { + let lookup = ControlledAppStoreLookup() + let ineligibleCondition = IneligibleDailyVersionCheckCondition() + var noopCallCount = 0 + initializeSUKForSchedulingTests() + SUK.reset() + + SUK.checkVersion(VersionCheckConditionDaily(), + update: nil, + newRelease: nil, + forUserID: "Test", + noop: nil, + lookup: lookup) + waitForMainQueue() + XCTAssertEqual(lookup.requestCount, 1) + + SUK.checkVersion(ineligibleCondition, + update: nil, + newRelease: nil, + forUserID: "Test", + noop: { + noopCallCount += 1 + }, + lookup: lookup) + waitForMainQueue() + waitForMainQueue() + + XCTAssertEqual(ineligibleCondition.eligibilityCallCount, 1) + XCTAssertEqual(noopCallCount, 1) + XCTAssertEqual(lookup.requestCount, 1) + + lookup.completeNext(with: .failure(TestLookupError.failed)) + } + func testLaunchingAndDailyCheckCanRetryAfterFailure() { let clock = TestClock(currentDate: 20_260_819) let stateStore = TestSchedulingStateStore() @@ -229,19 +930,20 @@ final class SchedulingConditionTests: XCTestCase { let clock = TestClock(currentDate: 20_260_819) let stateStore = TestSchedulingStateStore() let condition = RequestReviewConditionDaily(clock: clock, stateStore: stateStore) + let stateContext = SchedulingStateContext(userDefaults: SUKUserDefaults.standard, + key: SwiftyUpdateKitLastRequireReviewDateKey) var requestCount = 0 XCTAssertTrue(condition.shouldRequestReview()) XCTAssertEqual(stateStore.writeCount, 0) - SUK.requestReviewIfNeeded(condition) { + SUK.requestReviewIfNeededForTesting(condition) { requestCount += 1 XCTAssertEqual(stateStore.writeCount, 1) } XCTAssertEqual(requestCount, 1) - XCTAssertEqual(stateStore.integer(forKey: SwiftyUpdateKitLastRequireReviewDateKey), - 20_260_819) + XCTAssertEqual(stateStore.integer(for: stateContext), 20_260_819) XCTAssertFalse(condition.shouldRequestReview()) } @@ -250,26 +952,153 @@ final class SchedulingConditionTests: XCTestCase { let stateStore = TestSchedulingStateStore() let condition = RequestReviewConditionDailySkipFirstDay(clock: clock, stateStore: stateStore) + let stateContext = SchedulingStateContext(userDefaults: SUKUserDefaults.standard, + key: SwiftyUpdateKitLastRequireReviewDateKey) var requestCount = 0 - SUK.requestReviewIfNeeded(condition) { + SUK.requestReviewIfNeededForTesting(condition) { requestCount += 1 } XCTAssertEqual(requestCount, 0) XCTAssertEqual(stateStore.writeCount, 1) - XCTAssertEqual(stateStore.integer(forKey: SwiftyUpdateKitLastRequireReviewDateKey), - 20_260_819) + XCTAssertEqual(stateStore.integer(for: stateContext), 20_260_819) clock.date = 20_260_820 - SUK.requestReviewIfNeeded(condition) { + SUK.requestReviewIfNeededForTesting(condition) { requestCount += 1 } XCTAssertEqual(requestCount, 1) XCTAssertEqual(stateStore.writeCount, 2) - XCTAssertEqual(stateStore.integer(forKey: SwiftyUpdateKitLastRequireReviewDateKey), - 20_260_820) + XCTAssertEqual(stateStore.integer(for: stateContext), 20_260_820) + } +} + +private final class IneligibleDailyVersionCheckCondition: VersionCheckConditionDaily { + private(set) var eligibilityCallCount = 0 + + override func shouldCheckVersion() -> Bool { + eligibilityCallCount += 1 + return false + } +} + +private final class RecordingDailyVersionCheckCondition: VersionCheckConditionDaily { + private(set) var recordingCallCount = 0 + + override func recordSuccessfulVersionCheck() { + recordingCallCount += 1 + super.recordSuccessfulVersionCheck() + } +} + +private final class IneligibleLaunchingAndDailyVersionCheckCondition: + VersionCheckConditionLaunchingAndDaily +{ + private(set) var eligibilityCallCount = 0 + + override func shouldCheckVersion() -> Bool { + eligibilityCallCount += 1 + return false + } +} + +private final class RecordingLaunchingAndDailyVersionCheckCondition: + VersionCheckConditionLaunchingAndDaily +{ + private(set) var recordingCallCount = 0 + + override func recordSuccessfulVersionCheck() { + recordingCallCount += 1 + super.recordSuccessfulVersionCheck() + } +} + +private final class IneligibleDailyReviewCondition: RequestReviewConditionDaily { + private(set) var eligibilityCallCount = 0 + + override func shouldRequestReview() -> Bool { + eligibilityCallCount += 1 + return false + } +} + +private final class RecordingLaunchingAndDailyReviewCondition: + RequestReviewConditionLaunchingAndDaily +{ + private(set) var recordingCallCount = 0 + + override func recordReviewRequestAttempt() { + recordingCallCount += 1 + super.recordReviewRequestAttempt() + } +} + +private final class BlockingDailyReviewCondition: RequestReviewConditionDaily { + private let recordingStarted: DispatchSemaphore + private let continueRecording: DispatchSemaphore + + init(recordingStarted: DispatchSemaphore, continueRecording: DispatchSemaphore) { + self.recordingStarted = recordingStarted + self.continueRecording = continueRecording + super.init() + } + + override func recordReviewRequestAttempt() { + recordingStarted.signal() + continueRecording.wait() + super.recordReviewRequestAttempt() + } +} + +private final class BlockingLaunchingAndDailyReviewCondition: + RequestReviewConditionLaunchingAndDaily +{ + private let recordingStarted: DispatchSemaphore + private let continueRecording: DispatchSemaphore + + init(recordingStarted: DispatchSemaphore, continueRecording: DispatchSemaphore) { + self.recordingStarted = recordingStarted + self.continueRecording = continueRecording + super.init() + } + + override func recordReviewRequestAttempt() { + recordingStarted.signal() + continueRecording.wait() + super.recordReviewRequestAttempt() + } +} + +private final class ResettingVersionCompare: VersionComparable { + private let completion: () -> Void + + init(completion: @escaping () -> Void) { + self.completion = completion + } + + func compare(_: String, with _: String) -> Bool { + SUK.reset() + completion() + return true + } +} + +private final class LockedCounter { + private let lock = NSLock() + private var count = 0 + + var value: Int { + lock.lock() + defer { lock.unlock() } + return count + } + + func increment() { + lock.lock() + defer { lock.unlock() } + count += 1 } } @@ -289,13 +1118,17 @@ private final class TestSchedulingStateStore: SUKSchedulingStateStore { private var values: [String: Int] = [:] private(set) var writeCount = 0 - func set(_ value: Int, forKey key: String) { - values[key] = value + func set(_ value: Int, for context: SchedulingStateContext) { + values[context.storageKey] = value writeCount += 1 } - func integer(forKey key: String) -> Int { - values[key] ?? 0 + func integer(for context: SchedulingStateContext) -> Int { + values[context.storageKey] ?? 0 + } + + func removeValue(for context: SchedulingStateContext) { + values.removeValue(forKey: context.storageKey) } } @@ -336,10 +1169,24 @@ private enum TestLookupError: Error { case failed } -private func initializeSUKForSchedulingTests() { - SUK.initialize(withConfig: SwiftyUpdateKitConfig(version: "1.0.0", - iTunesID: "1234567890", - storeURL: "https://apps.apple.com/app/id1234567890")) +private func initializeSUKForSchedulingTests(development: Bool = true) { + SUK.initialize(withConfig: schedulingTestConfig(version: "1.0.0", + development: development)) +} + +private func schedulingTestConfig(version: String, + storeURL: String = "https://apps.apple.com/app/id1234567890", + updateAlertTitle: String = SwiftyUpdateKitConfig + .defaultUpdateAlertTitle, + versionCompare: VersionComparable? = nil, + development: Bool = true) -> SwiftyUpdateKitConfig +{ + SwiftyUpdateKitConfig(version: version, + iTunesID: "1234567890", + storeURL: storeURL, + versionCompare: versionCompare, + updateAlertTitle: updateAlertTitle, + development: development) } private func checkVersion(_ condition: VersionCheckCondition, diff --git a/README.md b/README.md index 47bcf4a..a214242 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,10 @@ let config = SwiftyUpdateKitConfig( SUK.initialize(withConfig: config) ``` +Calling `initialize` again does not invalidate work that is already queued or in progress. Those +operations continue with the configuration and environment captured when they started. Call +`SUK.reset()` before reinitializing when the existing operations must be cancelled. + ### Quick Usage To check whether new version is released, you use `SUK.checkVersion` method in `viewDidAppear` method of the view controller. See following: @@ -180,7 +184,11 @@ SUK.openAppStore() ### Reset the status -Resets the status: stored date of version check condition, stored date of request review condition, and stored app version for the release notes. +Resets the status for the current environment: stored dates of version check and request review +conditions in persistent and in-memory storage, and the stored app version for the release notes. +The reset completes synchronously and cancels in-flight and queued version checks, review requests, +and update alerts that have not yet been presented. An update alert that is already visible keeps +its captured configuration, and its Update button still opens the corresponding App Store page. For example, you may use `SUK.reset` method during testing and development.