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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ jobs:
with:
workspace: SwiftyUpdateKit.xcworkspace
scheme: SwiftyUpdateKit
destination: 'platform=iOS Simulator,name=iPhone 12 Pro Max'
destination: 'platform=iOS Simulator,name=iPhone 17'
Comment thread
HituziANDO marked this conversation as resolved.
action: test
110 changes: 110 additions & 0 deletions Framework/Sources/DailySchedule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//
// DailySchedule.swift
// SwiftyUpdateKit
//
// Copyright © 2026 Hituzi Ando. All rights reserved.
//

import Foundation

protocol SUKClock {
func currentDate() -> Int
}

struct SystemSUKClock: SUKClock {
func currentDate() -> Int {
DateUtils.currentDate()
}
}

protocol SUKSchedulingStateStore {
func set(_ value: Int, forKey key: String)
func integer(forKey key: String) -> Int
}

struct UserDefaultsSchedulingStateStore: SUKSchedulingStateStore {
func set(_ value: Int, forKey key: String) {
SUKUserDefaults.standard.set(value, forKey: key)
}

func integer(forKey key: String) -> Int {
SUKUserDefaults.standard.integer(forKey: key)
}
}

struct InMemorySchedulingStateStore: SUKSchedulingStateStore {
func set(_ value: Int, forKey key: String) {
sharedDictionary.setValue(value, forKey: key)
}

func integer(forKey key: String) -> Int {
sharedDictionary.value(forKey: key) as? Int ?? 0
}
}

protocol SchedulingExecutionGating: AnyObject {
func beginExecution(forKey key: String) -> Bool
func finishExecution(forKey key: String)
}

final class SchedulingExecutionGate: SchedulingExecutionGating {
private let lock = NSLock()
private var runningKeys: Set<String> = []

func beginExecution(forKey key: String) -> Bool {
lock.lock()
defer { lock.unlock() }

return runningKeys.insert(key).inserted
}

func finishExecution(forKey key: String) {
lock.lock()
defer { lock.unlock() }

runningKeys.remove(key)
}
}

let sharedSchedulingExecutionGate = SchedulingExecutionGate()

struct DailySchedule {
private let clock: SUKClock
private let stateStore: SUKSchedulingStateStore
private let executionGate: SchedulingExecutionGating
private let key: String

init(clock: SUKClock,
stateStore: SUKSchedulingStateStore,
executionGate: SchedulingExecutionGating = sharedSchedulingExecutionGate,
key: String)
{
self.clock = clock
self.stateStore = stateStore
self.executionGate = executionGate
self.key = key
}

func shouldRun() -> Bool {
stateStore.integer(forKey: key) < clock.currentDate()
}

func hasRecordedDate() -> Bool {
stateStore.integer(forKey: key) != 0
}

func recordCurrentDate() {
let currentDate = clock.currentDate()
guard stateStore.integer(forKey: key) < currentDate else { return }

stateStore.set(currentDate, forKey: key)
}

func beginExecution() -> Bool {
executionGate.beginExecution(forKey: key)
}

func finishExecution() {
executionGate.finishExecution(forKey: key)
}
}
13 changes: 13 additions & 0 deletions Framework/Sources/ITunesSearchAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ enum ITunesSearchAPIError: Error {
case invalidResponseData
}

protocol AppStoreLookup {
func lookUp(with config: SwiftyUpdateKitConfig,
completion: @escaping (Result<[LookUpResult], Error>) -> Void)
}

struct ITunesAppStoreLookup: AppStoreLookup {
func lookUp(with config: SwiftyUpdateKitConfig,
completion: @escaping (Result<[LookUpResult], Error>) -> Void)
{
ITunesSearchAPI.lookUp(with: config, completion: completion)
}
}

