Files
purchases-ios/Tests/TestingApps/PurchaseTesterSwiftUI/Shared/Views/LocalReceiptView.swift
T
Mark VillacampaandNachoSoto 84806ad86e Improve sandbox detector for macOS apps (#3549)
Checking for the presence of `Xcode/DerivedData` in the receipt path was
failing for TestFlight builds or if the .app bundle generated by Xcode
was moved from the folder.

Additionally, checking for `MASReceipt/receipt` seems less robust than
simply using compiler directives.

Instead, for macOS apps, we check the environment embedded in the receipt and alternatively check the signature of the app bundle.

---------

Co-authored-by: NachoSoto <NachoSoto@users.noreply.github.com>
2024-01-09 19:12:47 +01:00

252 lines
6.8 KiB
Swift

//
// LocalReceiptView.swift
// PurchaseTester
//
// Created by Nacho Soto on 1/11/23.
//
import Foundation
import SwiftUI
import RevenueCat
import ReceiptParser
private typealias AppleReceipt = ReceiptParser.AppleReceipt
@MainActor
struct LocalReceiptView: View {
@State
private var receipt: Result<AppleReceipt, Error>?
var body: some View {
VStack {
if #available(iOS 16.0, macCatalyst 16.0, macOS 13.0, watchOS 9.0, *) {
self.form
.scrollContentBackground(.hidden)
} else {
self.form
}
Spacer(minLength: 10)
Button {
Task<Void, Never> {
// There's no public API to refresh the receipt other than this.
// This is the simplest way to force a receipt refresh before fetching it again.
// Ignoring error, since the receipt might have actually been refreshed
// and that's all we care about here.
_ = try? await Purchases.shared.restorePurchases()
await self.refreshReceipt()
}
} label: {
Text("Refresh receipt")
}
.buttonStyle(.borderedProminent)
}
.navigationTitle("Local Receipt")
.task {
await self.refreshReceipt()
}
}
@ViewBuilder
private var form: some View {
Form {
switch self.receipt {
case .none: Text("Loading...")
case let .success(receipt)?: ReceiptDataView(receipt: receipt)
case let .failure(error)?: Text("Error loading receipt: \(error.localizedDescription)")
}
}
}
private func refreshReceipt() async {
self.receipt = await Self.loadReceipt()
}
private static func loadReceipt() async -> Result<ReceiptParser.AppleReceipt, Error> {
return await Task
.detached {
Result(
catching: { try ReceiptParser.PurchasesReceiptParser.default.fetchAndParseLocalReceipt() }
)
}
.value
}
}
private struct ReceiptDataView: View {
private let values: [DataView.Value]
private let purchases: [AppleReceipt.InAppPurchase]
init(receipt: AppleReceipt) {
self.init(
values: [
.init("Environment", receipt.environment.rawValue),
.init("Bundle", receipt.bundleId),
.init("App Version", receipt.applicationVersion),
.init("Creation", receipt.creationDate.formatted()),
.init("Hash", receipt.sha1Hash.base64EncodedString())
],
purchases: receipt.inAppPurchases
)
}
fileprivate init(values: [DataView.Value], purchases: [AppleReceipt.InAppPurchase]) {
self.values = values
self.purchases = purchases
}
var body: some View {
Section(header: Text("Receipt Data")) {
ForEach(self.values) { value in
DataView(value: value)
}
}
Section(header: Text("Purchases")) {
if self.purchases.isEmpty {
Text("No purchases found in this receipt.")
} else {
PurchasesView(purchases: self.purchases)
}
}
}
}
private struct PurchasesView: View {
private struct Item: Identifiable {
let index: Int
let purchase: AppleReceipt.InAppPurchase
var id: String { return "\(self.index)-\(self.purchase.transactionId)" }
}
let purchases: [AppleReceipt.InAppPurchase]
var body: some View {
List(self.purchases.enumerated().map(Item.init)) {
PurchaseView(purchase: $0.purchase)
}
}
private struct PurchaseView: View {
let purchase: AppleReceipt.InAppPurchase
var body: some View {
VStack {
Text(self.purchase.productId)
.font(.title3.bold())
.minimumScaleFactor(0.3)
.lineLimit(1)
.padding(.bottom, 5)
DataView(name: "Transaction", display: self.purchase.transactionId)
.font(.caption2)
DataView(name: "Product Type", display: self.purchase.productType.display)
DataView(name: "Purchase Date", display: self.purchase.purchaseDate.formatted())
if let expiration = self.purchase.expiresDate {
DataView(name: "Expiration", display: expiration.formatted())
}
if let cancellation = self.purchase.cancellationDate {
DataView(name: "Cancellation", display: cancellation.formatted())
}
if let promoOffer = self.purchase.promotionalOfferIdentifier {
DataView(name: "Promo Offer", display: promoOffer)
}
DataView(
name: "In Trial Period",
display: self.purchase.isInTrialPeriod?.description ?? "unknown"
)
DataView(
name: "In Intro Offer Period",
display: self.purchase.isInIntroOfferPeriod?.description ?? "unknown"
)
}
}
}
}
private struct DataView: View {
struct Value: Equatable, Identifiable {
let name: String
let display: String
init(_ name: String, _ display: String) {
self.name = name
self.display = display
}
var id: String { return self.name }
}
let value: Value
init(value: Value) { self.value = value }
init(name: String, display: String) { self.init(value: .init(name, display)) }
var body: some View {
HStack {
Text(self.value.name)
.font(.headline)
Spacer()
Text(self.value.display)
.font(.body)
}
}
}
private extension AppleReceipt.InAppPurchase.ProductType {
var display: String {
switch self {
case .nonConsumable: return "Non Consumable"
case .consumable: return "Consumable"
case .nonRenewingSubscription: return "Non-renewing Subscription"
case .autoRenewableSubscription: return "Auto-renewable Subscription"
case .unknown: fallthrough
@unknown default: return "unknown"
}
}
}
#if DEBUG
struct LocalReceiptView_Previews: PreviewProvider {
static var previews: some View {
ReceiptDataView(
values: [
.init("Bundle", "com.revenuecat.purchasetester"),
.init("App Version", "1.0"),
.init("Creation", Date().formatted())
],
purchases: []
)
}
}
#endif