Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Call AppTransaction.refresh() in certain scenarios if AppTransaction.shared is invalid #4099

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions Sources/Purchasing/Purchases/PurchasesOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,14 @@ final class PurchasesOrchestrator {
}

func restorePurchases(completion: (@Sendable (Result<CustomerInfo, PurchasesError>) -> Void)?) {
self.syncPurchases(receiptRefreshPolicy: .always,
self.syncPurchases(receiptRefreshAllowed: true,
isRestore: true,
initiationSource: .restore,
completion: completion)
}

func syncPurchases(completion: (@Sendable (Result<CustomerInfo, PurchasesError>) -> Void)? = nil) {
self.syncPurchases(receiptRefreshPolicy: .never,
self.syncPurchases(receiptRefreshAllowed: false,
isRestore: allowSharingAppStoreAccount,
initiationSource: .restore,
completion: completion)
Expand Down Expand Up @@ -1022,7 +1022,7 @@ private extension PurchasesOrchestrator {
}
}

func syncPurchases(receiptRefreshPolicy: ReceiptRefreshPolicy,
func syncPurchases(receiptRefreshAllowed: Bool,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since ReceiptRefreshPolicy is very tied to SK1, I changed the parameter to a more SK-agnostic one and decide in the method body what policy to use for each SK version.

isRestore: Bool,
initiationSource: ProductRequestData.InitiationSource,
completion: (@Sendable (Result<CustomerInfo, PurchasesError>) -> Void)?) {
Expand All @@ -1034,11 +1034,12 @@ private extension PurchasesOrchestrator {

if self.systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable,
#available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) {
self.syncPurchasesSK2(isRestore: isRestore,
self.syncPurchasesSK2(refreshPolicy: receiptRefreshAllowed ? .onlyIfEmpty : .never,
isRestore: isRestore,
initiationSource: initiationSource,
completion: completion)
} else {
self.syncPurchasesSK1(receiptRefreshPolicy: receiptRefreshPolicy,
self.syncPurchasesSK1(receiptRefreshPolicy: receiptRefreshAllowed ? .always : .never,
isRestore: isRestore,
initiationSource: initiationSource,
completion: completion)
Expand Down Expand Up @@ -1114,7 +1115,8 @@ private extension PurchasesOrchestrator {

// swiftlint:disable function_body_length
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
private func syncPurchasesSK2(isRestore: Bool,
private func syncPurchasesSK2(refreshPolicy: AppTransactionRefreshPolicy,
isRestore: Bool,
initiationSource: ProductRequestData.InitiationSource,
completion: (@Sendable (Result<CustomerInfo, PurchasesError>) -> Void)?) {
let currentAppUserID = self.appUserID
Expand All @@ -1123,7 +1125,6 @@ private extension PurchasesOrchestrator {
self.attribution.unsyncedAdServicesToken { adServicesToken in
_ = Task<Void, Never> {
let transaction = await self.transactionFetcher.firstVerifiedTransaction
let appTransactionJWS = await self.transactionFetcher.appTransactionJWS

guard let transaction = transaction, let jwsRepresentation = transaction.jwsRepresentation else {
// No transactions are present. If we have the originalPurchaseDate and originalApplicationVersion
Expand All @@ -1139,6 +1140,9 @@ private extension PurchasesOrchestrator {
return
}

let appTransactionJWS = await self.transactionFetcher.appTransactionJWS(
refreshPolicy: refreshPolicy)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved fetching the AppTransaction to after the check to see if we have transactions, originalPurchaseDate and originalApplicationVersion.

This way, if we don't need to post the receipt, we wouldn't be fetching the AppTransaction unnecessarily, potentially showing the authentication prompt.


self.backend.post(receipt: .empty,
productData: nil,
transactionData: .init(appUserID: currentAppUserID,
Expand All @@ -1157,6 +1161,7 @@ private extension PurchasesOrchestrator {
}

let receipt = await self.encodedReceipt(transaction: transaction, jwsRepresentation: jwsRepresentation)
let appTransactionJWS = await self.transactionFetcher.appTransactionJWS(refreshPolicy: refreshPolicy)

self.createProductRequestData(with: transaction.productIdentifier) { productRequestData in
let transactionData: PurchasedTransactionData = .init(
Expand Down Expand Up @@ -1511,11 +1516,12 @@ extension PurchasesOrchestrator {
.get()
}

func syncPurchases(receiptRefreshPolicy: ReceiptRefreshPolicy,
// Only used internally in tests
func syncPurchases(receiptRefreshAllowed: Bool,
isRestore: Bool,
initiationSource: ProductRequestData.InitiationSource) async throws -> CustomerInfo {
return try await Async.call { completion in
self.syncPurchases(receiptRefreshPolicy: receiptRefreshPolicy,
self.syncPurchases(receiptRefreshAllowed: receiptRefreshAllowed,
isRestore: isRestore,
initiationSource: initiationSource,
completion: completion)
Expand Down
6 changes: 5 additions & 1 deletion Sources/Purchasing/Purchases/TransactionPoster.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ final class TransactionPoster: TransactionPosterType {
switch result {
case .success(let encodedReceipt):
self.product(with: productIdentifier) { product in
self.transactionFetcher.appTransactionJWS { appTransaction in
// Only allow refreshing the AppTransaction if the source was a user action
// (e.g. purchase) to prevent showing a login prompt unnecessarily
let refreshPolicy: AppTransactionRefreshPolicy =
data.source.initiationSource == .purchase ? .onlyIfEmpty : .never
self.transactionFetcher.appTransactionJWS(refreshPolicy: refreshPolicy) { appTransaction in
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We dont want to potentially show the login prompt for transactions coming from the queue

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good, but let's also add that explicitly to the comment, i.e.:

// Only allow refreshing the AppTransaction if the source was a user action (e.g. purchase)
// This is to prevent showing a login prompt unnecessarily
let refreshPolicy: AppTransactionRefreshPolicy =

self.postReceipt(transaction: transaction,
purchasedTransactionData: data,
receipt: encodedReceipt,
Expand Down
4 changes: 3 additions & 1 deletion Sources/Purchasing/StoreKit2/SK2AppTransaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import StoreKit
internal struct SK2AppTransaction {

@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
init(appTransaction: AppTransaction) {
init(appTransaction: AppTransaction, jwsRepresentation: String) {
self.bundleId = appTransaction.bundleID
self.originalApplicationVersion = appTransaction.originalAppVersion
self.originalPurchaseDate = appTransaction.originalPurchaseDate
self.environment = .init(environment: appTransaction.environment)
self.jwsRepresentation = jwsRepresentation
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is similar to what we already so with the SK2StoreTransaction. The JWS token is part of the VerificationResult, not the AppTansaction, but we want to preserve it.

}

let jwsRepresentation: String
let bundleId: String
let originalApplicationVersion: String?
let originalPurchaseDate: Date?
Expand Down
73 changes: 51 additions & 22 deletions Sources/Purchasing/StoreKit2/StoreKit2TransactionFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@
import Foundation
import StoreKit

/// Determines the behavior when fetching the AppTransaction
enum AppTransactionRefreshPolicy {

// Calls refresh() only if AppTransaction.shared returns empty
case onlyIfEmpty
// Never calls refresh()
case never

}

protocol StoreKit2TransactionFetcherType: Sendable {

@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
Expand All @@ -31,9 +41,9 @@ protocol StoreKit2TransactionFetcherType: Sendable {
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
var firstVerifiedTransaction: StoreTransaction? { get async }

var appTransactionJWS: String? { get async }
func appTransactionJWS(refreshPolicy: AppTransactionRefreshPolicy) async -> String?

func appTransactionJWS(_ completionHandler: @escaping (String?) -> Void)
func appTransactionJWS(refreshPolicy: AppTransactionRefreshPolicy, _ completionHandler: @escaping (String?) -> Void)

}

Expand Down Expand Up @@ -85,7 +95,7 @@ final class StoreKit2TransactionFetcher: StoreKit2TransactionFetcherType {
func fetchReceipt(containing transaction: StoreTransactionType) async -> StoreKit2Receipt {
async let transactions = verifiedTransactions(containing: transaction)
async let subscriptionStatuses = subscriptionStatusBySubscriptionGroupId
async let appTransaction = appTransaction
async let appTransaction = appTransaction(refreshPolicy: .onlyIfEmpty)

return await .init(
environment: .xcode,
Expand All @@ -110,13 +120,11 @@ final class StoreKit2TransactionFetcher: StoreKit2TransactionFetcherType {
///
/// - Returns: A `String` containing the JWS representation of the app transaction,
/// or `nil` if the feature is unavailable on the current platform version.
var appTransactionJWS: String? {
get async {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
return try? await AppTransaction.shared.jwsRepresentation
} else {
return nil
}
func appTransactionJWS(refreshPolicy: AppTransactionRefreshPolicy) async -> String? {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
return await appTransaction(refreshPolicy: refreshPolicy)?.jwsRepresentation
} else {
return nil
}
}

Expand All @@ -130,10 +138,10 @@ final class StoreKit2TransactionFetcher: StoreKit2TransactionFetcherType {
/// if the feature is unavailable on the current platform version.
/// - Parameter result: A `String?` containing the JWS representation of the app transaction,
/// or `nil` if unavailable.
func appTransactionJWS(_ completion: @escaping (String?) -> Void) {
func appTransactionJWS(refreshPolicy: AppTransactionRefreshPolicy, _ completion: @escaping (String?) -> Void) {
Async.call(with: completion) {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
return try? await AppTransaction.shared.jwsRepresentation
return await self.appTransaction(refreshPolicy: refreshPolicy)?.jwsRepresentation
} else {
return nil
}
Expand Down Expand Up @@ -181,7 +189,8 @@ extension StoreKit.VerificationResult where SignedType == StoreKit.AppTransactio

var verifiedAppTransaction: SK2AppTransaction? {
switch self {
case let .verified(transaction): return .init(appTransaction: transaction)
case let .verified(transaction):
return .init(appTransaction: transaction, jwsRepresentation: self.jwsRepresentation)
case let .unverified(transaction, error):
Logger.warn(
Strings.storeKit.sk2_unverified_transaction(identifier: transaction.bundleID, error)
Expand Down Expand Up @@ -279,22 +288,42 @@ extension StoreKit2TransactionFetcher {
}
}

@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
private var appTransaction: SK2AppTransaction? {
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
private var refreshedAppTransaction: SK2AppTransaction? {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unified diff of this is a bit weird.

appTransaction was modified into the appTransaction(refreshPolicy:) method below.

and refreshedAppTransaction was added as a helper to fetch an AppTransaction via StoreKit.AppTransaction.refresh()

get async {
do {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
let transaction = try await StoreKit.AppTransaction.shared
return transaction.verifiedAppTransaction
} else {
Logger.warn(Strings.storeKit.sk2_app_transaction_unavailable)
return nil
}
let transaction = try await StoreKit.AppTransaction.refresh()
return transaction.verifiedAppTransaction
} catch {
Logger.warn(Strings.storeKit.sk2_error_fetching_app_transaction(error))
return nil
}
}
}

@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
func appTransaction(refreshPolicy: AppTransactionRefreshPolicy) async -> SK2AppTransaction? {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
do {
let transaction = try await StoreKit.AppTransaction.shared
if let transaction = transaction.verifiedAppTransaction {
return transaction
} else {
return await refreshedAppTransaction
}
} catch {
switch refreshPolicy {
case .onlyIfEmpty:
return await refreshedAppTransaction
case .never:
Logger.warn(Strings.storeKit.sk2_error_fetching_app_transaction(error))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this qualify for a a Logger.appleError?

return nil
}
}
} else {
Logger.warn(Strings.storeKit.sk2_app_transaction_unavailable)
return nil
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ class PurchasesOrchestratorCommonTests: BasePurchasesOrchestratorTests {
func testRestorePurchasesDoesNotLogWarningIfAllowSharingAppStoreAccountIsNotDefined() async throws {
self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo

_ = try? await self.orchestrator.syncPurchases(receiptRefreshPolicy: .never,
_ = try? await self.orchestrator.syncPurchases(receiptRefreshAllowed: false,
isRestore: false,
initiationSource: .restore)

Expand All @@ -182,7 +182,7 @@ class PurchasesOrchestratorCommonTests: BasePurchasesOrchestratorTests {

self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo

_ = try? await self.orchestrator.syncPurchases(receiptRefreshPolicy: .never,
_ = try? await self.orchestrator.syncPurchases(receiptRefreshAllowed: false,
isRestore: false,
initiationSource: .restore)

Expand All @@ -198,7 +198,7 @@ class PurchasesOrchestratorCommonTests: BasePurchasesOrchestratorTests {

self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo

_ = try? await self.orchestrator.syncPurchases(receiptRefreshPolicy: .never,
_ = try? await self.orchestrator.syncPurchases(receiptRefreshAllowed: false,
isRestore: false,
initiationSource: .restore)

Expand Down
12 changes: 6 additions & 6 deletions Tests/StoreKitUnitTests/PurchasesOrchestratorSK1Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr
self.customerInfoManager.stubbedCachedCustomerInfoResult = mockCustomerInfo
self.receiptParser.stubbedReceiptHasTransactionsResult = false

let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always,
let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true,
isRestore: false,
initiationSource: .purchase)
expect(self.backend.invokedPostReceiptData).to(beFalse())
Expand All @@ -596,7 +596,7 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr
self.customerInfoManager.stubbedCachedCustomerInfoResult = CustomerInfo.missingOriginalPurchaseDate
self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo)

let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always,
let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true,
isRestore: true,
initiationSource: .restore)

Expand All @@ -617,7 +617,7 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr
func testSyncPurchasesPostsReceipt() async throws {
self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo)

let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always,
let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true,
isRestore: false,
initiationSource: .purchase)

Expand All @@ -631,7 +631,7 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr
self.customerInfoManager.stubbedCachedCustomerInfoResult = nil
self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo)

let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always,
let customerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true,
isRestore: true,
initiationSource: .restore)

Expand All @@ -652,7 +652,7 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr
func testSyncPurchasesCallsSuccessDelegateMethod() async throws {
self.backend.stubbedPostReceiptResult = .success(mockCustomerInfo)

let receivedCustomerInfo = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always,
let receivedCustomerInfo = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true,
isRestore: false,
initiationSource: .purchase)

Expand All @@ -665,7 +665,7 @@ class PurchasesOrchestratorSK1Tests: BasePurchasesOrchestratorTests, PurchasesOr
self.backend.stubbedPostReceiptResult = .failure(expectedError)

do {
_ = try await self.orchestrator.syncPurchases(receiptRefreshPolicy: .always,
_ = try await self.orchestrator.syncPurchases(receiptRefreshAllowed: true,
isRestore: false,
initiationSource: .purchase)
fail("Expected error")
Expand Down
Loading