mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* Remove M1 base image + test with installing simulator runtime on xcode 14.3.1 * Comment out other jobs for testing * Added SnapshotTestingStub Package.swift for Swift 5.8 when running on CircleCI with Xcode 14.3.1 * Re-add rosetta step for iOS 13 simulators * Fix compilation errors targeting iOS 13 because of SKTesting API not being available * Reverting temporary commenting out CircleCI jobs * Removed iOS 13 test support since it was only used for generating snapshots, which failed * Removed M1 specific Mint dependency from CircleCI config * Simplified CircleCI config by removing macos 12.6 executer since it's no longer used * Remove last mention of old macos executor * Removed the validate Package.swift file for Swift 5.7 build step * Removed iOS 14 test from default build and run * Removed support for Swift 5.7 and removed related Swift version checks
637 lines
20 KiB
Swift
637 lines
20 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
|
|
//
|
|
// DebugViewContent.swift
|
|
//
|
|
// Created by Nacho Soto on 5/30/23.
|
|
|
|
#if DEBUG && (os(iOS) || os(macOS) || VISION_OS)
|
|
|
|
import StoreKit
|
|
import SwiftUI
|
|
|
|
#if os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
// swiftlint:disable file_length
|
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
|
struct DebugSwiftUIRootView: View {
|
|
|
|
@StateObject
|
|
private var model = DebugViewModel()
|
|
|
|
@Environment(\.presentationMode)
|
|
private var presentationMode
|
|
|
|
var body: some View {
|
|
NavigationStack(path: self.$model.navigationPath) {
|
|
DebugSummaryView(model: self.model)
|
|
.navigationDestination(for: DebugViewPath.self) { path in
|
|
switch path {
|
|
case let .offering(offering):
|
|
DebugOfferingView(offering: offering)
|
|
|
|
case let .offeringMetadata(offering):
|
|
DebugOfferingMetadataView(offering: offering)
|
|
|
|
case let .package(package):
|
|
DebugPackageView(package: package)
|
|
|
|
case let .paywall(paywall):
|
|
DebugPaywallJSONView(paywall: paywall)
|
|
|
|
case .statusDetails:
|
|
DebugStatusDetailsView(errors: model.errorsToExpandOn)
|
|
}
|
|
}
|
|
.background(
|
|
Rectangle()
|
|
.foregroundStyle(Material.thinMaterial)
|
|
.edgesIgnoringSafeArea(.all)
|
|
)
|
|
#if os(macOS) || targetEnvironment(macCatalyst)
|
|
.toolbar {
|
|
ToolbarItem(placement: .automatic) {
|
|
Button {
|
|
self.presentationMode.wrappedValue.dismiss()
|
|
} label: {
|
|
Label("Close", systemImage: "xmark")
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
.task {
|
|
await self.model.load()
|
|
}
|
|
}
|
|
|
|
static let cornerRadius: CGFloat = 24
|
|
|
|
}
|
|
|
|
private enum DebugViewPath: Hashable {
|
|
|
|
case offering(Offering)
|
|
case offeringMetadata(Offering)
|
|
case package(Package)
|
|
case paywall(PaywallData)
|
|
case statusDetails
|
|
|
|
}
|
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
|
internal struct DebugSummaryView: View {
|
|
|
|
@ObservedObject
|
|
var model: DebugViewModel
|
|
|
|
var body: some View {
|
|
List {
|
|
self.diagnosticsSection
|
|
|
|
self.openAppButton
|
|
|
|
self.configurationSection
|
|
|
|
#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
|
|
self.customerInfoSection
|
|
#endif
|
|
|
|
self.offeringsSection
|
|
}
|
|
#if os(iOS)
|
|
.listStyle(.insetGrouped)
|
|
#endif
|
|
.scrollContentBackground(.hidden)
|
|
.navigationTitle("RevenueCat Debug")
|
|
}
|
|
|
|
private var statusLabel: some View {
|
|
LabeledContent("Status") {
|
|
HStack {
|
|
Text(self.model.diagnosticsStatus)
|
|
self.model.diagnosticsIcon
|
|
}
|
|
}
|
|
}
|
|
|
|
private var diagnosticsSection: some View {
|
|
Section {
|
|
if !self.model.errorsToExpandOn.isEmpty {
|
|
NavigationLink(value: DebugViewPath.statusDetails) {
|
|
statusLabel
|
|
}
|
|
} else {
|
|
statusLabel
|
|
}
|
|
|
|
} header: {
|
|
Text("Diagnostics")
|
|
} footer: {
|
|
if let diagnosticsFooter = self.model.diagnosticsExplainer {
|
|
Text(diagnosticsFooter)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var openAppButton: some View {
|
|
Group {
|
|
if let url = model.diagnosticsActionURL,
|
|
let title = model.diagnosticsActionTitle {
|
|
Link(destination: url) {
|
|
Label(title, systemImage: "arrow.up.forward")
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
private var configurationSection: some View {
|
|
Section("Configuration") {
|
|
switch self.model.configuration {
|
|
case .loading:
|
|
Text("Loading...")
|
|
|
|
case let .loaded(config):
|
|
Group {
|
|
LabeledContent("SDK version", value: config.sdkVersion)
|
|
LabeledContent("Observer mode", value: config.observerMode.description)
|
|
LabeledContent("Sandbox", value: config.sandbox.description)
|
|
LabeledContent("StoreKit 2", value: config.storeKit2Enabled ? "on" : "off")
|
|
LabeledContent("Locale", value: config.locale.display)
|
|
LabeledContent("Offline Customer Info",
|
|
value: config.offlineCustomerInfoSupport ? "enabled" : "disabled")
|
|
LabeledContent("Entitlement Verification Mode", value: config.verificationMode)
|
|
LabeledContent("Receipt URL", value: config.receiptURL?.relativeString ?? "")
|
|
#if os(macOS)
|
|
.contextMenu {
|
|
Button {
|
|
if let url = config.receiptURL {
|
|
NSWorkspace.shared.selectFile(
|
|
nil,
|
|
inFileViewerRootedAtPath: url.deletingLastPathComponent().path
|
|
)
|
|
}
|
|
} label: {
|
|
Text("Show in Finder")
|
|
}
|
|
}
|
|
#endif
|
|
LabeledContent("Receipt status", value: config.receiptStatus)
|
|
|
|
ShareLink(item: AnyEncodable(config), preview: .init("Configuration")) {
|
|
Label("Share", systemImage: "square.and.arrow.up")
|
|
}
|
|
}
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
|
|
#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
|
|
private var customerInfoSection: some View {
|
|
Section("Customer Info") {
|
|
switch self.model.customerInfo {
|
|
case .loading:
|
|
Text("Loading...")
|
|
|
|
case let .loaded(info):
|
|
LabeledContent("User ID", value: self.model.currentAppUserID ?? "")
|
|
LabeledContent("Original User ID", value: info.originalAppUserId)
|
|
LabeledContent("Entitlements", value: info.entitlementDescription)
|
|
|
|
if let latestExpiration = info.latestExpirationDate {
|
|
LabeledContent("Latest Expiration Date",
|
|
value: latestExpiration.formatted(date: .abbreviated,
|
|
time: .omitted))
|
|
}
|
|
|
|
case let .failed(error):
|
|
Text("Error loading customer info: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
@ViewBuilder
|
|
private var offeringsSection: some View {
|
|
Section("Offerings") {
|
|
switch self.model.offerings {
|
|
case .loading:
|
|
Text("Loading...")
|
|
|
|
case let .loaded(offerings):
|
|
ForEach(Array(offerings.all.values)) { offering in
|
|
NavigationLink(value: DebugViewPath.offering(offering)) {
|
|
VStack {
|
|
LabeledContent(
|
|
offering.identifier,
|
|
value: "\(offering.availablePackages.count) package(s)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
case let .failed(error):
|
|
Text("Error loading offerings: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
|
private struct DebugOfferingView: View {
|
|
|
|
@State private var showingSubscriptionSheet = false
|
|
@State private var showingStoreSheet = false
|
|
|
|
var offering: Offering
|
|
|
|
var body: some View {
|
|
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, *) {
|
|
content
|
|
#if swift(>=5.9)
|
|
.onInAppPurchaseCompletion { _, _ in
|
|
self.showingSubscriptionSheet = false
|
|
self.showingStoreSheet = false
|
|
}
|
|
#endif
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
|
|
private var content: some View {
|
|
List {
|
|
Section("Data") {
|
|
LabeledContent("Identifier", value: self.offering.id)
|
|
LabeledContent("Description", value: self.offering.serverDescription)
|
|
|
|
if !self.offering.metadata.isEmpty {
|
|
NavigationLink(value: DebugViewPath.offeringMetadata(self.offering)) {
|
|
Text("Metadata")
|
|
}
|
|
} else {
|
|
LabeledContent("Metadata", value: "{}")
|
|
}
|
|
|
|
if let paywall = self.offering.paywall {
|
|
NavigationLink(value: DebugViewPath.paywall(paywall)) {
|
|
Text("RevenueCatUI paywall")
|
|
}
|
|
} else {
|
|
LabeledContent("RevenueCatUI", value: "No paywall")
|
|
}
|
|
}
|
|
|
|
Section("Packages") {
|
|
ForEach(self.offering.availablePackages) { package in
|
|
NavigationLink(value: DebugViewPath.package(package)) {
|
|
Text(package.identifier)
|
|
}
|
|
}
|
|
}
|
|
|
|
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, *) {
|
|
Section("Paywalls") {
|
|
Button {
|
|
self.showingSubscriptionSheet = true
|
|
} label: {
|
|
Text("Display SubscriptionStoreView")
|
|
}
|
|
|
|
Button {
|
|
self.showingStoreSheet = true
|
|
} label: {
|
|
Text("Display StoreView")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Offering")
|
|
#if swift(>=5.9)
|
|
.sheet(isPresented: self.$showingSubscriptionSheet) {
|
|
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, *) {
|
|
self.subscriptionStoreView
|
|
}
|
|
}
|
|
.sheet(isPresented: self.$showingStoreSheet) {
|
|
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, *) {
|
|
StoreView.forOffering(self.offering)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if swift(>=5.9)
|
|
@available(iOS 17.0, macOS 14.0, tvOS 17.0, *)
|
|
private var subscriptionStoreView: some View {
|
|
SubscriptionStoreView.forOffering(self.offering) {
|
|
VStack {
|
|
VStack {
|
|
Text("🐈")
|
|
Text("RevenueCat Demo Paywall")
|
|
}
|
|
.font(.title)
|
|
|
|
Text(self.offering.getMetadataValue(for: "title", default: "Premium Access"))
|
|
.font(.title2)
|
|
.foregroundStyle(.primary)
|
|
|
|
Text(self.offering.getMetadataValue(for: "subtitle",
|
|
default: "Unlimited access to premium content."))
|
|
.foregroundStyle(.secondary)
|
|
.font(.subheadline)
|
|
}
|
|
#if !VISION_OS
|
|
.containerBackground(for: .subscriptionStoreFullHeight) {
|
|
Rectangle()
|
|
.edgesIgnoringSafeArea(.all)
|
|
.foregroundStyle(Color.blue.gradient.quaternary)
|
|
}
|
|
#endif
|
|
}
|
|
.backgroundStyle(.clear)
|
|
.subscriptionStoreButtonLabel(.multiline)
|
|
.subscriptionStorePickerItemBackground(.thickMaterial)
|
|
}
|
|
#endif
|
|
|
|
}
|
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
|
private struct DebugOfferingMetadataView: View {
|
|
|
|
var offering: Offering
|
|
|
|
var body: some View {
|
|
DebugJSONView(value: AnyEncodable(self.offering.metadata))
|
|
.navigationTitle("Offering Metadata")
|
|
}
|
|
|
|
}
|
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
|
private struct DebugPackageView: View {
|
|
|
|
var package: Package
|
|
|
|
@State private var error: NSError? {
|
|
didSet {
|
|
if self.error != nil {
|
|
self.displayError = true
|
|
}
|
|
}
|
|
}
|
|
|
|
@State private var displayError: Bool = false
|
|
@State private var purchasing: Bool = false
|
|
|
|
var body: some View {
|
|
List {
|
|
Section("Data") {
|
|
LabeledContent("Identifier", value: self.package.identifier)
|
|
LabeledContent("Price", value: self.package.localizedPriceString)
|
|
LabeledContent("Product", value: self.package.storeProduct.productIdentifier)
|
|
LabeledContent("Type", value: self.package.packageType.description ?? "")
|
|
}
|
|
|
|
Section("Purchasing") {
|
|
Button {
|
|
_ = Task<Void, Never> { @MainActor in
|
|
do {
|
|
self.purchasing = true
|
|
try await self.purchase()
|
|
} catch {
|
|
self.error = error as NSError
|
|
}
|
|
|
|
self.purchasing = false
|
|
}
|
|
} label: {
|
|
Text("Purchase with RevenueCat")
|
|
}
|
|
|
|
#if swift(>=5.9)
|
|
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
|
|
ProductView(id: self.package.storeProduct.productIdentifier)
|
|
.productViewStyle(ProductStyle())
|
|
}
|
|
#endif
|
|
}
|
|
.disabled(self.purchasing)
|
|
}
|
|
.navigationTitle("Package")
|
|
.alert(
|
|
"Error",
|
|
isPresented: self.$displayError,
|
|
presenting: self.error
|
|
) { error in
|
|
Text(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func purchase() async throws {
|
|
_ = try await Purchases.shared.purchase(package: self.package)
|
|
}
|
|
|
|
}
|
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
|
private struct DebugStatusDetailsView: View {
|
|
let errors: [PurchasesDiagnostics.SDKHealthError]
|
|
|
|
var body: some View {
|
|
List {
|
|
ForEach(errors, id: \.errorCode) { error in
|
|
switch error {
|
|
case let .invalidProducts(products):
|
|
Section("Product Validation Issues") {
|
|
ForEach(products, id: \.identifier) { product in
|
|
if product.status != .valid {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(alignment: .center, spacing: 8) {
|
|
product.status.icon.imageScale(.small)
|
|
Text(product.identifier)
|
|
}
|
|
Text(product.description)
|
|
.foregroundStyle(.secondary)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
case .noOfferings:
|
|
Section("Offering Validation Issues") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(alignment: .center, spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.yellow)
|
|
Text("No offerings configured")
|
|
}
|
|
Text(
|
|
"""
|
|
While offerings are not mandatory, they are the way to 'offer' products \
|
|
to your customers on your paywalls.
|
|
"""
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
case let .offeringConfiguration(offerings):
|
|
Section("Offering Validation Issues") {
|
|
ForEach(offerings, id: \.identifier) { offering in
|
|
if offering.status != .passed {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(alignment: .center, spacing: 8) {
|
|
offering.status.icon.imageScale(.small)
|
|
Text(offering.identifier)
|
|
}
|
|
Text(
|
|
offering.packages.isEmpty ?
|
|
"Offerings must have at least one package" :
|
|
"""
|
|
Products \(offering.packages.map { "'\($0.productIdentifier)'" }.formatted()) \
|
|
in the offering have issues, check the products list above for more details.
|
|
"""
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
default: EmptyView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
|
private struct DebugPaywallJSONView: View {
|
|
|
|
let paywall: PaywallData
|
|
|
|
var body: some View {
|
|
DebugJSONView(value: AnyEncodable(self.paywall))
|
|
.navigationTitle("RevenueCatUI Paywall")
|
|
}
|
|
|
|
}
|
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
|
private struct DebugJSONView<Value: Encodable & Transferable>: View {
|
|
|
|
let value: Value
|
|
|
|
var body: some View {
|
|
ScrollView(.vertical) {
|
|
Text(self.json)
|
|
.multilineTextAlignment(.leading)
|
|
.font(.caption)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.padding()
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
ShareLink(item: self.value, preview: .init("JSON")) {
|
|
Label("Export", systemImage: "square.and.arrow.up")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var json: String {
|
|
return (try? self.value.prettyPrintedJSON) ?? "{}"
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Locale
|
|
|
|
private extension Locale {
|
|
|
|
var display: String {
|
|
return "\(self.identifier) (\(self.rc_currencyCode ?? "unknown"))"
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Transferable
|
|
|
|
@available(iOS 16.0, macOS 13.0, *)
|
|
extension AnyEncodable: Transferable {
|
|
|
|
static var transferRepresentation: some TransferRepresentation {
|
|
return CodableRepresentation(
|
|
for: Self.self,
|
|
contentType: .plainText,
|
|
encoder: JSONEncoder.prettyPrinted,
|
|
decoder: JSONDecoder.default
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
#if swift(>=5.9)
|
|
@available(iOS 17.0, macOS 14.0, *)
|
|
private struct ProductStyle: ProductViewStyle {
|
|
|
|
func makeBody(configuration: ProductViewStyleConfiguration) -> some View {
|
|
switch configuration.state {
|
|
case .loading:
|
|
ProgressView()
|
|
.progressViewStyle(.circular)
|
|
|
|
case .success:
|
|
Button {
|
|
configuration.purchase()
|
|
} label: {
|
|
Text("Purchase with StoreKit")
|
|
}
|
|
|
|
default:
|
|
ProductView(configuration)
|
|
}
|
|
}
|
|
|
|
}
|
|
#endif
|
|
|
|
#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
|
|
|
|
private extension CustomerInfo {
|
|
|
|
var entitlementDescription: String {
|
|
let total = self.entitlements.all.count
|
|
let active = self.entitlements.active.values
|
|
|
|
let activeList = active.isEmpty
|
|
? ""
|
|
: ": \(active.map(\.identifier).joined(separator: ", "))"
|
|
|
|
return "\(total) total, \(active.count) active\(activeList)"
|
|
}
|
|
|
|
}
|
|
|
|
#endif
|
|
|
|
#endif
|