178 lines
6.5 KiB
Swift
178 lines
6.5 KiB
Swift
import Foundation
|
|
|
|
/// One invitation budget for every app surface. StoreKit reports neither
|
|
/// presentation nor completion, so persisted records describe attempts, never ratings.
|
|
@MainActor
|
|
public final class AppReviewController {
|
|
public static let reviewURL = URL(string: "https://apps.apple.com/app/id6805856690?action=write-review")!
|
|
|
|
public enum Location: Equatable {
|
|
case workspace(UUID)
|
|
case reviewPage(String)
|
|
case other
|
|
|
|
var isWorkspace: Bool {
|
|
if case .workspace = self { return true }
|
|
return false
|
|
}
|
|
|
|
var isReviewPage: Bool {
|
|
if case .reviewPage = self { return true }
|
|
return false
|
|
}
|
|
}
|
|
|
|
public struct Context: Equatable {
|
|
public var location: Location
|
|
public var isActive: Bool
|
|
public var isBlocked: Bool
|
|
|
|
public init(location: Location, isActive: Bool, isBlocked: Bool = false) {
|
|
self.location = location
|
|
self.isActive = isActive
|
|
self.isBlocked = isBlocked
|
|
}
|
|
|
|
var canRequest: Bool { isActive && !isBlocked && location.isReviewPage }
|
|
}
|
|
|
|
private struct Pending {
|
|
let surfaceID: UUID
|
|
let deadline: TimeInterval
|
|
}
|
|
|
|
private let defaults: UserDefaults?
|
|
private let version: String
|
|
private let now: () -> Date
|
|
private let uptime: () -> TimeInterval
|
|
private let calendar: Calendar
|
|
public let isEnabled: Bool
|
|
private var contexts: [UUID: Context] = [:]
|
|
private var activeSurfaceID: UUID?
|
|
private var lastTick: TimeInterval?
|
|
private var workspaceSeconds: TimeInterval = 0
|
|
private var pending: Pending?
|
|
|
|
public init(
|
|
defaults: UserDefaults? = nil, version: String = "",
|
|
isEnabled: Bool = false, now: @escaping () -> Date = Date.init,
|
|
uptime: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime },
|
|
calendar: Calendar = .autoupdatingCurrent
|
|
) {
|
|
self.defaults = defaults
|
|
self.version = version
|
|
self.isEnabled = isEnabled
|
|
self.now = now
|
|
self.uptime = uptime
|
|
self.calendar = calendar
|
|
}
|
|
|
|
public static func live() -> AppReviewController {
|
|
let isTesting = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
|
|
return AppReviewController(
|
|
defaults: isTesting ? nil : .standard,
|
|
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "",
|
|
isEnabled: !isTesting
|
|
)
|
|
}
|
|
|
|
/// Called on navigation, native activity changes and foreground timer ticks.
|
|
/// Only a workspace-to-list transition can arm a request; becoming eligible,
|
|
/// dismissing a sheet, or regaining focus alone never does.
|
|
public func update(surfaceID: UUID, context: Context) {
|
|
guard isEnabled else { return }
|
|
accountForWorkspaceTime()
|
|
let previous = contexts[surfaceID]
|
|
contexts[surfaceID] = context
|
|
|
|
if context.isActive {
|
|
if activeSurfaceID != surfaceID {
|
|
pending = nil
|
|
activeSurfaceID = surfaceID
|
|
}
|
|
recordActiveDay()
|
|
} else if activeSurfaceID == surfaceID {
|
|
activeSurfaceID = nil
|
|
}
|
|
|
|
if pending?.surfaceID == surfaceID,
|
|
!context.canRequest || previous?.location != context.location
|
|
{
|
|
pending = nil
|
|
}
|
|
if previous?.isActive == true, previous?.location.isWorkspace == true,
|
|
context.canRequest, isEligible
|
|
{
|
|
pending = Pending(surfaceID: surfaceID, deadline: uptime() + 3)
|
|
}
|
|
}
|
|
|
|
public func recordInteraction(surfaceID: UUID) {
|
|
accountForWorkspaceTime()
|
|
pending = nil
|
|
if contexts[surfaceID]?.isActive == true { activeSurfaceID = surfaceID }
|
|
}
|
|
|
|
public func removeSurface(_ surfaceID: UUID) {
|
|
accountForWorkspaceTime()
|
|
contexts.removeValue(forKey: surfaceID)
|
|
if activeSurfaceID == surfaceID { activeSurfaceID = nil }
|
|
recordInteraction(surfaceID: surfaceID)
|
|
}
|
|
|
|
public func requestIfReady(surfaceID: UUID, request: () -> Void) {
|
|
guard let pending, pending.surfaceID == surfaceID,
|
|
uptime() >= pending.deadline, activeSurfaceID == surfaceID,
|
|
contexts[surfaceID]?.canRequest == true, isEligible
|
|
else { return }
|
|
self.pending = nil
|
|
var versions = defaults?.stringArray(forKey: SettingsKey.reviewRequestedVersions) ?? []
|
|
versions.append(version)
|
|
defaults?.set(versions, forKey: SettingsKey.reviewRequestedVersions)
|
|
defaults?.set(now(), forKey: SettingsKey.reviewLastAttempt)
|
|
request()
|
|
}
|
|
|
|
public func recordManualReview() {
|
|
pending = nil
|
|
defaults?.set(now(), forKey: SettingsKey.reviewLastAttempt)
|
|
}
|
|
|
|
private var isEligible: Bool {
|
|
guard isEnabled, !version.isEmpty, workspaceSeconds >= 300,
|
|
let firstUse = defaults?.object(forKey: SettingsKey.reviewFirstUse) as? Date,
|
|
now().timeIntervalSince(firstUse) >= 7 * 86_400,
|
|
(defaults?.stringArray(forKey: SettingsKey.reviewActiveDays)?.count ?? 0) >= 3,
|
|
!(defaults?.stringArray(forKey: SettingsKey.reviewRequestedVersions) ?? []).contains(version)
|
|
else { return false }
|
|
if let lastAttempt = defaults?.object(forKey: SettingsKey.reviewLastAttempt) as? Date {
|
|
return now().timeIntervalSince(lastAttempt) >= 120 * 86_400
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func accountForWorkspaceTime() {
|
|
let tick = uptime()
|
|
defer { lastTick = tick }
|
|
guard let lastTick, let activeSurfaceID,
|
|
let context = contexts[activeSurfaceID], context.isActive, context.location.isWorkspace
|
|
else { return }
|
|
workspaceSeconds += max(0, tick - lastTick)
|
|
}
|
|
|
|
private func recordActiveDay() {
|
|
let date = now()
|
|
if defaults?.object(forKey: SettingsKey.reviewFirstUse) == nil {
|
|
defaults?.set(date, forKey: SettingsKey.reviewFirstUse)
|
|
}
|
|
var days = defaults?.stringArray(forKey: SettingsKey.reviewActiveDays) ?? []
|
|
// Three distinct local dates prove eligibility; no growing usage log.
|
|
guard days.count < 3 else { return }
|
|
let parts = calendar.dateComponents([.era, .year, .month, .day], from: date)
|
|
let key = "\(parts.era ?? 0)-\(parts.year ?? 0)-\(parts.month ?? 0)-\(parts.day ?? 0)"
|
|
guard !days.contains(key) else { return }
|
|
days.append(key)
|
|
defaults?.set(days, forKey: SettingsKey.reviewActiveDays)
|
|
}
|
|
}
|