Files
purchases-ios/Sources/Support/DebugUI/DebugViewModel.swift
T
47f40f5abe [DX-345] Update the testSDKHealth to use the new /health_report endpoint (#4979)
* Initial app creation

* Initial screen

* Installs and configures the SDK

* Creates a full Swift 6 compliant ViewModel

* Adds a demo customer center button

* Fixes icon name

* Updates a bit of the logic to fetch offerings

* Updates initial layout by showing offerings

* Makes more progress with a VERY DRAFT and VERY WIP screen

* Creates a new network path and requests

* Creates a new health API and adds it to the BE class

* Creates a `checkOfferings` method

* Pushes local changes

* Moved viewmodels to separate files

* Prepared some basic UI structure

* Initial shell of the new method for testing

* Initial implementation of `HealthReportOperation` from the SDK

* Passes `appUserId` through to the internal API

* Adds a new path called `appUserID`

* Removes 'old' `appHealth` endpoint

* Creates a new model for the network response

* Report should be the response type

* Updates backend request

* Updates types and documentation for the new internal request

* Moves validation into its own file

* Replaces method for DebugViewModel

* Updates error messages

* Updates the `Status` section

* Adds logic to show failure reason

* Removes Sample App from PR

* Resets `Package.resolved`

* Availability checks

* Check if device is authorized to make payments

* Fixes all SwiftLint errors

* Updates `ProductStatus` to not have a raw value of String

* Minor style fixes

* Removes warning

* Adds more availability checks

* Fixes comments

Co-authored-by: Antonio Pallares <ajpallares@users.noreply.github.com>

* Simplifies logic

* Updates comment

* Updates the comments to denote that this method is not intended for production use

* Removes android-only invalid SDK version error

* Adds an action button that the user can use to open the dashboard

* Updates copy about non-actionable item

* Prefers `Your RevenueCat Configuration` to `The Dashboard`

* Removes `Sendable`-related warnings

* Fixes unit tests on CI/CD

* Fixes tests

* Adds unit test for offering configuration error

* Adds unit test for no packages in default offering

* Adds unit test for generic offering configuration

* Removes periods from the end of the error messages

* Adds unit tests for all new errors

* Fixes linter errors

* Wrap in a `DEBUG` compiler directive

* Includes all products in the healthy check status to be used in the Sample App

* Updates report to include product and offering information

* Moves logic over to the report rather than being exclusive to the Debug View

* Fixes CI/CD failures

* Updates the asset for the health check status

---------

Co-authored-by: Hidde van der Ploeg <hiddevanderploeg@me.com>
Co-authored-by: Antonio Pallares <ajpallares@users.noreply.github.com>
2025-04-24 13:46:21 +00:00

250 lines
7.5 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
//
// DebugViewModel.swift
//
// Created by Nacho Soto on 5/30/23.
import Foundation
#if DEBUG && swift(>=5.8) && (os(iOS) || os(macOS) || VISION_OS)
import SwiftUI
@MainActor
@available(iOS 16.0, macOS 13.0, *)
final class DebugViewModel: ObservableObject {
struct Configuration: Codable {
var sdkVersion: String = SystemInfo.frameworkVersion
var observerMode: Bool
var sandbox: Bool
var storeKit2Enabled: Bool
var locale: Locale
var offlineCustomerInfoSupport: Bool
var verificationMode: String
var receiptURL: URL?
}
var configuration: LoadingState<Configuration, Never> = .loading
@Published
var diagnosticsResult: LoadingState<PurchasesDiagnostics.SDKHealthReport, Never> = .loading
@Published
var offerings: LoadingState<Offerings, NSError> = .loading
#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
@Published
var customerInfo: LoadingState<CustomerInfo, NSError> = .loading
@Published
var currentAppUserID: String?
#endif
// 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
@Published
private var _navigationPath: Any = NavigationPath()
var navigationPath: NavigationPath {
// swiftlint:disable:next force_cast
get { return self._navigationPath as! NavigationPath }
set { self._navigationPath = newValue }
}
@MainActor
func load() async {
self.configuration = .loaded(.create())
self.diagnosticsResult = .loaded(await PurchasesDiagnostics.default.healthReport())
self.offerings = await .create { try await Purchases.shared.offerings() }
#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
self.customerInfo = await .create { try await Purchases.shared.customerInfo() }
self.currentAppUserID = Purchases.shared.appUserID
for await info in Purchases.shared.customerInfoStream {
self.customerInfo = .loaded(info)
}
#endif
}
}
@available(iOS 16.0, macOS 13.0, *)
extension DebugViewModel {
var diagnosticsStatus: String {
switch self.diagnosticsResult {
case .loading: return "Loading..."
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
}
}
}
}
@ViewBuilder
var diagnosticsIcon: some View {
switch self.diagnosticsResult {
case .loading:
Image(systemName: "gear.circle")
.foregroundColor(.gray)
case let .loaded(healthReport):
healthReport.status.icon
}
}
var errorsToExpandOn: [PurchasesDiagnostics.Error] {
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 []
}
}
}
}
}
@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"
}
}
}
}
// 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)
}
}
}
@available(iOS 16.0, macOS 13.0, *)
private extension DebugViewModel.Configuration {
static func create(with purchases: Purchases = .shared) -> Self {
return .init(
observerMode: purchases.observerMode,
sandbox: purchases.isSandbox,
storeKit2Enabled: purchases.isStoreKit2EnabledAndAvailable,
locale: .autoupdatingCurrent,
offlineCustomerInfoSupport: purchases.offlineCustomerInfoEnabled,
verificationMode: purchases.responseVerificationMode.display,
receiptURL: purchases.receiptURL
)
}
}
private extension Signing.ResponseVerificationMode {
var display: String {
switch self {
case .disabled: return "disabled"
case .informational: return "informational"
case .enforced: return "enforced"
}
}
}
#endif