2024-08-09 15:26:55 +02:00
|
|
|
//
|
|
|
|
|
// 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 {
|
2024-12-11 20:55:22 +01:00
|
|
|
|
2024-08-16 12:02:21 +02:00
|
|
|
private static let defaultAppIsLatestVersion = true
|
2024-08-09 15:26:55 +02:00
|
|
|
|
2024-08-16 12:02:21 +02:00
|
|
|
typealias CurrentVersionFetcher = () -> String?
|
|
|
|
|
|
|
|
|
|
private lazy var currentAppVersion: String? = currentVersionFetcher()
|
2025-02-07 11:14:37 +01:00
|
|
|
|
2024-08-09 15:26:55 +02:00
|
|
|
@Published
|
2024-12-11 20:55:22 +01:00
|
|
|
private(set) var purchaseInformation: PurchaseInformation?
|
2025-02-07 11:14:37 +01:00
|
|
|
|
2024-08-16 12:02:21 +02:00
|
|
|
@Published
|
|
|
|
|
private(set) var appIsLatestVersion: Bool = defaultAppIsLatestVersion
|
2024-08-09 15:26:55 +02:00
|
|
|
|
2024-12-11 15:16:15 -06:00
|
|
|
@Published
|
|
|
|
|
private(set) var onUpdateAppClick: (() -> Void)?
|
|
|
|
|
|
2025-02-07 11:14:37 +01:00
|
|
|
private(set) var purchasesProvider: CustomerCenterPurchasesType
|
|
|
|
|
private(set) var customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType
|
|
|
|
|
|
2024-12-11 15:16:15 -06:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-09 15:26:55 +02:00
|
|
|
// @PublicForExternalTesting
|
|
|
|
|
@Published
|
|
|
|
|
var state: CustomerCenterViewState {
|
|
|
|
|
didSet {
|
|
|
|
|
if case let .error(stateError) = state {
|
|
|
|
|
self.error = stateError
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@Published
|
2024-08-16 12:02:21 +02:00
|
|
|
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
|
2024-12-11 20:55:22 +01:00
|
|
|
}
|
2024-08-16 12:02:21 +02:00
|
|
|
}
|
2024-08-09 15:26:55 +02:00
|
|
|
|
2024-08-16 12:02:21 +02:00
|
|
|
private let currentVersionFetcher: CurrentVersionFetcher
|
2024-08-09 15:26:55 +02:00
|
|
|
internal let customerCenterActionHandler: CustomerCenterActionHandler?
|
|
|
|
|
|
|
|
|
|
private var error: Error?
|
2025-02-12 16:54:29 +01:00
|
|
|
private var impressionData: CustomerCenterEvent.Data?
|
2024-08-09 15:26:55 +02:00
|
|
|
|
2024-08-16 12:02:21 +02:00
|
|
|
init(
|
|
|
|
|
customerCenterActionHandler: CustomerCenterActionHandler?,
|
|
|
|
|
currentVersionFetcher: @escaping CurrentVersionFetcher = {
|
|
|
|
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
2024-12-02 15:43:53 +01:00
|
|
|
},
|
2024-12-20 07:50:30 -06:00
|
|
|
purchasesProvider: CustomerCenterPurchasesType = CustomerCenterPurchases(),
|
|
|
|
|
customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType = CustomerCenterStoreKitUtilities()
|
2024-08-16 12:02:21 +02:00
|
|
|
) {
|
2024-08-09 15:26:55 +02:00
|
|
|
self.state = .notLoaded
|
2024-08-16 12:02:21 +02:00
|
|
|
self.currentVersionFetcher = currentVersionFetcher
|
2024-08-09 15:26:55 +02:00
|
|
|
self.customerCenterActionHandler = customerCenterActionHandler
|
2024-12-02 15:43:53 +01:00
|
|
|
self.purchasesProvider = purchasesProvider
|
2024-12-20 07:50:30 -06:00
|
|
|
self.customerCenterStoreKitUtilities = customerCenterStoreKitUtilities
|
2024-08-09 15:26:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#if DEBUG
|
|
|
|
|
|
2024-08-16 12:02:21 +02:00
|
|
|
convenience init(
|
2024-12-11 20:55:22 +01:00
|
|
|
purchaseInformation: PurchaseInformation,
|
|
|
|
|
configuration: CustomerCenterConfigData
|
2024-08-16 12:02:21 +02:00
|
|
|
) {
|
|
|
|
|
self.init(customerCenterActionHandler: nil)
|
2024-12-11 20:55:22 +01:00
|
|
|
self.purchaseInformation = purchaseInformation
|
|
|
|
|
self.configuration = configuration
|
2024-08-09 15:26:55 +02:00
|
|
|
self.state = .success
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endif
|
|
|
|
|
|
2024-12-12 12:36:19 +01:00
|
|
|
func loadScreen() async {
|
2024-08-09 15:26:55 +02:00
|
|
|
do {
|
2024-12-12 12:36:19 +01:00
|
|
|
try await self.loadPurchaseInformation()
|
|
|
|
|
try await self.loadCustomerCenterConfig()
|
2024-08-09 15:26:55 +02:00
|
|
|
self.state = .success
|
|
|
|
|
} catch {
|
|
|
|
|
self.state = .error(error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func performRestore() async -> RestorePurchasesAlert.AlertType {
|
|
|
|
|
self.customerCenterActionHandler?(.restoreStarted)
|
|
|
|
|
do {
|
2024-12-12 12:36:19 +01:00
|
|
|
let customerInfo = try await purchasesProvider.restorePurchases()
|
2024-08-09 15:26:55 +02:00
|
|
|
self.customerCenterActionHandler?(.restoreCompleted(customerInfo))
|
2024-12-12 04:45:22 -06:00
|
|
|
let hasPurchases = !customerInfo.activeSubscriptions.isEmpty || !customerInfo.nonSubscriptions.isEmpty
|
|
|
|
|
return hasPurchases ? .purchasesRecovered : .purchasesNotFound
|
2024-08-09 15:26:55 +02:00
|
|
|
} catch {
|
|
|
|
|
self.customerCenterActionHandler?(.restoreFailed(error))
|
|
|
|
|
return .purchasesNotFound
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-08-27 10:07:23 +02:00
|
|
|
|
2024-12-02 15:43:53 +01:00
|
|
|
func trackImpression(darkMode: Bool, displayMode: CustomerCenterPresentationMode) {
|
2025-02-12 16:54:29 +01:00
|
|
|
guard impressionData == nil else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-02 15:43:53 +01:00
|
|
|
let eventData = CustomerCenterEvent.Data(locale: .current,
|
|
|
|
|
darkMode: darkMode,
|
2025-02-12 16:54:29 +01:00
|
|
|
isSandbox: purchasesProvider.isSandbox,
|
2024-12-02 15:43:53 +01:00
|
|
|
displayMode: displayMode)
|
2025-02-12 16:54:29 +01:00
|
|
|
defer { self.impressionData = eventData }
|
2024-12-02 15:43:53 +01:00
|
|
|
|
2025-02-12 16:54:29 +01:00
|
|
|
let event = CustomerCenterEvent.impression(CustomerCenterEventCreationData(), eventData)
|
2024-12-02 15:43:53 +01:00
|
|
|
purchasesProvider.track(customerCenterEvent: event)
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-16 12:02:21 +02:00
|
|
|
}
|
2024-08-09 15:26:55 +02:00
|
|
|
|
2024-12-11 20:55:22 +01:00
|
|
|
@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 {
|
|
|
|
|
|
2024-12-12 12:36:19 +01:00
|
|
|
func loadPurchaseInformation() async throws {
|
2025-02-07 11:14:37 +01:00
|
|
|
let customerInfo = try await purchasesProvider.customerInfo(fetchPolicy: .fetchCurrent)
|
2024-12-12 12:36:19 +01:00
|
|
|
|
2025-02-07 11:14:37 +01:00
|
|
|
let hasActiveProducts = !customerInfo.activeSubscriptions.isEmpty ||
|
|
|
|
|
!customerInfo.nonSubscriptions.isEmpty
|
2024-12-12 12:36:19 +01:00
|
|
|
|
2025-02-07 11:14:37 +01:00
|
|
|
if !hasActiveProducts {
|
|
|
|
|
self.purchaseInformation = nil
|
|
|
|
|
self.state = .success
|
|
|
|
|
return
|
|
|
|
|
}
|
2024-12-12 12:36:19 +01:00
|
|
|
|
2025-02-07 11:14:37 +01:00
|
|
|
guard let activeTransaction = findActiveTransaction(customerInfo: customerInfo) else {
|
|
|
|
|
Logger.warning(Strings.could_not_find_subscription_information)
|
|
|
|
|
self.purchaseInformation = nil
|
|
|
|
|
throw CustomerCenterError.couldNotFindSubscriptionInformation
|
|
|
|
|
}
|
2024-12-12 12:36:19 +01:00
|
|
|
|
2025-02-07 11:14:37 +01:00
|
|
|
let entitlement = customerInfo.entitlements.all.values
|
|
|
|
|
.first(where: { $0.productIdentifier == activeTransaction.productIdentifier })
|
2024-12-12 12:36:19 +01:00
|
|
|
|
2025-02-25 13:13:49 +01:00
|
|
|
self.purchaseInformation = try await createPurchaseInformation(
|
|
|
|
|
for: activeTransaction,
|
|
|
|
|
entitlement: entitlement,
|
|
|
|
|
customerInfo: customerInfo)
|
2024-12-12 12:36:19 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loadCustomerCenterConfig() async throws {
|
|
|
|
|
self.configuration = try await purchasesProvider.loadCustomerCenter()
|
2025-02-26 12:40:49 +01:00
|
|
|
if let productId = configuration?.productId,
|
|
|
|
|
let url = URL(string: "https://itunes.apple.com/app/id\(productId)") {
|
2024-12-12 12:36:19 +01:00
|
|
|
self.onUpdateAppClick = {
|
|
|
|
|
// productId is a positive integer, so it should be safe to construct a URL from it.
|
|
|
|
|
URLUtilities.openURLIfNotAppExtension(url)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-11 20:55:22 +01:00
|
|
|
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,
|
2025-02-25 13:13:49 +01:00
|
|
|
entitlement: EntitlementInfo?,
|
|
|
|
|
customerInfo: CustomerInfo) async throws -> PurchaseInformation {
|
2024-12-11 20:55:22 +01:00
|
|
|
if transaction.store == .appStore {
|
2024-12-17 16:05:59 +01:00
|
|
|
if let product = await purchasesProvider.products([transaction.productIdentifier]).first {
|
2024-12-20 07:50:30 -06:00
|
|
|
return await PurchaseInformation.purchaseInformationUsingRenewalInfo(
|
2024-12-17 16:05:59 +01:00
|
|
|
entitlement: entitlement,
|
|
|
|
|
subscribedProduct: product,
|
2024-12-20 07:50:30 -06:00
|
|
|
transaction: transaction,
|
2025-02-25 13:13:49 +01:00
|
|
|
customerCenterStoreKitUtilities: customerCenterStoreKitUtilities,
|
|
|
|
|
customerInfoRequestedDate: customerInfo.requestDate
|
2024-12-17 16:05:59 +01:00
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
Logger.warning(
|
|
|
|
|
Strings.could_not_find_product_loading_without_product_information(transaction.productIdentifier)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return PurchaseInformation(
|
|
|
|
|
entitlement: entitlement,
|
2025-02-25 13:13:49 +01:00
|
|
|
transaction: transaction,
|
|
|
|
|
customerInfoRequestedDate: customerInfo.requestDate
|
2024-12-17 16:05:59 +01:00
|
|
|
)
|
2024-12-11 20:55:22 +01:00
|
|
|
}
|
|
|
|
|
}
|
2024-12-17 16:05:59 +01:00
|
|
|
Logger.warning(Strings.active_product_is_not_apple_loading_without_product_information(transaction.store))
|
|
|
|
|
|
|
|
|
|
return PurchaseInformation(
|
|
|
|
|
entitlement: entitlement,
|
2025-02-25 13:13:49 +01:00
|
|
|
transaction: transaction,
|
|
|
|
|
customerInfoRequestedDate: customerInfo.requestDate
|
2024-12-17 16:05:59 +01:00
|
|
|
)
|
2024-12-11 20:55:22 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-16 12:02:21 +02:00
|
|
|
fileprivate extension String {
|
2025-02-26 12:40:49 +01:00
|
|
|
// swiftlint:disable force_unwrapping
|
|
|
|
|
|
2024-08-16 12:02:21 +02:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-08-09 15:26:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endif
|