2023-06-07 09:16:45 -07: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
|
|
|
|
|
//
|
|
|
|
|
// DebugViewModel.swift
|
|
|
|
|
//
|
|
|
|
|
// Created by Nacho Soto on 5/30/23.
|
|
|
|
|
|
|
|
|
|
import Foundation
|
|
|
|
|
|
2026-01-28 08:21:24 +01:00
|
|
|
#if DEBUG && (os(iOS) || os(macOS) || VISION_OS)
|
2023-06-07 09:16:45 -07:00
|
|
|
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
@MainActor
|
2023-06-15 08:10:03 -07:00
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
2023-06-07 09:16:45 -07:00
|
|
|
final class DebugViewModel: ObservableObject {
|
|
|
|
|
|
2023-06-08 08:37:22 -07:00
|
|
|
struct Configuration: Codable {
|
2023-06-07 09:16:45 -07:00
|
|
|
|
2023-06-08 08:37:22 -07:00
|
|
|
var sdkVersion: String = SystemInfo.frameworkVersion
|
|
|
|
|
var observerMode: Bool
|
|
|
|
|
var sandbox: Bool
|
|
|
|
|
var storeKit2Enabled: Bool
|
2023-12-21 12:19:54 -08:00
|
|
|
var locale: Locale
|
2023-06-08 08:37:22 -07:00
|
|
|
var offlineCustomerInfoSupport: Bool
|
|
|
|
|
var verificationMode: String
|
2023-06-15 08:37:58 -07:00
|
|
|
var receiptURL: URL?
|
2023-06-07 09:16:45 -07:00
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var configuration: LoadingState<Configuration, Never> = .loading
|
|
|
|
|
|
|
|
|
|
@Published
|
2025-04-24 14:46:21 +01:00
|
|
|
var diagnosticsResult: LoadingState<PurchasesDiagnostics.SDKHealthReport, Never> = .loading
|
2023-06-07 09:16:45 -07:00
|
|
|
@Published
|
|
|
|
|
var offerings: LoadingState<Offerings, NSError> = .loading
|
|
|
|
|
#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
|
|
|
|
|
@Published
|
|
|
|
|
var customerInfo: LoadingState<CustomerInfo, NSError> = .loading
|
2023-06-13 08:54:36 -07:00
|
|
|
@Published
|
|
|
|
|
var currentAppUserID: String?
|
2023-06-07 09:16:45 -07:00
|
|
|
#endif
|
|
|
|
|
|
2023-09-05 10:12:12 -07:00
|
|
|
// We can't directly store instances of `NavigationPath`, since that causes runtime crashes when
|
|
|
|
|
// loading this type in iOS <= 15, even with @available checks correctly in place.
|
|
|
|
|
// See https://openradar.appspot.com/radar?id=4970535809187840 / https://github.com/apple/swift/issues/58099
|
2023-06-07 09:16:45 -07:00
|
|
|
@Published
|
2023-09-05 10:12:12 -07:00
|
|
|
private var _navigationPath: Any = NavigationPath()
|
|
|
|
|
|
|
|
|
|
var navigationPath: NavigationPath {
|
|
|
|
|
// swiftlint:disable:next force_cast
|
|
|
|
|
get { return self._navigationPath as! NavigationPath }
|
|
|
|
|
set { self._navigationPath = newValue }
|
|
|
|
|
}
|
2023-06-07 09:16:45 -07:00
|
|
|
|
2024-01-25 15:29:31 -08:00
|
|
|
@MainActor
|
2023-06-07 09:16:45 -07:00
|
|
|
func load() async {
|
|
|
|
|
self.configuration = .loaded(.create())
|
2025-07-07 17:00:54 +01:00
|
|
|
#if DEBUG && !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
|
2025-04-24 14:46:21 +01:00
|
|
|
self.diagnosticsResult = .loaded(await PurchasesDiagnostics.default.healthReport())
|
2025-07-07 17:00:54 +01:00
|
|
|
#endif
|
2023-06-07 09:16:45 -07:00
|
|
|
self.offerings = await .create { try await Purchases.shared.offerings() }
|
|
|
|
|
#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
|
|
|
|
|
self.customerInfo = await .create { try await Purchases.shared.customerInfo() }
|
2023-06-13 08:54:36 -07:00
|
|
|
self.currentAppUserID = Purchases.shared.appUserID
|
2023-06-07 09:16:45 -07:00
|
|
|
|
2024-02-06 14:11:16 -08:00
|
|
|
for await info in Purchases.shared.customerInfoStream {
|
|
|
|
|
self.customerInfo = .loaded(info)
|
|
|
|
|
}
|
2023-06-07 09:16:45 -07:00
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-15 08:10:03 -07:00
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
2023-06-07 09:16:45 -07:00
|
|
|
extension DebugViewModel {
|
|
|
|
|
|
|
|
|
|
var diagnosticsStatus: String {
|
|
|
|
|
switch self.diagnosticsResult {
|
|
|
|
|
case .loading: return "Loading..."
|
2025-04-24 14:46:21 +01:00
|
|
|
case let .loaded(healthReport):
|
|
|
|
|
switch healthReport.status {
|
|
|
|
|
case .healthy: return "Configuration OK"
|
|
|
|
|
case .unhealthy: return "Invalid Configuration"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var diagnosticsExplainer: String? {
|
|
|
|
|
switch self.diagnosticsResult {
|
|
|
|
|
case let .loaded(healthReport):
|
|
|
|
|
switch healthReport.status {
|
|
|
|
|
case let .healthy(warnings):
|
|
|
|
|
return warnings.count > 0 ? """
|
|
|
|
|
Your RevenueCat configuration is valid, however we encountered some potential issues \
|
|
|
|
|
during validation. Feel free to ignore them if your configuration works as expected.
|
|
|
|
|
"""
|
|
|
|
|
: nil
|
|
|
|
|
case .unhealthy(let error): return error.localizedDescription
|
|
|
|
|
}
|
|
|
|
|
default: return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var diagnosticsActionURL: URL? {
|
|
|
|
|
switch self.diagnosticsResult {
|
|
|
|
|
case let .loaded(healthReport):
|
|
|
|
|
guard let appId = healthReport.appId, let projectId = healthReport.projectId else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
switch healthReport.status {
|
|
|
|
|
case .healthy: return nil
|
|
|
|
|
case .unhealthy(let error):
|
|
|
|
|
switch error {
|
|
|
|
|
case .offeringConfiguration:
|
|
|
|
|
return URL(string: "https://app.revenuecat.com/projects/\(projectId)/offerings")
|
|
|
|
|
case .invalidBundleId:
|
|
|
|
|
return URL(string: "https://app.revenuecat.com/projects/\(projectId)/apps/\(appId)")
|
|
|
|
|
case .invalidProducts:
|
|
|
|
|
return URL(string: "https://app.revenuecat.com/projects/\(projectId)/products")
|
|
|
|
|
default: return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
case .loading, .failed: return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var diagnosticsActionTitle: String? {
|
|
|
|
|
switch self.diagnosticsResult {
|
|
|
|
|
case .loading, .failed: return nil
|
|
|
|
|
case let .loaded(healthReport):
|
|
|
|
|
switch healthReport.status {
|
|
|
|
|
case .healthy: return nil
|
|
|
|
|
case .unhealthy(let error):
|
|
|
|
|
switch error {
|
|
|
|
|
case .offeringConfiguration:
|
|
|
|
|
return "Open Offerings"
|
|
|
|
|
case .invalidBundleId:
|
|
|
|
|
return "Open App Configuration"
|
|
|
|
|
case .invalidProducts:
|
|
|
|
|
return "Open Products"
|
|
|
|
|
default: return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-06-07 09:16:45 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ViewBuilder
|
|
|
|
|
var diagnosticsIcon: some View {
|
|
|
|
|
switch self.diagnosticsResult {
|
|
|
|
|
case .loading:
|
|
|
|
|
Image(systemName: "gear.circle")
|
|
|
|
|
.foregroundColor(.gray)
|
2025-04-24 14:46:21 +01:00
|
|
|
case let .loaded(healthReport):
|
|
|
|
|
healthReport.status.icon
|
2023-06-07 09:16:45 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-28 18:26:06 +01:00
|
|
|
var errorsToExpandOn: [PurchasesDiagnostics.SDKHealthError] {
|
2025-04-24 14:46:21 +01:00
|
|
|
switch self.diagnosticsResult {
|
|
|
|
|
case .loading: return []
|
|
|
|
|
case let .loaded(healthReport):
|
|
|
|
|
switch healthReport.status {
|
|
|
|
|
case let .healthy(warnings): return warnings
|
|
|
|
|
case let .unhealthy(error):
|
|
|
|
|
switch error {
|
|
|
|
|
case .invalidProducts, .offeringConfiguration: return [error]
|
|
|
|
|
default: return []
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-06-07 09:16:45 -07:00
|
|
|
}
|
|
|
|
|
|
2023-10-16 07:34:34 -07:00
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
|
|
|
|
extension DebugViewModel.Configuration {
|
|
|
|
|
|
|
|
|
|
var receiptStatus: String {
|
|
|
|
|
switch self.receiptURL {
|
|
|
|
|
case .none:
|
|
|
|
|
return "no URL"
|
|
|
|
|
case let .some(url):
|
|
|
|
|
if FileManager.default.fileExists(atPath: url.relativePath) {
|
|
|
|
|
return "present"
|
|
|
|
|
} else {
|
|
|
|
|
return "missing"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-07 09:16:45 -07:00
|
|
|
// MARK: -
|
|
|
|
|
|
|
|
|
|
enum LoadingState<Value, Error: Swift.Error> {
|
|
|
|
|
|
|
|
|
|
case loading
|
|
|
|
|
case loaded(Value)
|
|
|
|
|
case failed(Error)
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension LoadingState where Error == NSError {
|
|
|
|
|
|
|
|
|
|
static func create(_ loader: @Sendable () async throws -> Value) async -> Self {
|
|
|
|
|
do {
|
|
|
|
|
return .loaded(try await loader())
|
|
|
|
|
} catch {
|
|
|
|
|
return .failed(error as NSError)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-15 08:10:03 -07:00
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
2023-06-07 09:16:45 -07:00
|
|
|
private extension DebugViewModel.Configuration {
|
|
|
|
|
|
|
|
|
|
static func create(with purchases: Purchases = .shared) -> Self {
|
|
|
|
|
return .init(
|
|
|
|
|
observerMode: purchases.observerMode,
|
|
|
|
|
sandbox: purchases.isSandbox,
|
2024-07-01 12:37:21 -07:00
|
|
|
storeKit2Enabled: purchases.isStoreKit2EnabledAndAvailable,
|
2023-12-21 12:19:54 -08:00
|
|
|
locale: .autoupdatingCurrent,
|
2023-06-07 09:16:45 -07:00
|
|
|
offlineCustomerInfoSupport: purchases.offlineCustomerInfoEnabled,
|
2023-06-15 08:37:58 -07:00
|
|
|
verificationMode: purchases.responseVerificationMode.display,
|
|
|
|
|
receiptURL: purchases.receiptURL
|
2023-06-07 09:16:45 -07:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-08 08:37:22 -07:00
|
|
|
private extension Signing.ResponseVerificationMode {
|
|
|
|
|
|
|
|
|
|
var display: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .disabled: return "disabled"
|
|
|
|
|
case .informational: return "informational"
|
|
|
|
|
case .enforced: return "enforced"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-07 09:16:45 -07:00
|
|
|
#endif
|