struct ITunesSearchAPI {
public static func lookUp(with config: SwiftyUpdateKitConfig,
completion: @escaping (Result<[LookUpResult], Error>) -> Void)
Expand Down
122 changes: 83 additions & 39 deletions Framework/Sources/RequestReviewCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
import Foundation

/// The key of UserDefaults.standard.
/// The value retrieved with this key is Int value as yyyyMMdd representation.
/// The value retrieved with this key is the last review request attempt date as an Int in yyyyMMdd
/// representation. Skip-first-day conditions initially store the first evaluation date.
public let SwiftyUpdateKitLastRequireReviewDateKey =
"jp.hituzi.SwiftyUpdateKit.lastRequireReviewDateKey"

Expand All @@ -18,6 +19,15 @@ public protocol RequestReviewCondition: AnyObject {
func shouldRequestReview() -> Bool
}

/// Records a review request attempt for a condition that maintains scheduling state.
///
/// StoreKit does not report whether the system displayed the review interface, so the stored value
/// represents an attempt rather than a confirmed presentation.
public protocol ReviewRequestAttemptRecording: AnyObject {
/// Records that the app called the StoreKit review request API.
func recordReviewRequestAttempt()
}

/// Always asks a user for a review.
open class RequestReviewConditionAlways: RequestReviewCondition {
public init() {}
Expand All @@ -37,75 +47,109 @@ open class RequestReviewConditionDisable: RequestReviewCondition {
}

/// Asks a user for a review once a day.
open class RequestReviewConditionDaily: RequestReviewCondition {
public init() {}
open class RequestReviewConditionDaily: RequestReviewCondition, ReviewRequestAttemptRecording {
private var schedule = DailySchedule(clock: SystemSUKClock(),
stateStore: UserDefaultsSchedulingStateStore(),
key: SwiftyUpdateKitLastRequireReviewDateKey)

open func shouldRequestReview() -> Bool {
let lastDate = SUKUserDefaults.standard
.integer(forKey: SwiftyUpdateKitLastRequireReviewDateKey)
let today = DateUtils.currentDate()
public init() {}

guard lastDate < today else { return false }
init(clock: SUKClock, stateStore: SUKSchedulingStateStore) {
schedule = DailySchedule(clock: clock,
stateStore: stateStore,
key: SwiftyUpdateKitLastRequireReviewDateKey)
}

SUKUserDefaults.standard.set(today, forKey: SwiftyUpdateKitLastRequireReviewDateKey)
open func shouldRequestReview() -> Bool {
schedule.shouldRun()
}

return true
open func recordReviewRequestAttempt() {
schedule.recordCurrentDate()
}
}

/// Asks a user for a review once a day, but skips first day.
open class RequestReviewConditionDailySkipFirstDay: RequestReviewCondition {
public init() {}
open class RequestReviewConditionDailySkipFirstDay: RequestReviewCondition,
ReviewRequestAttemptRecording
{
private var schedule = DailySchedule(clock: SystemSUKClock(),
stateStore: UserDefaultsSchedulingStateStore(),
key: SwiftyUpdateKitLastRequireReviewDateKey)

open func shouldRequestReview() -> Bool {
let lastDate = SUKUserDefaults.standard
.integer(forKey: SwiftyUpdateKitLastRequireReviewDateKey)
public init() {}

// lastDate is 0 means the first day because the value is not set.
if lastDate == 0 {
let today = DateUtils.currentDate()
SUKUserDefaults.standard.set(today, forKey: SwiftyUpdateKitLastRequireReviewDateKey)
init(clock: SUKClock, stateStore: SUKSchedulingStateStore) {
schedule = DailySchedule(clock: clock,
stateStore: stateStore,
key: SwiftyUpdateKitLastRequireReviewDateKey)
}

open func shouldRequestReview() -> Bool {
if !schedule.hasRecordedDate() {
schedule.recordCurrentDate()
return false
}

return RequestReviewConditionDaily().shouldRequestReview()
return schedule.shouldRun()
}

open func recordReviewRequestAttempt() {
schedule.recordCurrentDate()
}
}

/// Asks a user for a review when the app is launched and once a day.
open class RequestReviewConditionLaunchingAndDaily: RequestReviewCondition {
public init() {}
open class RequestReviewConditionLaunchingAndDaily: RequestReviewCondition,
ReviewRequestAttemptRecording
{
private var schedule = DailySchedule(clock: SystemSUKClock(),
stateStore: InMemorySchedulingStateStore(),
key: SwiftyUpdateKitLastRequireReviewDateKey)

open func shouldRequestReview() -> Bool {
let lastDate = sharedDictionary
.value(forKey: SwiftyUpdateKitLastRequireReviewDateKey) as? Int ?? 0
let today = DateUtils.currentDate()
public init() {}

guard lastDate < today else { return false }
init(clock: SUKClock, stateStore: SUKSchedulingStateStore) {
schedule = DailySchedule(clock: clock,
stateStore: stateStore,
key: SwiftyUpdateKitLastRequireReviewDateKey)
}

sharedDictionary.setValue(today, forKey: SwiftyUpdateKitLastRequireReviewDateKey)
open func shouldRequestReview() -> Bool {
schedule.shouldRun()
}

return true
open func recordReviewRequestAttempt() {
schedule.recordCurrentDate()
}
}

/// Asks a user for a review when the app is launched and once a day, but skips first day.
open class RequestReviewConditionLaunchingAndDailySkipFirstDay: RequestReviewCondition {
public init() {}
open class RequestReviewConditionLaunchingAndDailySkipFirstDay: RequestReviewCondition,
ReviewRequestAttemptRecording
{
private var schedule = DailySchedule(clock: SystemSUKClock(),
stateStore: InMemorySchedulingStateStore(),
key: SwiftyUpdateKitLastRequireReviewDateKey)

open func shouldRequestReview() -> Bool {
let lastDate = sharedDictionary
.value(forKey: SwiftyUpdateKitLastRequireReviewDateKey) as? Int ?? 0
public init() {}

// lastDate is 0 means the first day because the value is not set.
if lastDate == 0 {
let today = DateUtils.currentDate()
sharedDictionary.setValue(today, forKey: SwiftyUpdateKitLastRequireReviewDateKey)
init(clock: SUKClock, stateStore: SUKSchedulingStateStore) {
schedule = DailySchedule(clock: clock,
stateStore: stateStore,
key: SwiftyUpdateKitLastRequireReviewDateKey)
}

open func shouldRequestReview() -> Bool {
if !schedule.hasRecordedDate() {
schedule.recordCurrentDate()
return false
}

return RequestReviewConditionLaunchingAndDaily().shouldRequestReview()
return schedule.shouldRun()
}

open func recordReviewRequestAttempt() {
schedule.recordCurrentDate()
}
}
Loading
Loading