Files
purchases-ios/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift
T
Facundo Menzella 6b5c430082 chore: Enable force_unwrapping for SwiftLint (#4820)
* enable force_unwrapping

* avoid force unwrap

* fixed all errors

* remove extra disable
2025-02-26 12:40:49 +01:00

274 lines
10 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// CustomerCenterViewModel.swift
//
//
// Created by Cesar de la Vega on 27/5/24.
//
import Foundation
import RevenueCat
#if os(iOS)
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@MainActor class CustomerCenterViewModel: ObservableObject {
private static let defaultAppIsLatestVersion = true
typealias CurrentVersionFetcher = () -> String?
private lazy var currentAppVersion: String? = currentVersionFetcher()
@Published
private(set) var purchaseInformation: PurchaseInformation?
@Published
private(set) var appIsLatestVersion: Bool = defaultAppIsLatestVersion
@Published
private(set) var onUpdateAppClick: (() -> Void)?
private(set) var purchasesProvider: CustomerCenterPurchasesType
private(set) var customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType
/// Whether or not the Customer Center should warn the customer that they're on an outdated version of the app.
var shouldShowAppUpdateWarnings: Bool {
return !appIsLatestVersion && (configuration?.support.shouldWarnCustomerToUpdate ?? true)
}
// @PublicForExternalTesting
@Published
var state: CustomerCenterViewState {
didSet {
if case let .error(stateError) = state {
self.error = stateError
}
}
}
@Published
var configuration: CustomerCenterConfigData? {
didSet {
guard
let currentVersionString = currentAppVersion?.versionString(),
let latestVersionString = configuration?.lastPublishedAppVersion?.versionString(),
let currentVersion = try? SemanticVersion(currentVersionString),
let latestVersion = try? SemanticVersion(latestVersionString)
else {
self.appIsLatestVersion = Self.defaultAppIsLatestVersion
return
}
self.appIsLatestVersion = currentVersion >= latestVersion
}
}
private let currentVersionFetcher: CurrentVersionFetcher
internal let customerCenterActionHandler: CustomerCenterActionHandler?
private var error: Error?
private var impressionData: CustomerCenterEvent.Data?
init(
customerCenterActionHandler: CustomerCenterActionHandler?,
currentVersionFetcher: @escaping CurrentVersionFetcher = {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
},
purchasesProvider: CustomerCenterPurchasesType = CustomerCenterPurchases(),
customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType = CustomerCenterStoreKitUtilities()
) {
self.state = .notLoaded
self.currentVersionFetcher = currentVersionFetcher
self.customerCenterActionHandler = customerCenterActionHandler
self.purchasesProvider = purchasesProvider
self.customerCenterStoreKitUtilities = customerCenterStoreKitUtilities
}
#if DEBUG
convenience init(
purchaseInformation: PurchaseInformation,
configuration: CustomerCenterConfigData
) {
self.init(customerCenterActionHandler: nil)
self.purchaseInformation = purchaseInformation
self.configuration = configuration
self.state = .success
}
#endif
func loadScreen() async {
do {
try await self.loadPurchaseInformation()
try await self.loadCustomerCenterConfig()
self.state = .success
} catch {
self.state = .error(error)
}
}
func performRestore() async -> RestorePurchasesAlert.AlertType {
self.customerCenterActionHandler?(.restoreStarted)
do {
let customerInfo = try await purchasesProvider.restorePurchases()
self.customerCenterActionHandler?(.restoreCompleted(customerInfo))
let hasPurchases = !customerInfo.activeSubscriptions.isEmpty || !customerInfo.nonSubscriptions.isEmpty
return hasPurchases ? .purchasesRecovered : .purchasesNotFound
} catch {
self.customerCenterActionHandler?(.restoreFailed(error))
return .purchasesNotFound
}
}
func trackImpression(darkMode: Bool, displayMode: CustomerCenterPresentationMode) {
guard impressionData == nil else {
return
}
let eventData = CustomerCenterEvent.Data(locale: .current,
darkMode: darkMode,
isSandbox: purchasesProvider.isSandbox,
displayMode: displayMode)
defer { self.impressionData = eventData }
let event = CustomerCenterEvent.impression(CustomerCenterEventCreationData(), eventData)
purchasesProvider.track(customerCenterEvent: event)
}
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
private extension CustomerCenterViewModel {
func loadPurchaseInformation() async throws {
let customerInfo = try await purchasesProvider.customerInfo(fetchPolicy: .fetchCurrent)
let hasActiveProducts = !customerInfo.activeSubscriptions.isEmpty ||
!customerInfo.nonSubscriptions.isEmpty
if !hasActiveProducts {
self.purchaseInformation = nil
self.state = .success
return
}
guard let activeTransaction = findActiveTransaction(customerInfo: customerInfo) else {
Logger.warning(Strings.could_not_find_subscription_information)
self.purchaseInformation = nil
throw CustomerCenterError.couldNotFindSubscriptionInformation
}
let entitlement = customerInfo.entitlements.all.values
.first(where: { $0.productIdentifier == activeTransaction.productIdentifier })
self.purchaseInformation = try await createPurchaseInformation(
for: activeTransaction,
entitlement: entitlement,
customerInfo: customerInfo)
}
func loadCustomerCenterConfig() async throws {
self.configuration = try await purchasesProvider.loadCustomerCenter()
if let productId = configuration?.productId,
let url = URL(string: "https://itunes.apple.com/app/id\(productId)") {
self.onUpdateAppClick = {
// productId is a positive integer, so it should be safe to construct a URL from it.
URLUtilities.openURLIfNotAppExtension(url)
}
}
}
func findActiveTransaction(customerInfo: CustomerInfo) -> Transaction? {
let activeSubscriptions = customerInfo.subscriptionsByProductIdentifier.values
.filter(\.isActive)
.sorted(by: {
guard let date1 = $0.expiresDate, let date2 = $1.expiresDate else {
return $0.expiresDate != nil
}
return date1 < date2
})
let (activeAppleSubscriptions, otherActiveSubscriptions) = (
activeSubscriptions.filter { $0.store == .appStore },
activeSubscriptions.filter { $0.store != .appStore }
)
let (appleNonSubscriptions, otherNonSubscriptions) = (
customerInfo.nonSubscriptions.filter { $0.store == .appStore },
customerInfo.nonSubscriptions.filter { $0.store != .appStore }
)
return activeAppleSubscriptions.first ??
appleNonSubscriptions.first ??
otherActiveSubscriptions.first ??
otherNonSubscriptions.first
}
func createPurchaseInformation(for transaction: Transaction,
entitlement: EntitlementInfo?,
customerInfo: CustomerInfo) async throws -> PurchaseInformation {
if transaction.store == .appStore {
if let product = await purchasesProvider.products([transaction.productIdentifier]).first {
return await PurchaseInformation.purchaseInformationUsingRenewalInfo(
entitlement: entitlement,
subscribedProduct: product,
transaction: transaction,
customerCenterStoreKitUtilities: customerCenterStoreKitUtilities,
customerInfoRequestedDate: customerInfo.requestDate
)
} else {
Logger.warning(
Strings.could_not_find_product_loading_without_product_information(transaction.productIdentifier)
)
return PurchaseInformation(
entitlement: entitlement,
transaction: transaction,
customerInfoRequestedDate: customerInfo.requestDate
)
}
}
Logger.warning(Strings.active_product_is_not_apple_loading_without_product_information(transaction.store))
return PurchaseInformation(
entitlement: entitlement,
transaction: transaction,
customerInfoRequestedDate: customerInfo.requestDate
)
}
}
fileprivate extension String {
// swiftlint:disable force_unwrapping
/// Takes the first characters of this string, if they conform to Major.Minor.Patch. Returns nil otherwise.
/// Note that Minor and Patch are optional. So if this string starts with a single number, that number is returned.
func versionString() -> String? {
do {
let pattern = #"^(\d+)(?:\.\d+)?(?:\.\d+)?"#
let regex = try NSRegularExpression(pattern: pattern)
let match = regex.firstMatch(in: self, range: NSRange(self.startIndex..., in: self))
return match.map { String(self[Range($0.range, in: self)!]) }
} catch {
return nil
}
}
}
#endif