Skip to content

Commit

Permalink
[SK2] Enable Observer Mode for StoreKit 2 (#3580)
Browse files Browse the repository at this point in the history
  • Loading branch information
MarkVillacampa authored and joshdholtz committed Feb 23, 2024
1 parent b71207e commit 18e6d5e
Show file tree
Hide file tree
Showing 14 changed files with 159 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"StoreKit2JWSIntegrationTests",
"StoreKit2JWSObserverModeIntegrationTests",
"StoreKit2JWSObserverModeWithExistingPurchasesTests",
"StoreKit2NotEnabledObserverModeIntegrationTests",
"StoreKit2ObserverModeIntegrationTests",
"StoreKit2ObserverModeWithExistingPurchasesTests",
"SubscriberAttributesManagerIntegrationTests",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"StoreKit2JWSIntegrationTests",
"StoreKit2JWSObserverModeIntegrationTests",
"StoreKit2JWSObserverModeWithExistingPurchasesTests",
"StoreKit2NotEnabledObserverModeIntegrationTests",
"StoreKit2ObserverModeIntegrationTests",
"StoreKit2ObserverModeWithExistingPurchasesTests",
"TestCase"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"StoreKit2JWSIntegrationTests",
"StoreKit2JWSObserverModeIntegrationTests",
"StoreKit2JWSObserverModeWithExistingPurchasesTests",
"StoreKit2NotEnabledObserverModeIntegrationTests",
"StoreKit2ObserverModeIntegrationTests",
"StoreKit2ObserverModeWithExistingPurchasesTests",
"SubscriberAttributesManagerIntegrationTests",
Expand Down
22 changes: 18 additions & 4 deletions Sources/Logging/Strings/ConfigureStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ enum ConfigureStrings {

case observer_mode_enabled

case observer_mode_with_storekit2

case response_verification_mode(Signing.ResponseVerificationMode)

case storekit_version(StoreKitVersion)
Expand Down Expand Up @@ -75,6 +73,12 @@ enum ConfigureStrings {

case sk2_required_for_swiftui_paywalls

case handle_transaction_observer_mode_required

case sk2_required

case observer_mode_with_storekit2

}

extension ConfigureStrings: LogMessage {
Expand All @@ -98,8 +102,6 @@ extension ConfigureStrings: LogMessage {
return "Debug logging enabled"
case .observer_mode_enabled:
return "Purchases is configured in observer mode"
case .observer_mode_with_storekit2:
return "Observer mode is not currently compatible with StoreKit 2"
case let .response_verification_mode(mode):
switch mode {
case .disabled:
Expand Down Expand Up @@ -187,6 +189,18 @@ extension ConfigureStrings: LogMessage {
return "Purchases is not configured with StoreKit 2 enabled. This is required in order to detect " +
"transactions coming from SwiftUI paywalls. You must use `.with(storeKitVersion: .storeKit2)` " +
"when configuring the SDK."

case .handle_transaction_observer_mode_required:
return "Attempted to manually handle transactions with observer mode not enabled. " +
"You must use `.with(observerMode: true)` when configuring the SDK."

case .sk2_required:
return "StoreKit 2 must be enabled. You must use `.with(storeKitVersion: .storeKit2)` " +
"when configuring the SDK."

case .observer_mode_with_storekit2:
return "StoreKit 2 Observer Mode is enabled. You must manually handle newly purchased transactions " +
"calling `Purchases.shared.handleObserverModeTransaction()`."
}
}

Expand Down
3 changes: 3 additions & 0 deletions Sources/Logging/Strings/PurchaseStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ enum PurchaseStrings {
case caching_presented_offering_identifier(offeringID: String, productID: String)
case payment_queue_wrapper_delegate_call_sk1_enabled
case restorepurchases_called_with_allow_sharing_appstore_account_false
case sk2_observer_mode_error_processing_transaction(Error)

}

Expand Down Expand Up @@ -329,6 +330,8 @@ extension PurchaseStrings: LogMessage {
case .restorepurchases_called_with_allow_sharing_appstore_account_false:
return "allowSharingAppStoreAccount is set to false and restorePurchases has been called. " +
"Are you sure you want to do this?"
case let .sk2_observer_mode_error_processing_transaction(error):
return "Obserber mode could not process transaction: \(error)"
}
}

Expand Down
24 changes: 24 additions & 0 deletions Sources/Purchasing/Purchases/Purchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,30 @@ public extension Purchases {

#endif

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
func handleObserverModeTransaction(
_ purchaseResult: StoreKit.Product.PurchaseResult
) async throws -> StoreTransaction? {
guard self.systemInfo.observerMode else {
throw NewErrorUtils.configurationError(
message: Strings.configure.handle_transaction_observer_mode_required.description
).asPublicError
}
guard self.systemInfo.storeKitVersion == .storeKit2 else {
throw NewErrorUtils.configurationError(
message: Strings.configure.sk2_required.description
).asPublicError
}
do {
let (_, transaction) = try await self.purchasesOrchestrator.storeKit2TransactionListener.handle(
purchaseResult: purchaseResult, fromTransactionUpdate: true
)
return transaction
} catch {
throw NewErrorUtils.purchasesError(withUntypedError: error).asPublicError
}
}

}

// swiftlint:enable missing_docs
Expand Down
2 changes: 1 addition & 1 deletion Sources/Purchasing/Purchases/PurchasesOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ final class PurchasesOrchestrator {
// `userCancelled` above comes from `StoreKitError.userCancelled`.
// This detects if `Product.PurchaseResult.userCancelled` is true.
let (userCancelled, transaction) = try await self.storeKit2TransactionListener
.handle(purchaseResult: result)
.handle(purchaseResult: result, fromTransactionUpdate: false)

if userCancelled, self.systemInfo.dangerousSettings.customEntitlementComputation {
throw ErrorUtils.purchaseCancelledError()
Expand Down
51 changes: 51 additions & 0 deletions Sources/Purchasing/Purchases/PurchasesType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// Created by Nacho Soto on 9/20/22.

import Foundation
import StoreKit

// swiftlint:disable file_length

Expand Down Expand Up @@ -1008,6 +1009,56 @@ public protocol PurchasesSwiftType: AnyObject {

#endif

/**
* Use this method only if you already have your own IAP implementation using StoreKit 2 and want to use
* RevenueCat's backend. If you are using StoreKit 1 for your implementation, you do not need this method.
*
* You only need to use this method with *new* purchases. Subscription updates are observed automatically.
*
* #### Example:
*
* ```swift
* // Fetch and purchase the product
* let product = try await StoreKit.Product.products(for: ["my_product_id"]).first
* guard let product = product else { return }
* let result = try await product.purchase()
* // Let RevenueCat handle the transaction result
* _ = try await Purchases.shared.handleObserverModeTransaction(result)
* // Handle the result and finish the transaction
* switch result {
* case .success(let verification):
* switch verification {
* case .unverified(_, _):
* break
* case .verified(let transaction):
* // If the purchase was successful and verified, finish the transaction
* await transaction.finish()
* }
* case .userCancelled:
* break
* case .pending:
* break
* @unknown default:
* break
* }
* ```
*
* - Warning: You need to finish the transaction yourself after calling this method.
*
* - Parameter purchaseResult: The `StoreKit.Product.PurchaseResult` of the product that was just purchased.
*
* - Throws: An error of type ``ErrorCode`` is thrown if a failure occurs while handling the purchase.
*
* - Returns: A ``StoreTransaction`` if there was a transacton found and handled for the provided product ID.
*
* - Important: This should only be used if you have enabled observer mode during SDK configuration using
* ``Configuration/Builder/with(observerMode:)``
*/
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
func handleObserverModeTransaction(
_ purchaseResult: StoreKit.Product.PurchaseResult
) async throws -> StoreTransaction?

}

// MARK: -
Expand Down
14 changes: 7 additions & 7 deletions Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ protocol StoreKit2TransactionListenerType: Sendable {

func set(delegate: StoreKit2TransactionListenerDelegate) async

/// - Returns: `nil` `CustomerInfo` if purchases were not synced
/// - Throws: Error if purchase was not completed successfully
/// - Throws: ``PurchasesError`` if purchase was not completed successfully
/// - Parameter fromTransactionUpdate: `true` only for transactions detected outside of a manual purchase flow.
func handle(
purchaseResult: StoreKit.Product.PurchaseResult
purchaseResult: StoreKit.Product.PurchaseResult,
fromTransactionUpdate: Bool
) async throws -> StoreKit2TransactionListener.ResultData

}
Expand Down Expand Up @@ -113,15 +114,14 @@ actor StoreKit2TransactionListener: StoreKit2TransactionListenerType {
self.taskHandle = nil
}

/// - Returns: `nil` `CustomerInfo` if purchases were not synced
/// - Throws: Error if purchase was not completed successfully
func handle(
purchaseResult: StoreKit.Product.PurchaseResult
purchaseResult: StoreKit.Product.PurchaseResult,
fromTransactionUpdate: Bool = false
) async throws -> ResultData {
switch purchaseResult {
case let .success(verificationResult):
let transaction = try await self.handle(transactionResult: verificationResult,
fromTransactionUpdate: false)
fromTransactionUpdate: fromTransactionUpdate)
return (false, transaction)
case .pending:
throw ErrorUtils.paymentDeferredError()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,11 @@ private func checkAsyncMethods(purchases: Purchases) async {
let _: CustomerInfo = try await purchases.restorePurchases()
let _: CustomerInfo = try await purchases.syncPurchases()

if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) {
let result = try await StoreKit.Product.products(for: [""]).first!.purchase()
let _: StoreTransaction? = try await purchases.handleObserverModeTransaction(result)
}

for try await _: CustomerInfo in purchases.customerInfoStream {}

#if os(iOS) || VISION_OS
Expand Down
13 changes: 13 additions & 0 deletions Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ class StoreKit2IntegrationTests: StoreKit1IntegrationTests {

override class var storeKitVersion: StoreKitVersion { return .storeKit2 }

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
func testObservingTransactionThrowsIfObserverModeNotEnabled() async throws {
let manager = ObserverModeManager()
let result = try await manager.purchaseProductFromStoreKit2()

do {
_ = try await Purchases.shared.handleObserverModeTransaction(result)
fail("Expected error")
} catch {
expect(error).to(matchError(ErrorCode.configurationError))
}
}

}

class StoreKit1IntegrationTests: BaseStoreKitIntegrationTests {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// Created by Nacho Soto on 12/15/22.

import Foundation

import Nimble
@testable import RevenueCat
import StoreKit
import StoreKitTest
Expand Down Expand Up @@ -45,25 +45,16 @@ class StoreKit2ObserverModeIntegrationTests: StoreKit1ObserverModeIntegrationTes
}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
func testPurchaseInDevicePostsReceipt() async throws {
func testObservingTransactionUnlocksEntitlement() async throws {
let result = try await self.manager.purchaseProductFromStoreKit2()
let transaction = try XCTUnwrap(result.verificationResult?.underlyingTransaction)

try self.testSession.disableAutoRenewForTransaction(identifier: UInt(transaction.id))

XCTExpectFailure("This test currently does not pass (see FB12231111)")

try await asyncWait(
description: "Entitlement didn't become active",
timeout: .seconds(5),
pollInterval: .milliseconds(500)
) {
let entitlement = await self.purchasesDelegate
.customerInfo?
.entitlements[Self.entitlementIdentifier]
_ = try await Purchases.shared.handleObserverModeTransaction(result)

return entitlement?.isActive == true
}
let customerInfo = try XCTUnwrap(self.purchasesDelegate.customerInfo)
try await self.verifyEntitlementWentThrough(customerInfo)
}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
Expand Down Expand Up @@ -184,3 +175,22 @@ class StoreKit1ObserverModeWithExistingPurchasesTests: BaseStoreKitObserverModeI
}

}

class StoreKit2NotEnabledObserverModeIntegrationTests: BaseStoreKitObserverModeIntegrationTests {

override class var storeKitVersion: StoreKitVersion { .storeKit1 }

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
func testObservingTransactionThrowsIfStoreKit2NotEnabled() async throws {
let manager = ObserverModeManager()
let result = try await manager.purchaseProductFromStoreKit2()

do {
_ = try await Purchases.shared.handleObserverModeTransaction(result)
fail("Expected error")
} catch {
expect(error).to(matchError(ErrorCode.configurationError))
}
}

}
8 changes: 8 additions & 0 deletions Tests/UnitTests/Mocks/MockPurchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// Created by Nacho Soto on 10/10/22.

@testable import RevenueCat
import StoreKit

final class MockPurchases {

Expand Down Expand Up @@ -457,6 +458,13 @@ extension MockPurchases: PurchasesSwiftType {
self.unimplemented()
}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
func handleObserverModeTransaction(
_ purchaseResult: Product.PurchaseResult
) async throws -> RevenueCat.StoreTransaction? {
self.unimplemented()
}

#if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS

@available(iOS 16.0, *)
Expand Down
3 changes: 2 additions & 1 deletion Tests/UnitTests/Mocks/MockStoreKit2TransactionListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ final class MockStoreKit2TransactionListener: StoreKit2TransactionListenerType {
var invokedHandleParametersList = [(purchaseResult: Box<StoreKit.Product.PurchaseResult>, Void)]()

func handle(
purchaseResult: StoreKit.Product.PurchaseResult
purchaseResult: StoreKit.Product.PurchaseResult,
fromTransactionUpdate: Bool = false
) async throws -> StoreKit2TransactionListener.ResultData {
self.invokedHandle = true
self.invokedHandleCount += 1
Expand Down

0 comments on commit 18e6d5e

Please sign in to comment.