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

[SK2] Enable Observer Mode for StoreKit 2 #3580

Merged
merged 31 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f1e49d1
Add method to process initial sk2 transactions in observer mode
MarkVillacampa Nov 15, 2023
e988db6
Improve error handling, add method for processing last transaction by…
MarkVillacampa Jan 17, 2024
762df7d
process -> handle
MarkVillacampa Jan 17, 2024
0b62e26
add back removed tests
MarkVillacampa Jan 17, 2024
d2dba88
Remove method to handle purchases by product id, add documentation, t…
MarkVillacampa Jan 17, 2024
7a274b6
remove unused string
MarkVillacampa Jan 17, 2024
ebb32b6
do not throw purchaseCancelledError
MarkVillacampa Jan 17, 2024
87a431a
typos & docs
MarkVillacampa Jan 17, 2024
8c05b52
docs
MarkVillacampa Jan 17, 2024
2a9de8a
remove unneeded tests
MarkVillacampa Jan 17, 2024
1b7a1fe
put back space
MarkVillacampa Jan 17, 2024
e52b1d1
swiftify api
MarkVillacampa Jan 17, 2024
48a84d9
fix test
MarkVillacampa Jan 17, 2024
f0e4b22
do not create new error
MarkVillacampa Jan 17, 2024
373f081
remove leftover api
MarkVillacampa Jan 17, 2024
c800831
thow error if using observer mode without storekit 2
MarkVillacampa Jan 17, 2024
39a5a22
docs
MarkVillacampa Jan 17, 2024
5d91282
docs
MarkVillacampa Jan 17, 2024
7686b63
strings
MarkVillacampa Jan 17, 2024
baf90c6
tests
MarkVillacampa Jan 17, 2024
3754740
make sure error is an ErrorCode
MarkVillacampa Jan 17, 2024
9e58ecf
log message when storekit2 + observer mode
MarkVillacampa Jan 17, 2024
31d1d5c
Remove StoreKit2NotEnabledObserverModeIntegrationTests from offline/o…
MarkVillacampa Jan 17, 2024
e6008c3
documentation and error messages
MarkVillacampa Jan 17, 2024
7b34817
fix build
MarkVillacampa Jan 17, 2024
f7d0a1f
simplify test
MarkVillacampa Jan 17, 2024
3fc2dac
make extension private again
MarkVillacampa Jan 17, 2024
b00ef6a
fix test
MarkVillacampa Jan 17, 2024
eb83202
space
MarkVillacampa Jan 17, 2024
8e68c20
reset tests
MarkVillacampa Jan 18, 2024
97bd115
add tests
MarkVillacampa Jan 18, 2024
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
5 changes: 5 additions & 0 deletions Sources/Error Handling/ErrorCode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import Foundation
@objc(RCFeatureNotAvailableInCustomEntitlementsComputationMode)
case featureNotAvailableInCustomEntitlementsComputationMode = 36
@objc(RCSignatureVerificationFailed) case signatureVerificationFailed = 37
@objc(RCObserberModeNotEnabledError) case observerModeNotEnabledError = 38

// swiftlint:enable missing_docs

Expand Down Expand Up @@ -180,6 +181,8 @@ extension ErrorCode: DescribableError {
return "This feature is not available when utilizing the customEntitlementsComputation dangerousSetting."
case .signatureVerificationFailed:
return "Request failed signature verification. See https://rev.cat/trusted-entitlements for more info."
case .observerModeNotEnabledError:
return "Observer mode is not enabled. You must enable Observer Mode when initializing the SDKs."

@unknown default:
return "Something went wrong."
Expand Down Expand Up @@ -281,6 +284,8 @@ extension ErrorCode {
return "FEATURE_NOT_AVAILABLE_IN_CUSTOM_ENTITLEMENTS_COMPUTATION_MODE_ERROR"
case .signatureVerificationFailed:
return "SIGNATURE_VERIFICATION_FAILED"
case .observerModeNotEnabledError:
return "OBSERVER_MODE_NOT_ENABLED"
@unknown default:
return "UNRECOGNIZED_ERROR"
}
Expand Down
17 changes: 16 additions & 1 deletion Sources/Error Handling/ErrorUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@ enum ErrorUtils {
)
}

/**
* Constructs an NSError with the ``ErrorCode/observerModeNotEnabledError`` code.
*/
static func observerModeNotEnabledError(
error: Error? = nil,
fileName: String = #fileID, functionName: String = #function, line: UInt = #line
) -> PurchasesError {
let errorCode = ErrorCode.observerModeNotEnabledError
return ErrorUtils.error(with: errorCode,
message: errorCode.description,
underlyingError: error,
fileName: fileName, functionName: functionName, line: line)
}

/**
* Maps a ``BackendErrorCode`` code to a ``ErrorCode``. code. Constructs an Error with the mapped code and adds a
* `NSUnderlyingErrorKey` in the `NSError.userInfo` dictionary. The backend error code will be mapped using
Expand Down Expand Up @@ -684,7 +698,8 @@ private extension ErrorUtils {
.invalidPromotionalOfferError,
.offlineConnectionError,
.featureNotAvailableInCustomEntitlementsComputationMode,
.signatureVerificationFailed:
.signatureVerificationFailed,
.observerModeNotEnabledError:
Logger.error(
localizedDescription,
fileName: fileName,
Expand Down
4 changes: 0 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 @@ -98,8 +96,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
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: Error)
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: the error label isn't required since it doesn't add anything to the type:

Suggested change
case sk2_observer_mode_error_processing_transaction(error: Error)
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
7 changes: 0 additions & 7 deletions Sources/Purchasing/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ import Foundation

private init(with builder: Builder) {
Self.verify(apiKey: builder.apiKey)
Self.verify(observerMode: builder.observerMode, storeKitVersion: builder.storeKitVersion)

self.apiKey = builder.apiKey
self.appUserID = builder.appUserID
Expand Down Expand Up @@ -324,12 +323,6 @@ extension Configuration {
}
}

fileprivate static func verify(observerMode: Bool, storeKitVersion: StoreKitVersion) {
if observerMode, storeKitVersion == .storeKit2 {
Logger.warn(Strings.configure.observer_mode_with_storekit2)
}
}

private static let applePlatformKeyPrefix: String = "appl_"

}
Expand Down
12 changes: 12 additions & 0 deletions Sources/Purchasing/Purchases/Purchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,18 @@ 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? {
Copy link
Contributor

Choose a reason for hiding this comment

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

Note that this needs to call throw NewErrorUtils.error().asPublicError.

Copy link
Member

@joshdholtz joshdholtz Jan 17, 2024

Choose a reason for hiding this comment

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

I know this is Observer Mode but does it matter what they have set for the StoreKit version? Like... should this also require .with(storeKitVersion: .storeKit2)?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good catch. Indeed that's a requirement.

guard !self.systemInfo.observerMode else {
throw NewErrorUtils.observerModeNotEnabledError()
Copy link
Member Author

Choose a reason for hiding this comment

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

Throw early, throw often. 😄

Copy link
Contributor

Choose a reason for hiding this comment

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

I would prevent creating yet another error for this. That's yet another error that users need to handle not just on this API, but in all of our APIs, and maybe localize to different languages, etc.
I would instead throw unsupportedError or configurationError.

}
let (_, transaction) = try await self.purchasesOrchestrator.storeKit2TransactionListener.handle(
purchaseResult: purchaseResult, fromTransactionUpdate: true)
Copy link
Contributor

Choose a reason for hiding this comment

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

I wish we linted this stuff 😇
I'd format it with symmetrical parenthesis:

Suggested change
let (_, transaction) = try await self.purchasesOrchestrator.storeKit2TransactionListener.handle(
purchaseResult: purchaseResult, fromTransactionUpdate: true)
let (_, transaction) = try await self.purchasesOrchestrator.storeKit2TransactionListener.handle(
purchaseResult: purchaseResult, fromTransactionUpdate: true
)

return transaction
}

}

// 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
30 changes: 30 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,35 @@ 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
* let result = try await product.purchase(options: options)
* Purchases.shared.handleObserverModeTransaction(purchaseResult: result)
* ```
*
* - Note: You need to finish the transaction yourself after calling this method.
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe turn this into a Warning since it's pretty important? Maybe put that in the code example above too?

*
* - Parameter purchaseResult: The ``StoreKit.Product.PurchaseResult`` of the product that was just purchased.
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think you can reference types outside the module with "``", does this not give a warning when building?

Copy link
Member Author

Choose a reason for hiding this comment

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

I haven't noticed any warning, but replaced with single ticks since it cannot link anywhere ayway.

Copy link
Contributor

Choose a reason for hiding this comment

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

Screenshot 2024-01-17 at 12 18 25

*
* - Throws: An error of type ``ErrorCode`` is thrown if a failure occurs while handling the purchase.
Copy link
Contributor

@NachoSoto NachoSoto Jan 17, 2024

Choose a reason for hiding this comment

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

This isn't guaranteed because Swift doesn't have typed throws yet.
See PurchasesError.swift, I've done a lot of work to make sure that we always ensure this.

You need to do this:

do {
    try purchasesOrchestratorMethod()
} catch {
    throw ErrorUtils.purchasesError(withUntypedError: error).asPublicError
}

*
* - 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
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the swifty way would be

Suggested change
purchaseResult: StoreKit.Product.PurchaseResult
_ purchaseResult: StoreKit.Product.PurchaseResult

) async throws -> StoreTransaction?

}

// MARK: -
Expand Down
28 changes: 14 additions & 14 deletions Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,20 @@ 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: ``ErrorCode`` if purchase was not completed successfully
Copy link
Contributor

Choose a reason for hiding this comment

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

Technically these throw PurchasesError (for the most part, since it's impossible to guarantee that there aren't any other types).

/// - 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

/// - Throws: ``ErrorCode`` if the transaction fails to verify.
/// - Parameter fromTransactionUpdate: `true` only for transactions detected outside of a manual purchase flow.
func handle(
transactionResult: StoreKit.VerificationResult<StoreKit.Transaction>,
fromTransactionUpdate: Bool
) async throws -> StoreTransaction

}

/// Observes `StoreKit.Transaction.updates`, which receives:
Expand Down Expand Up @@ -113,15 +121,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 All @@ -135,13 +142,6 @@ actor StoreKit2TransactionListener: StoreKit2TransactionListenerType {
}
}

}

@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
private extension StoreKit2TransactionListener {

/// - Throws: ``ErrorCode`` if the transaction fails to verify.
/// - Parameter fromTransactionUpdate: `true` only for transactions detected outside of a manual purchase flow.
func handle(
transactionResult: TransactionResult,
fromTransactionUpdate: Bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ + (void)checkAPI {
[p purchasePackage:pack withCompletion:^(RCStoreTransaction *t, RCCustomerInfo *i, NSError *e, BOOL userCancelled) { }];
[p restorePurchasesWithCompletion:^(RCCustomerInfo *i, NSError *e) {}];
[p syncPurchasesWithCompletion:^(RCCustomerInfo *i, NSError *e) {}];

[p checkTrialOrIntroDiscountEligibilityForProduct:storeProduct completion:^(RCIntroEligibilityStatus status) { }];
[p checkTrialOrIntroDiscountEligibility:@[@""] completion:^(NSDictionary<NSString *,RCIntroEligibility *> *d) { }];
if (@available(iOS 12.2, macOS 10.14.4, macCatalyst 13.0, tvOS 12.2, watchOS 6.2, *)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ + (void)checkEnums {
case RCInvalidPromotionalOfferError:
case RCOfflineConnectionError:
case RCSignatureVerificationFailed:
case RCObserberModeNotEnabledError:
NSLog(@"%ld", (long)errCode);
case RCFeatureNotAvailableInCustomEntitlementsComputationMode:
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ func checkPurchasesErrorCodeEnums() {
.invalidPromotionalOfferError,
.offlineConnectionError,
.featureNotAvailableInCustomEntitlementsComputationMode,
.signatureVerificationFailed:
.signatureVerificationFailed,
.observerModeNotEnabledError:
print(errCode!)
@unknown default:
fatalError()
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()
Copy link
Contributor

Choose a reason for hiding this comment

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

I like that you did it this way to ensure that whatever we pass to the next method is the result of this.

let _: StoreTransaction? = try await purchases.handleObserverModeTransaction(purchaseResult: result)
}

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

#if os(iOS) || VISION_OS
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,13 +45,13 @@ 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 Purchases.shared.handleObserverModeTransaction(purchaseResult: result)

try await asyncWait(
description: "Entitlement didn't become active",
Expand Down Expand Up @@ -81,6 +81,33 @@ class StoreKit2ObserverModeIntegrationTests: StoreKit1ObserverModeIntegrationTes

}

class StoreKit2ObserverModeDisabledIntegrationTests: StoreKit1ObserverModeIntegrationTests {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is going to inherit and run every single test in the super class.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ugh. This can be tested as unit in StoreKitTests (because we need a Transaction object) but we don't have any test that instantiates a Purchases object. Is it worth it creating one?

Copy link
Contributor

Choose a reason for hiding this comment

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

but we don't have any test that instantiates a Purchases object. Is it worth it creating one?

Yeah I think it would be useful, but also mock all the dependencies. So I think we can extract the Purchases setup from BasePurchasesTest for that.

Copy link
Contributor

Choose a reason for hiding this comment

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

But we can put this in the regular SK2 test class, right?

Copy link
Member Author

Choose a reason for hiding this comment

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

true! I moved it to StoreKit2IntegrationTests and created StoreKit2NotEnabledObserverModeIntegrationTests for the sk2 not enabled one.


override class var storeKitVersion: StoreKitVersion { .storeKit2 }

override class var observerMode: Bool { false }

override func setUp() async throws {
try await super.setUp()

try AvailabilityChecks.iOS15APIAvailableOrSkipTest()
}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
func testObservingTransactionThrowsIfObserverModeNotEnabled() async throws {
NachoSoto marked this conversation as resolved.
Show resolved Hide resolved
let result = try await self.manager.purchaseProductFromStoreKit2()
let transaction = try XCTUnwrap(result.verificationResult?.underlyingTransaction)

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

}

class StoreKit1ObserverModeIntegrationTests: BaseStoreKitObserverModeIntegrationTests {

override class var storeKitVersion: StoreKitVersion { .storeKit1 }
Expand Down
Loading