From 458ea9f8f2c58af258b4c3f2e6250e674233b832 Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Fri, 12 Aug 2022 16:03:30 -0700 Subject: [PATCH 01/16] set min ios version to 15 --- IapExample/ios/Podfile | 2 +- IapExample/ios/Podfile.lock | 6 +- RNIap.podspec | 2 +- ios/RNIapIos.swift | 121 ++++++++++++++++++++---------------- 4 files changed, 73 insertions(+), 58 deletions(-) diff --git a/IapExample/ios/Podfile b/IapExample/ios/Podfile index 96cfc5e38..7728e563c 100644 --- a/IapExample/ios/Podfile +++ b/IapExample/ios/Podfile @@ -1,7 +1,7 @@ require_relative '../node_modules/react-native/scripts/react_native_pods' require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules' -platform :ios, '12.4' +platform :ios, '15.0' install! 'cocoapods', :deterministic_uuids => false target 'IapExample' do diff --git a/IapExample/ios/Podfile.lock b/IapExample/ios/Podfile.lock index 9bbd26596..f4f43016b 100644 --- a/IapExample/ios/Podfile.lock +++ b/IapExample/ios/Podfile.lock @@ -360,7 +360,7 @@ PODS: - React-Core - RNGestureHandler (2.5.0): - React-Core - - RNIap (8.6.5): + - RNIap (9.0.0): - React-Core - RNScreens (3.15.0): - React-Core @@ -575,12 +575,12 @@ SPEC CHECKSUMS: ReactCommon: e30ec17dfb1d4c4f3419eac254350d6abca6d5a2 RNCMaskedView: cb9670ea9239998340eaab21df13fa12a1f9de15 RNGestureHandler: bad495418bcbd3ab47017a38d93d290ebd406f50 - RNIap: e9f648d00e693913f80bbe5b661a5257fb72fe93 + RNIap: 95084640290f4d68559dc37f1c8424b3f7dbf5d8 RNScreens: 4a1af06327774490d97342c00aee0c2bafb497b7 SocketRocket: fccef3f9c5cedea1353a9ef6ada904fde10d6608 Yoga: 7ab6e3ee4ce47d7b789d1cb520163833e515f452 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a -PODFILE CHECKSUM: cfed50b11ea421296640e72457dcf62114e957f6 +PODFILE CHECKSUM: 47f330ba4aa0808e88cd2a085debf346af3dc659 COCOAPODS: 1.11.3 diff --git a/RNIap.podspec b/RNIap.podspec index 3e24142b2..e2a09726f 100644 --- a/RNIap.podspec +++ b/RNIap.podspec @@ -12,7 +12,7 @@ Pod::Spec.new do |s| s.license = package["license"] s.authors = package["author"] - s.platforms = { :ios => "10.0" , :tvos => "10.0"} + s.platforms = { :ios => "15.0" , :tvos => "15.0"} s.source = { :git => "https://github.com/dooboolab/react-native-iap.git", :tag => "#{s.version}" } s.source_files = "ios/*.{h,m,mm,swift}" diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index ed9c075f3..2ed4ce333 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -37,9 +37,9 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver private var promisesByKey: [String: [RNIapIosPromise]] private var myQueue: DispatchQueue private var hasListeners = false - private var pendingTransactionWithAutoFinish = false private var receiptBlock: ((Data?, Error?) -> Void)? // Block to handle request the receipt async from delegate - private var validProducts: [SKProduct] + private var products: [String:Product] + private var transactions: [String: Transaction] private var promotedPayment: SKPayment? private var promotedProduct: SKProduct? private var productsRequest: SKProductsRequest? @@ -48,9 +48,8 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver override init() { promisesByKey = [String: [RNIapIosPromise]]() - pendingTransactionWithAutoFinish = false myQueue = DispatchQueue(label: "reject") - validProducts = [SKProduct]() + products = [String: Product]() super.init() addTransactionObserver() } @@ -166,17 +165,14 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver _ skus: [String], resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - let productIdentifiers = Set(skus) - if let productIdentifiers = productIdentifiers as? Set { - productsRequest = SKProductsRequest(productIdentifiers: productIdentifiers) - if let productsRequest = productsRequest { - productsRequest.delegate = self - let key: String = productsRequest.key - addPromise(forKey: key, resolve: resolve, reject: reject) - productsRequest.start() + ) async { + do{ + let products = try await Product.products(for: skus) + resolve(products) + }catch{ + reject("E_UNKNOWN","Error fetching items",nil) } - } + } @objc public func getAvailableItems( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, @@ -189,47 +185,65 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver @objc public func buyProduct( _ sku: String, andDangerouslyFinishTransactionAutomatically: Bool, - applicationUsername: String?, + applicationUsername: String?, //TODO resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - pendingTransactionWithAutoFinish = andDangerouslyFinishTransactionAutomatically - var product: SKProduct? - let lockQueue = DispatchQueue(label: "validProducts") - lockQueue.sync { - for p in validProducts { - if sku == p.productIdentifier { - product = p - break + ) async { + let product: Product? = products[sku] + + if let product = product { + do { + let result = try await product.purchase() + switch result { + case .success(let verification): + //Check whether the transaction is verified. If it isn't, + //this function rethrows the verification error. + let transaction = try checkVerified(verification) + + //The transaction is verified. Deliver content to the user. + // Do on JS :await updateCustomerProductStatus() + + //Always finish a transaction. + let transactionId = String(transaction.id) + if(andDangerouslyFinishTransactionAutomatically){ + await transaction.finish() + resolve(nil) + }else{ + transactions[transactionId]=transaction + resolve(transactionId) + } + return + case .userCancelled, .pending: + reject() + return + default: + reject() + return + } + }catch{ + reject() } - } - } - if let prod = product { - addPromise(forKey: prod.productIdentifier, resolve: resolve, reject: reject) - - let payment = SKMutablePayment(product: prod) - - if let applicationUsername = applicationUsername { - payment.applicationUsername = applicationUsername - } - SKPaymentQueue.default().add(payment) + } else { - if hasListeners { - let err = [ - "debugMessage": "Invalid product ID.", - "code": "E_DEVELOPER_ERROR", - "message": "Invalid product ID.", - "productId": sku - ] - - sendEvent(withName: "purchase-error", body: err) - } - reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) } } + public enum StoreError: Error { + case failedVerification + } + func checkVerified(_ result: VerificationResult) throws -> T { + //Check whether the JWS passes StoreKit verification. + switch result { + case .unverified: + //StoreKit parses the JWS, but it fails verification. + throw StoreError.failedVerification + case .verified(let safe): + //The result is verified. Return the unwrapped value. + return safe + } + } - @objc public func buyProductWithOffer( + @objc public func buyProductWithOffer( // TODO: merge with regular buy product _ sku: String, forUser usernameHash: String, withOffer discountOffer: [String: String], @@ -277,7 +291,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } } - @objc public func buyProductWithQuantityIOS( + @objc public func buyProductWithQuantityIOS( // TODO merge with regular buy _ sku: String, quantity: Int, resolve: @escaping RCTPromiseResolveBlock = { _ in }, @@ -315,16 +329,17 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver @objc public func clearTransaction( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - let pendingTrans = SKPaymentQueue.default().transactions - countPendingTransaction = pendingTrans.count + ) async { + + countPendingTransaction = transactions.count debugMessage("clear remaining Transactions (\(countPendingTransaction)). Call this before make a new transaction") if countPendingTransaction > 0 { addPromise(forKey: "cleaningTransactions", resolve: resolve, reject: reject) - for transaction in pendingTrans { - SKPaymentQueue.default().finishTransaction(transaction) + for transaction in transactions { + await transaction.value.finish() + transactions.removeValue(forKey: transaction.key) } } else { resolve(nil) From 1c9787799f75387a0b82957bb8c0269c76a84d91 Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Fri, 12 Aug 2022 17:33:19 -0700 Subject: [PATCH 02/16] consolidated buy methods --- docs/docs/api_reference/methods.md | 3 +- .../usage_instructions/receipt_validation.md | 21 -- ios/RNIapIos.m | 15 +- ios/RNIapIos.swift | 258 ++++++------------ src/iap.ts | 45 +-- src/types/index.ts | 3 + 6 files changed, 97 insertions(+), 248 deletions(-) diff --git a/docs/docs/api_reference/methods.md b/docs/docs/api_reference/methods.md index 34bfc55f4..bc608db8c 100644 --- a/docs/docs/api_reference/methods.md +++ b/docs/docs/api_reference/methods.md @@ -13,8 +13,7 @@ | `requestPurchaseWithQuantityIOS(sku: string, quantity: number)`
  • sku: product ID/sku
  • quantity: Quantity
| `void` | **iOS only** Buy a product with a specified quantity. `purchaseUpdatedListener` will receive the result | | _\*deprecated_ ~~`buySubscription(sku: string)`~~
  • sku: subscription ID/sku
| `void` | Create (buy) a subscription to a sku. | | `requestSubscription(sku: string, andDangerouslyFinishTransactionAutomaticallyIOS: boolean, /**oldSkuAndroid: string **WARNING** this parameter has been removed*/, purchaseTokenAndroid: string, prorationModeAndroid: ProrationModesAndroid, obfuscatedAccountIdAndroid: string, obfuscatedProfileIdAndroid: string)`
  • sku: subscription ID/sku
  • prorationModeAndroid: one of undefined, UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY, IMMEDIATE_WITH_TIME_PRORATION, IMMEDIATE_AND_CHARGE_PRORATED_PRICE, IMMEDIATE_WITHOUT_PRORATION, DEFERRED
| Promise | Create (buy) a subscription to a sku. **Note:** Promise resolves to null when using proratioModesAndroid=DEFERRED, and to a SubscriptionPurchase otherwise | -| `clearTransactionIOS()` | `void` | **iOS only** Clear up unfinished transanctions which sometimes cause problems. Read more in [#257](https://github.com/dooboolab/react-native-iap/issues/257), [#801](https://github.com/dooboolab/react-native-iap/issues/801). | -| `clearProductsIOS()` | `void` | **iOS only** Clear all products and subscriptions. Read more in below README. | +| `clearTransactionIOS()` | `void` | **iOS only** Clear up unfinished transanctions which sometimes cause problems. Read more in [#257](https://github.com/dooboolab/react-native-iap/issues/257), [#801](https://github.com/dooboolab/react-native-iap/issues/801). | | | `getReceiptIOS()` | `Promise` | **iOS only** Get the current receipt. | | `getPendingPurchasesIOS()` | `Promise` | **IOS only** Gets all the transactions which are pending to be finished. | | `validateReceiptIos(body: Record, devMode: boolean)`
  • body: receiptBody
  • devMode: isTest
| Object | boolean | **iOS only** Validate receipt. | diff --git a/docs/docs/usage_instructions/receipt_validation.md b/docs/docs/usage_instructions/receipt_validation.md index 5110f7bf5..896d3e36c 100644 --- a/docs/docs/usage_instructions/receipt_validation.md +++ b/docs/docs/usage_instructions/receipt_validation.md @@ -57,27 +57,6 @@ flow) or unstable internet connections. For these cases we have a convenience method `getReceiptIOS()` which gets the latest receipt for the app at any given time. The response is base64 encoded. -### iOS Purchasing process right way. - -Issue regarding `valid products` - -- In iOS, generally you are fetching valid products at App launching process. - - If you fetch again, or fetch valid subscription, the products are added to - the array object in iOS side (Objective-C `NSMutableArray`). - - This makes unexpected behavior when you fetch with a part of product lists. - - For example, if you have products of `[A, B, C]`, and you call fetch function - with only `[A]`, this module returns `[A, B, C]`). - - This is weird, but it works. - -- But, weird result is weird, so we made a new method which remove all valid products. - - If you need to clear all products, subscriptions in that array, just call - `clearProductsIOS()`, and do the fetching job again, and you will receive what - you expected. ### Example backend (Node.js) diff --git a/ios/RNIapIos.m b/ios/RNIapIos.m index f7882219e..450ee0d57 100644 --- a/ios/RNIapIos.m +++ b/ios/RNIapIos.m @@ -25,17 +25,8 @@ @interface RCT_EXTERN_MODULE (RNIapIos, NSObject) (NSString*)sku andDangerouslyFinishTransactionAutomatically:(BOOL)andDangerouslyFinishTransactionAutomatically applicationUsername:(NSString*)applicationUsername - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(buyProductWithOffer: - (NSString*)sku - forUser:(NSString*)usernameHash - withOffer:(NSDictionary*)discountOffer - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(buyProductWithQuantityIOS: - (NSString*)sku quantity:(NSInteger)quantity + withOffer:(NSDictionary*)discountOffer resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) @@ -43,10 +34,6 @@ @interface RCT_EXTERN_MODULE (RNIapIos, NSObject) (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(clearProducts: - (RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) - RCT_EXTERN_METHOD(promotedProduct: (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index 2ed4ce333..3ac63b1cb 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -35,60 +35,53 @@ extension SKProductsRequest { @objc(RNIapIos) class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver, SKProductsRequestDelegate { private var promisesByKey: [String: [RNIapIosPromise]] - private var myQueue: DispatchQueue - private var hasListeners = false + private var receiptBlock: ((Data?, Error?) -> Void)? // Block to handle request the receipt async from delegate private var products: [String:Product] private var transactions: [String: Transaction] + var updateListenerTask: Task? = nil private var promotedPayment: SKPayment? - private var promotedProduct: SKProduct? + private var promotedProduct: Product? private var productsRequest: SKProductsRequest? private var countPendingTransaction: Int = 0 private var hasTransactionObserver = false override init() { promisesByKey = [String: [RNIapIosPromise]]() - myQueue = DispatchQueue(label: "reject") - products = [String: Product]() + products = [String: Product]() super.init() - addTransactionObserver() + updateListenerTask = listenForTransactions() } deinit { - removeTransactionObserver() + updateListenerTask?.cancel() } override class func requiresMainQueueSetup() -> Bool { return true } - func addTransactionObserver() { - if !hasTransactionObserver { - hasTransactionObserver = true - SKPaymentQueue.default().add(self) - } - } - - func removeTransactionObserver() { - if hasTransactionObserver { - hasTransactionObserver = false - SKPaymentQueue.default().remove(self) - } - } - - func flushUnheardEvents() { - paymentQueue(SKPaymentQueue.default(), updatedTransactions: SKPaymentQueue.default().transactions) - } + func listenForTransactions() -> Task { + return Task.detached { + //Iterate through any transactions that don't come from a direct call to `purchase()`. + for await result in Transaction.updates { + do { + let transaction = try self.checkVerified(result) - override func startObserving() { - hasListeners = true - flushUnheardEvents() - } + //Deliver products to the user. + await self.updateCustomerProductStatus() - override func stopObserving() { - hasListeners = false - } + //Always finish a transaction. + await transaction.finish() + } catch { + //StoreKit has a transaction that fails verification. Don't deliver content to the user. + print("Transaction failed verification") + } + } + } + } + override func addListener(_ eventName: String?) { super.addListener(eventName) @@ -133,12 +126,9 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { - promotedProduct = product + promotedProduct = Product.SubscriptionOffer promotedPayment = payment - - if hasListeners { sendEvent(withName: "iap-promoted-product", body: product.productIdentifier) - } return false } @@ -150,7 +140,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { - addTransactionObserver() + let canMakePayments = SKPaymentQueue.canMakePayments() resolve(NSNumber(value: canMakePayments)) } @@ -158,7 +148,8 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { - removeTransactionObserver() + updateListenerTask?.cancel() + updateListenerTask = nil resolve(nil) } @objc public func getItems( @@ -177,15 +168,64 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver @objc public func getAvailableItems( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - addPromise(forKey: "availableItems", resolve: resolve, reject: reject) - SKPaymentQueue.default().restoreCompletedTransactions() + ) async { + var purchasedItems: [Product] = [] + //Iterate through all of the user's purchased products. + for await result in Transaction.currentEntitlements { + do { + //Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. + let transaction = try checkVerified(result) + + //Check the `productType` of the transaction and get the corresponding product from the store. + switch transaction.productType { + case .nonConsumable: + if let product = products[transaction.productID] { + purchasedItems.append(product) + } + case .nonRenewable: + if let nonRenewable = products[transaction.productID], + transaction.productID == "nonRenewing.standard" { + //Non-renewing subscriptions have no inherent expiration date, so they're always + //contained in `Transaction.currentEntitlements` after the user purchases them. + //This app defines this non-renewing subscription's expiration date to be one year after purchase. + //If the current date is within one year of the `purchaseDate`, the user is still entitled to this + //product. + let currentDate = Date() + let expirationDate = Calendar(identifier: .gregorian).date(byAdding: DateComponents(year: 1), + to: transaction.purchaseDate)! + + if currentDate < expirationDate { + purchasedItems.append(nonRenewable) + } + } + case .autoRenewable: + if let subscription = products[transaction.productID] { + purchasedItems.append(subscription) + } + default: + break + } + } catch { + print() + reject() + } + } + + + //Check the `subscriptionGroupStatus` to learn the auto-renewable subscription state to determine whether the customer + //is new (never subscribed), active, or inactive (expired subscription). This app has only one subscription + //group, so products in the subscriptions array all belong to the same group. The statuses that + //`product.subscription.status` returns apply to the entire subscription group. + //subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state + resolve(purchasedItems) } @objc public func buyProduct( _ sku: String, andDangerouslyFinishTransactionAutomatically: Bool, - applicationUsername: String?, //TODO + applicationUsername: String?, //TODO convert to appAccountToken?? + quantity: Int, + withOffer discountOffer: [String: String], resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { @@ -193,7 +233,9 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver if let product = product { do { - let result = try await product.purchase() + + let result = try await product.purchase(options: [.quantity(quantity),.promotionalOffer(offerID: discountOffer["identifier"]!, keyID: discountOffer["keyIdentifier"]!, nonce: UUID(uuidString: discountOffer["nonce"]!)!, signature: discountOffer["signature"]!, timestamp: Int(discountOffer["timestamp"]!)), + .appAccountToken(UUID(uuidString: applicationUsername))]) switch result { case .success(let verification): //Check whether the transaction is verified. If it isn't, @@ -243,89 +285,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } } - @objc public func buyProductWithOffer( // TODO: merge with regular buy product - _ sku: String, - forUser usernameHash: String, - withOffer discountOffer: [String: String], - resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - var product: SKProduct? - let lockQueue = DispatchQueue(label: "validProducts") - lockQueue.sync { - for p in validProducts { - if sku == p.productIdentifier { - product = p - break - } - } - } - - if let prod = product { - addPromise(forKey: prod.productIdentifier, resolve: resolve, reject: reject) - - let payment = SKMutablePayment(product: prod) - - if #available(iOS 12.2, tvOS 12.2, *) { - let discount = SKPaymentDiscount( - identifier: discountOffer["identifier"]!, - keyIdentifier: discountOffer["keyIdentifier"]!, - nonce: UUID(uuidString: discountOffer["nonce"]!)!, - signature: discountOffer["signature"]!, - timestamp: NSNumber(value: Int(discountOffer["timestamp"]!)!)) - payment.paymentDiscount = discount - } - payment.applicationUsername = usernameHash - SKPaymentQueue.default().add(payment) - } else { - if hasListeners { - let err = [ - "debugMessage": "Invalid product ID.", - "message": "Invalid product ID.", - "code": "E_DEVELOPER_ERROR", - "productId": sku - ] - sendEvent(withName: "purchase-error", body: err) - } - reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) - } - } - - @objc public func buyProductWithQuantityIOS( // TODO merge with regular buy - _ sku: String, - quantity: Int, - resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - debugMessage("buyProductWithQuantityIOS") - var product: SKProduct? - let lockQueue = DispatchQueue(label: "validProducts") - lockQueue.sync { - for p in validProducts { - if sku == p.productIdentifier { - product = p - break - } - } - } - if let prod = product { - let payment = SKMutablePayment(product: prod) - payment.quantity = quantity - SKPaymentQueue.default().add(payment) - } else { - if hasListeners { - let err = [ - "debugMessage": "Invalid product ID.", - "message": "Invalid product ID.", - "code": "E_DEVELOPER_ERROR", - "productId": sku - ] - sendEvent(withName: "purchase-error", body: err) - } - reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) - } - } - @objc public func clearTransaction( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } @@ -346,21 +305,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } } - @objc public func clearProducts( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - debugMessage("clear valid products") - - let lockQueue = DispatchQueue(label: "validProducts") - - lockQueue.sync { - validProducts.removeAll() - } - - resolve(nil) - } - @objc public func promotedProduct( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } @@ -432,7 +376,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } } - @objc public func presentCodeRedemptionSheet( + @objc public func presentCodeRedemptionSheet(//TODO _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { @@ -457,38 +401,10 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver var items: [[String: Any?]] = [[:]] let lockQueue = DispatchQueue(label: "validProducts") - lockQueue.sync { - for product in validProducts { - items.append(getProductObject(product)) - } - } - + resolvePromises(forKey: request.key, value: items) } - // Add to valid products from Apple server response. Allowing getProducts, getSubscriptions call several times. - // Doesn't allow duplication. Replace new product. - func add(_ aProd: SKProduct) { - let lockQueue = DispatchQueue(label: "validProducts") - - lockQueue.sync { - debugMessage("Add new object: \(aProd.productIdentifier)") - var delTar = -1 - - for k in 0..= 0 { - validProducts.remove(at: delTar) - } - - validProducts.append(aProd) - } - } func request(_ request: SKRequest, didFailWithError error: Error) { let nsError = error as NSError @@ -764,7 +680,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver if #available(iOS 13.0, tvOS 13.0, *) { countryCode = SKPaymentQueue.default().storefront?.countryCode - } else if #available(iOS 10.0, tvOS 10.0, *) { + } else { countryCode = product.priceLocale.regionCode } diff --git a/src/iap.ts b/src/iap.ts index 9d1feaced..645a011a7 100644 --- a/src/iap.ts +++ b/src/iap.ts @@ -276,6 +276,7 @@ export const getAvailablePurchases = (): Promise< * @param {string} [obfuscatedProfileIdAndroid] Specifies an optional obfuscated string that is uniquely associated with the user's profile in your app. * @param {string[]} [skus] Product Ids to purchase. Note that this is only for Android. iOS only uses a single SKU. If not provided, it'll default to using [sku] for backward-compatibility * @param {boolean} [isOfferPersonalized] Defaults to false, Only for Android V5 + * * @returns {Promise} */ @@ -287,6 +288,8 @@ export const requestPurchase = ({ applicationUsername, skus, // Android Billing V5 isOfferPersonalized = undefined, // Android Billing V5 + quantity, + withOffer, }: RequestPurchase): Promise => ( Platform.select({ @@ -301,6 +304,8 @@ export const requestPurchase = ({ sku, andDangerouslyFinishTransactionAutomaticallyIOS, applicationUsername, + quantity, + withOffer, ); }, android: async () => { @@ -381,17 +386,6 @@ export const requestSubscription = ({ }) || Promise.resolve )(); -/** - * Request a purchase for product. This will be received in `PurchaseUpdatedListener`. - * @param {string} sku The product's sku/ID - * @returns {Promise} - */ -export const requestPurchaseWithQuantityIOS = ( - sku: string, - quantity: number, -): Promise => - getIosModule().buyProductWithQuantityIOS(sku, quantity); - /** * Finish Transaction (both platforms) * Abstracts Finish Transaction @@ -451,14 +445,6 @@ export const finishTransaction = ( export const clearTransactionIOS = (): Promise => getIosModule().clearTransaction(); -/** - * Clear valid Products (iOS only) - * Remove all products which are validated by Apple server. - * @returns {void} - */ -export const clearProductsIOS = (): Promise => - getIosModule().clearProducts(); - /** * Acknowledge a product (on Android.) No-op on iOS. * @param {string} token The product's token (on Android) @@ -546,27 +532,6 @@ const requestAgnosticReceiptValidationIos = async ( return response; }; -/** - * Buy products or subscriptions with offers (iOS only) - * - * Runs the payment process with some info you must fetch - * from your server. - * @param {string} sku The product identifier - * @param {string} forUser An user identifier on you system - * @param {Apple.PaymentDiscount} withOffer The offer information - * @param {string} withOffer.identifier The offer identifier - * @param {string} withOffer.keyIdentifier Key identifier that it uses to generate the signature - * @param {string} withOffer.nonce An UUID returned from the server - * @param {string} withOffer.signature The actual signature returned from the server - * @param {number} withOffer.timestamp The timestamp of the signature - * @returns {Promise} - */ -export const requestPurchaseWithOfferIOS = ( - sku: string, - forUser: string, - withOffer: Apple.PaymentDiscount, -): Promise => getIosModule().buyProductWithOffer(sku, forUser, withOffer); - /** * Validate receipt for iOS. * @param {object} receiptBody the receipt body to send to apple server. diff --git a/src/types/index.ts b/src/types/index.ts index 6f4b8400a..3c2f07016 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ +import type * as Apple from './apple'; export type Sku = string; export enum IAPErrorCode { @@ -189,6 +190,8 @@ export interface RequestPurchaseIOS { sku?: Sku; andDangerouslyFinishTransactionAutomaticallyIOS?: boolean; applicationUsername?: string; + quantity: number; + withOffer: Apple.PaymentDiscount; } export type RequestPurchase = RequestPurchaseAndroid & RequestPurchaseIOS; From 8e090ff7c833e07fe0bf5e90fb3b6ee75e8d0ae1 Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Mon, 15 Aug 2022 11:16:32 -0700 Subject: [PATCH 03/16] removed checks for older versions of ios --- ios/RNIapIos.swift | 79 +++++++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index 3ac63b1cb..5d5c235c1 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -41,7 +41,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver private var transactions: [String: Transaction] var updateListenerTask: Task? = nil private var promotedPayment: SKPayment? - private var promotedProduct: Product? + private var promotedProduct: SKProduct? private var productsRequest: SKProductsRequest? private var countPendingTransaction: Int = 0 private var hasTransactionObserver = false @@ -126,7 +126,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { - promotedProduct = Product.SubscriptionOffer + promotedProduct = products.first?.value //TODO promotedPayment = payment sendEvent(withName: "iap-promoted-product", body: product.productIdentifier) return false @@ -158,7 +158,18 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { do{ - let products = try await Product.products(for: skus) + let products: [[String:Any]] = try await Product.products(for: skus).map({ (product: Product) -> [String:Any] in + var prod = [String:Any]() + prod["displayName"] = product.displayName + prod["description"] = product.description + prod["id"] = product.id + prod["displayPrice"] = product.displayPrice + prod["price"] = product.price + prod["isFamilyShareable"] = product.isFamilyShareable + prod["subscription"] = product.subscription?.subscriptionGroupID + return prod + + }) resolve(products) }catch{ reject("E_UNKNOWN","Error fetching items",nil) @@ -207,7 +218,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } } catch { print() - reject() + reject("","",nil) // TODO } } @@ -233,9 +244,24 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver if let product = product { do { + var options: Set = [] + if quantity > -1 { + options.insert(.quantity(quantity)) + } + + let offerID = discountOffer["identifier"] + let keyID = discountOffer["keyIdentifier"] + let nonce = discountOffer["nonce"] + let signature = discountOffer["signature"] + let timestamp = discountOffer["timestamp"] + if let offerID = offerID, let keyID = keyID, let nonce = nonce, let nonce = UUID(uuidString: nonce), let signature = signature, let signature = signature.data(using: .utf8), let timestamp = timestamp, let timestamp = Int(timestamp){ + options.insert(.promotionalOffer(offerID: offerID, keyID: keyID, nonce: nonce, signature: signature, timestamp: timestamp )) + } + if let applicationUsername = applicationUsername, let applicationUsername = UUID(uuidString: applicationUsername) { + options.insert(.appAccountToken(applicationUsername)) + } - let result = try await product.purchase(options: [.quantity(quantity),.promotionalOffer(offerID: discountOffer["identifier"]!, keyID: discountOffer["keyIdentifier"]!, nonce: UUID(uuidString: discountOffer["nonce"]!)!, signature: discountOffer["signature"]!, timestamp: Int(discountOffer["timestamp"]!)), - .appAccountToken(UUID(uuidString: applicationUsername))]) + let result = try await product.purchase(options: options) switch result { case .success(let verification): //Check whether the transaction is verified. If it isn't, @@ -256,20 +282,28 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } return case .userCancelled, .pending: - reject() + reject("","",nil) //TODO return default: - reject() + reject("","",nil)// TODO return } }catch{ - reject() + reject("","",nil)//TODO } } else { reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) } } + + + @MainActor + func updateCustomerProductStatus() async { + + } + + public enum StoreError: Error { case failedVerification } @@ -381,12 +415,8 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { #if !os(tvOS) - if #available(iOS 14.0, tvOS 14.0, *) { SKPaymentQueue.default().presentCodeRedemptionSheet() resolve(nil) - } else { - reject(standardErrorCode(2), "This method only available above iOS 14", nil) - } #else reject(standardErrorCode(2), "This method is not available on tvOS", nil) #endif @@ -606,7 +636,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver var periodUnitIOS = "" var itemType = "iap" - if #available(iOS 11.2, tvOS 11.2, *) { + let numOfUnits = UInt(product.subscriptionPeriod?.numberOfUnits ?? 0) let unit = product.subscriptionPeriod?.unit @@ -672,24 +702,19 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver introductoryPriceNumberOfPeriods = "" introductoryPriceSubscriptionPeriod = "" } - } + - if #available(iOS 10.0, tvOS 10.0, *) { + currencyCode = product.priceLocale.currencyCode - } - - if #available(iOS 13.0, tvOS 13.0, *) { + countryCode = SKPaymentQueue.default().storefront?.countryCode - } else { - countryCode = product.priceLocale.regionCode - } + //countryCode = product.priceLocale.regionCode + var discounts: [[String: String?]]? - if #available(iOS 12.2, tvOS 12.2, *) { discounts = getDiscountData(product) - } - + let obj: [String: Any?] = [ "productId": product.productIdentifier, "price": "\(product.price)", @@ -713,7 +738,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } func getDiscountData(_ product: SKProduct) -> [[String: String?]]? { - if #available(iOS 12.2, tvOS 12.2, *) { var mappedDiscounts: [[String: String?]] = [] var localizedPrice: String? var paymendMode: String? @@ -798,9 +822,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } return mappedDiscounts - } - - return nil } func getPurchaseData(_ transaction: SKPaymentTransaction, withBlock block: @escaping (_ transactionDict: [String: Any]?) -> Void) { From 8b345d15e6791103fed3a69da50ab33b4fbf3848 Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Mon, 15 Aug 2022 17:04:18 -0700 Subject: [PATCH 04/16] cleared most errors --- IapUtils.swift | 239 ++++++++++++++++++++++++++ ios/RNIapIos.m | 2 +- ios/RNIapIos.swift | 410 ++++++++------------------------------------- src/iap.ts | 12 +- src/types/index.ts | 5 +- 5 files changed, 323 insertions(+), 345 deletions(-) create mode 100644 IapUtils.swift diff --git a/IapUtils.swift b/IapUtils.swift new file mode 100644 index 000000000..de24e25ae --- /dev/null +++ b/IapUtils.swift @@ -0,0 +1,239 @@ +// +// IapUtils.swift +// RNIap +// +// Created by Aguilar Andres on 8/15/22. +// + +import Foundation +import StoreKit + +typealias RNIapIosPromise = (RCTPromiseResolveBlock, RCTPromiseRejectBlock) + +public func debugMessage(_ object: Any...) { + #if DEBUG + for item in object { + print("[react-native-iap] \(item)") + } + #endif +} + +// Based on https://stackoverflow.com/a/40135192/570612 +extension Date { + var millisecondsSince1970: Int64 { + return Int64((self.timeIntervalSince1970 * 1000.0).rounded()) + } + + var millisecondsSince1970String: String { + return String((self.timeIntervalSince1970 * 1000.0).rounded()) + } + + init(milliseconds: Int64) { + self = Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000) + } +} + + +func getProductObject(_ product: SKProduct) -> [String: Any?] { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = product.priceLocale + + let localizedPrice = formatter.string(from: product.price) + var introductoryPrice = localizedPrice + var introductoryPriceAsAmountIOS = "\(product.price)" + + var introductoryPricePaymentMode = "" + var introductoryPriceNumberOfPeriods = "" + + var introductoryPriceSubscriptionPeriod = "" + + var currencyCode: String? = "" + var countryCode: String? = "" + var periodNumberIOS = "0" + var periodUnitIOS = "" + var itemType = "iap" + + let numOfUnits = UInt(product.subscriptionPeriod?.numberOfUnits ?? 0) + let unit = product.subscriptionPeriod?.unit + + if unit == .year { + periodUnitIOS = "YEAR" + } else if unit == .month { + periodUnitIOS = "MONTH" + } else if unit == .week { + periodUnitIOS = "WEEK" + } else if unit == .day { + periodUnitIOS = "DAY" + } + + periodNumberIOS = String(format: "%lu", numOfUnits) + if numOfUnits != 0 { + itemType = "subs" + } + + // subscriptionPeriod = product.subscriptionPeriod ? [product.subscriptionPeriod stringValue] : @""; + // introductoryPrice = product.introductoryPrice != nil ? [NSString stringWithFormat:@"%@", product.introductoryPrice] : @""; + if product.introductoryPrice != nil { + formatter.locale = product.introductoryPrice?.priceLocale + + if let price = product.introductoryPrice?.price { + introductoryPrice = formatter.string(from: price) + } + + introductoryPriceAsAmountIOS = product.introductoryPrice?.price.stringValue ?? "" + + switch product.introductoryPrice?.paymentMode { + case .freeTrial: + introductoryPricePaymentMode = "FREETRIAL" + introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.subscriptionPeriod.numberOfUnits ?? 0).stringValue + + case .payAsYouGo: + introductoryPricePaymentMode = "PAYASYOUGO" + introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.numberOfPeriods ?? 0).stringValue + + case .payUpFront: + introductoryPricePaymentMode = "PAYUPFRONT" + introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.subscriptionPeriod.numberOfUnits ?? 0).stringValue + + default: + introductoryPricePaymentMode = "" + introductoryPriceNumberOfPeriods = "0" + } + + if product.introductoryPrice?.subscriptionPeriod.unit == .day { + introductoryPriceSubscriptionPeriod = "DAY" + } else if product.introductoryPrice?.subscriptionPeriod.unit == .week { + introductoryPriceSubscriptionPeriod = "WEEK" + } else if product.introductoryPrice?.subscriptionPeriod.unit == .month { + introductoryPriceSubscriptionPeriod = "MONTH" + } else if product.introductoryPrice?.subscriptionPeriod.unit == .year { + introductoryPriceSubscriptionPeriod = "YEAR" + } else { + introductoryPriceSubscriptionPeriod = "" + } + } else { + introductoryPrice = "" + introductoryPriceAsAmountIOS = "" + introductoryPricePaymentMode = "" + introductoryPriceNumberOfPeriods = "" + introductoryPriceSubscriptionPeriod = "" + } + + currencyCode = product.priceLocale.currencyCode + + countryCode = SKPaymentQueue.default().storefront?.countryCode + // countryCode = product.priceLocale.regionCode + + var discounts: [[String: String?]]? + + discounts = getDiscountData(product) + + let obj: [String: Any?] = [ + "productId": product.productIdentifier, + "price": "\(product.price)", + "currency": currencyCode, + "countryCode": countryCode ?? "", + "type": itemType, + "title": product.localizedTitle != "" ? product.localizedTitle : "", + "description": product.localizedDescription != "" ? product.localizedDescription : "", + "localizedPrice": localizedPrice, + "subscriptionPeriodNumberIOS": periodNumberIOS, + "subscriptionPeriodUnitIOS": periodUnitIOS, + "introductoryPrice": introductoryPrice, + "introductoryPriceAsAmountIOS": introductoryPriceAsAmountIOS, + "introductoryPricePaymentModeIOS": introductoryPricePaymentMode, + "introductoryPriceNumberOfPeriodsIOS": introductoryPriceNumberOfPeriods, + "introductoryPriceSubscriptionPeriodIOS": introductoryPriceSubscriptionPeriod, + "discounts": discounts + ] + + return obj +} + +func getDiscountData(_ product: SKProduct) -> [[String: String?]]? { + var mappedDiscounts: [[String: String?]] = [] + var localizedPrice: String? + var paymendMode: String? + var subscriptionPeriods: String? + var discountType: String? + + for discount in product.discounts { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + let priceLocale: Locale? = discount.priceLocale + if let pLocale = priceLocale { + formatter.locale = pLocale + } + localizedPrice = formatter.string(from: discount.price) + var numberOfPeriods: String? + + switch discount.paymentMode { + case .freeTrial: + paymendMode = "FREETRIAL" + numberOfPeriods = NSNumber(value: discount.subscriptionPeriod.numberOfUnits ).stringValue + break + + case .payAsYouGo: + paymendMode = "PAYASYOUGO" + numberOfPeriods = NSNumber(value: discount.numberOfPeriods).stringValue + break + + case .payUpFront: + paymendMode = "PAYUPFRONT" + numberOfPeriods = NSNumber(value: discount.subscriptionPeriod.numberOfUnits ).stringValue + break + + default: + paymendMode = "" + numberOfPeriods = "0" + break + } + + switch discount.subscriptionPeriod.unit { + case .day: + subscriptionPeriods = "DAY" + + case .week: + subscriptionPeriods = "WEEK" + + case .month: + subscriptionPeriods = "MONTH" + + case .year: + subscriptionPeriods = "YEAR" + + default: + subscriptionPeriods = "" + } + + let discountIdentifier = discount.identifier + switch discount.type { + case SKProductDiscount.Type.introductory: + discountType = "INTRODUCTORY" + break + + case SKProductDiscount.Type.subscription: + discountType = "SUBSCRIPTION" + break + + default: + discountType = "" + break + } + + let discountObj = [ + "identifier": discountIdentifier, + "type": discountType, + "numberOfPeriods": numberOfPeriods, + "price": "\(discount.price)", + "localizedPrice": localizedPrice, + "paymentMode": paymendMode, + "subscriptionPeriod": subscriptionPeriods + ] + + mappedDiscounts.append(discountObj) + } + + return mappedDiscounts +} diff --git a/ios/RNIapIos.m b/ios/RNIapIos.m index 450ee0d57..f7c67cba0 100644 --- a/ios/RNIapIos.m +++ b/ios/RNIapIos.m @@ -24,7 +24,7 @@ @interface RCT_EXTERN_MODULE (RNIapIos, NSObject) RCT_EXTERN_METHOD(buyProduct: (NSString*)sku andDangerouslyFinishTransactionAutomatically:(BOOL)andDangerouslyFinishTransactionAutomatically - applicationUsername:(NSString*)applicationUsername + appAccountToken:(NSString*)appAccountToken quantity:(NSInteger)quantity withOffer:(NSDictionary*)discountOffer resolve:(RCTPromiseResolveBlock)resolve diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index 5d5c235c1..606e55f1a 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -1,31 +1,6 @@ import React import StoreKit -typealias RNIapIosPromise = (RCTPromiseResolveBlock, RCTPromiseRejectBlock) - -public func debugMessage(_ object: Any...) { - #if DEBUG - for item in object { - print("[react-native-iap] \(item)") - } - #endif -} - -// Based on https://stackoverflow.com/a/40135192/570612 -extension Date { - var millisecondsSince1970: Int64 { - return Int64((self.timeIntervalSince1970 * 1000.0).rounded()) - } - - var millisecondsSince1970String: String { - return String((self.timeIntervalSince1970 * 1000.0).rounded()) - } - - init(milliseconds: Int64) { - self = Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000) - } -} - extension SKProductsRequest { var key: String { return String(self.hashValue) @@ -33,13 +8,14 @@ extension SKProductsRequest { } @objc(RNIapIos) -class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver, SKProductsRequestDelegate { +class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver { private var promisesByKey: [String: [RNIapIosPromise]] - + private var hasListeners = false + private var pendingTransactionWithAutoFinish = false // TODO: private var receiptBlock: ((Data?, Error?) -> Void)? // Block to handle request the receipt async from delegate - private var products: [String:Product] + private var products: [String: Product] private var transactions: [String: Transaction] - var updateListenerTask: Task? = nil + var updateListenerTask: Task? private var promotedPayment: SKPayment? private var promotedProduct: SKProduct? private var productsRequest: SKProductsRequest? @@ -49,6 +25,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver override init() { promisesByKey = [String: [RNIapIosPromise]]() products = [String: Product]() + transactions = [String: Transaction]() super.init() updateListenerTask = listenForTransactions() } @@ -63,25 +40,31 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver func listenForTransactions() -> Task { return Task.detached { - //Iterate through any transactions that don't come from a direct call to `purchase()`. + // Iterate through any transactions that don't come from a direct call to `purchase()`. for await result in Transaction.updates { do { let transaction = try self.checkVerified(result) - //Deliver products to the user. + // Deliver products to the user. await self.updateCustomerProductStatus() - //Always finish a transaction. + // Always finish a transaction. await transaction.finish() } catch { - //StoreKit has a transaction that fails verification. Don't deliver content to the user. + // StoreKit has a transaction that fails verification. Don't deliver content to the user. print("Transaction failed verification") } } } } - + override func startObserving() { + hasListeners = true + } + + override func stopObserving() { + hasListeners = false + } override func addListener(_ eventName: String?) { super.addListener(eventName) @@ -126,7 +109,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { - promotedProduct = products.first?.value //TODO + promotedProduct = product promotedPayment = payment sendEvent(withName: "iap-promoted-product", body: product.productIdentifier) return false @@ -140,7 +123,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { - + let canMakePayments = SKPaymentQueue.canMakePayments() resolve(NSNumber(value: canMakePayments)) } @@ -157,9 +140,9 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { - do{ - let products: [[String:Any]] = try await Product.products(for: skus).map({ (product: Product) -> [String:Any] in - var prod = [String:Any]() + do { + let products: [[String: Any]] = try await Product.products(for: skus).map({ (product: Product) -> [String: Any] in + var prod = [String: Any]() prod["displayName"] = product.displayName prod["description"] = product.description prod["id"] = product.id @@ -168,26 +151,26 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver prod["isFamilyShareable"] = product.isFamilyShareable prod["subscription"] = product.subscription?.subscriptionGroupID return prod - + }) resolve(products) - }catch{ - reject("E_UNKNOWN","Error fetching items",nil) + } catch { + reject("E_UNKNOWN", "Error fetching items", nil) } - + } @objc public func getAvailableItems( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { var purchasedItems: [Product] = [] - //Iterate through all of the user's purchased products. + // Iterate through all of the user's purchased products. for await result in Transaction.currentEntitlements { do { - //Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. + // Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. let transaction = try checkVerified(result) - //Check the `productType` of the transaction and get the corresponding product from the store. + // Check the `productType` of the transaction and get the corresponding product from the store. switch transaction.productType { case .nonConsumable: if let product = products[transaction.productID] { @@ -196,11 +179,11 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver case .nonRenewable: if let nonRenewable = products[transaction.productID], transaction.productID == "nonRenewing.standard" { - //Non-renewing subscriptions have no inherent expiration date, so they're always - //contained in `Transaction.currentEntitlements` after the user purchases them. - //This app defines this non-renewing subscription's expiration date to be one year after purchase. - //If the current date is within one year of the `purchaseDate`, the user is still entitled to this - //product. + // Non-renewing subscriptions have no inherent expiration date, so they're always + // contained in `Transaction.currentEntitlements` after the user purchases them. + // This app defines this non-renewing subscription's expiration date to be one year after purchase. + // If the current date is within one year of the `purchaseDate`, the user is still entitled to this + // product. let currentDate = Date() let expirationDate = Calendar(identifier: .gregorian).date(byAdding: DateComponents(year: 1), to: transaction.purchaseDate)! @@ -218,103 +201,101 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } } catch { print() - reject("","",nil) // TODO + reject("", "", nil) // TODO } } - - //Check the `subscriptionGroupStatus` to learn the auto-renewable subscription state to determine whether the customer - //is new (never subscribed), active, or inactive (expired subscription). This app has only one subscription - //group, so products in the subscriptions array all belong to the same group. The statuses that - //`product.subscription.status` returns apply to the entire subscription group. - //subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state + // Check the `subscriptionGroupStatus` to learn the auto-renewable subscription state to determine whether the customer + // is new (never subscribed), active, or inactive (expired subscription). This app has only one subscription + // group, so products in the subscriptions array all belong to the same group. The statuses that + // `product.subscription.status` returns apply to the entire subscription group. + // subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state resolve(purchasedItems) } @objc public func buyProduct( _ sku: String, andDangerouslyFinishTransactionAutomatically: Bool, - applicationUsername: String?, //TODO convert to appAccountToken?? + appAccountToken: String?, quantity: Int, withOffer discountOffer: [String: String], resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { + pendingTransactionWithAutoFinish = andDangerouslyFinishTransactionAutomatically let product: Product? = products[sku] - + if let product = product { do { var options: Set = [] if quantity > -1 { options.insert(.quantity(quantity)) } - + let offerID = discountOffer["identifier"] let keyID = discountOffer["keyIdentifier"] let nonce = discountOffer["nonce"] let signature = discountOffer["signature"] let timestamp = discountOffer["timestamp"] - if let offerID = offerID, let keyID = keyID, let nonce = nonce, let nonce = UUID(uuidString: nonce), let signature = signature, let signature = signature.data(using: .utf8), let timestamp = timestamp, let timestamp = Int(timestamp){ + if let offerID = offerID, let keyID = keyID, let nonce = nonce, let nonce = UUID(uuidString: nonce), let signature = signature, let signature = signature.data(using: .utf8), let timestamp = timestamp, let timestamp = Int(timestamp) { options.insert(.promotionalOffer(offerID: offerID, keyID: keyID, nonce: nonce, signature: signature, timestamp: timestamp )) } - if let applicationUsername = applicationUsername, let applicationUsername = UUID(uuidString: applicationUsername) { - options.insert(.appAccountToken(applicationUsername)) + if let appAccountToken = appAccountToken, let appAccountToken = UUID(uuidString: appAccountToken) { + options.insert(.appAccountToken(appAccountToken)) } - + let result = try await product.purchase(options: options) switch result { case .success(let verification): - //Check whether the transaction is verified. If it isn't, - //this function rethrows the verification error. + // Check whether the transaction is verified. If it isn't, + // this function rethrows the verification error. let transaction = try checkVerified(verification) - //The transaction is verified. Deliver content to the user. + // The transaction is verified. Deliver content to the user. // Do on JS :await updateCustomerProductStatus() - //Always finish a transaction. + // Always finish a transaction. let transactionId = String(transaction.id) - if(andDangerouslyFinishTransactionAutomatically){ + if andDangerouslyFinishTransactionAutomatically { await transaction.finish() resolve(nil) - }else{ + } else { transactions[transactionId]=transaction resolve(transactionId) } return case .userCancelled, .pending: - reject("","",nil) //TODO + reject("", "", nil) // TODO return default: - reject("","",nil)// TODO + reject("", "", nil)// TODO return } - }catch{ - reject("","",nil)//TODO + } catch { + reject("", "", nil)// TODO } - + } else { reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) } } - - + @MainActor func updateCustomerProductStatus() async { - + } - - + public enum StoreError: Error { case failedVerification } func checkVerified(_ result: VerificationResult) throws -> T { - //Check whether the JWS passes StoreKit verification. + // Check whether the JWS passes StoreKit verification. switch result { case .unverified: - //StoreKit parses the JWS, but it fails verification. + // StoreKit parses the JWS, but it fails verification. throw StoreError.failedVerification case .verified(let safe): - //The result is verified. Return the unwrapped value. + // The result is verified. Return the unwrapped value. return safe } } @@ -323,7 +304,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { - + countPendingTransaction = transactions.count debugMessage("clear remaining Transactions (\(countPendingTransaction)). Call this before make a new transaction") @@ -410,7 +391,11 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } } - @objc public func presentCodeRedemptionSheet(//TODO + /** + Should remain the same according to: + https://stackoverflow.com/a/72789651/570612 + */ + @objc public func presentCodeRedemptionSheet( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { @@ -422,43 +407,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver #endif } - // StoreKitDelegate - func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { - for prod in response.products { - add(prod) - } - - var items: [[String: Any?]] = [[:]] - let lockQueue = DispatchQueue(label: "validProducts") - - - resolvePromises(forKey: request.key, value: items) - } - - - func request(_ request: SKRequest, didFailWithError error: Error) { - let nsError = error as NSError - - if request is SKReceiptRefreshRequest { - if let unwrappedReceiptBlock = receiptBlock { - let standardError = NSError(domain: nsError.domain, code: 9, userInfo: nsError.userInfo) - unwrappedReceiptBlock(nil, standardError) - receiptBlock = nil - return - } else { - if let key: String = productsRequest?.key { - myQueue.sync(execute: { [self] in - rejectPromises( - forKey: key, - code: standardErrorCode(nsError.code), - message: error.localizedDescription, - error: error)} - ) - } - } - } - } - func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { for transaction in transactions { switch transaction.transactionState { @@ -479,7 +427,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver case .deferred: debugMessage("Deferred (awaiting approval via parental controls, etc.)") - myQueue.sync(execute: { [self] in if hasListeners { let err = [ "debugMessage": "The payment was deferred (awaiting approval via parental controls for instance)", @@ -497,14 +444,12 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver code: "E_DEFERRED_PAYMENT", message: "The payment was deferred (awaiting approval via parental controls for instance)", error: nil) - }) case .failed: debugMessage("Purchase Failed") SKPaymentQueue.default().finishTransaction(transaction) - myQueue.sync(execute: { [self] in let nsError = transaction.error as NSError? if hasListeners { @@ -526,9 +471,10 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver code: standardErrorCode(nsError?.code), message: nsError?.localizedDescription, error: nsError) - }) break + @unknown default: + fatalError() } } } @@ -563,13 +509,11 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { - myQueue.sync(execute: { [self] in rejectPromises( forKey: "availableItems", code: standardErrorCode((error as NSError).code), message: error.localizedDescription, error: error) - }) debugMessage("restoreCompletedTransactionsFailedWithError") } @@ -616,214 +560,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver return descriptions[code] } - func getProductObject(_ product: SKProduct) -> [String: Any?] { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.locale = product.priceLocale - - let localizedPrice = formatter.string(from: product.price) - var introductoryPrice = localizedPrice - var introductoryPriceAsAmountIOS = "\(product.price)" - - var introductoryPricePaymentMode = "" - var introductoryPriceNumberOfPeriods = "" - - var introductoryPriceSubscriptionPeriod = "" - - var currencyCode: String? = "" - var countryCode: String? = "" - var periodNumberIOS = "0" - var periodUnitIOS = "" - var itemType = "iap" - - - let numOfUnits = UInt(product.subscriptionPeriod?.numberOfUnits ?? 0) - let unit = product.subscriptionPeriod?.unit - - if unit == .year { - periodUnitIOS = "YEAR" - } else if unit == .month { - periodUnitIOS = "MONTH" - } else if unit == .week { - periodUnitIOS = "WEEK" - } else if unit == .day { - periodUnitIOS = "DAY" - } - - periodNumberIOS = String(format: "%lu", numOfUnits) - if numOfUnits != 0 { - itemType = "subs" - } - - // subscriptionPeriod = product.subscriptionPeriod ? [product.subscriptionPeriod stringValue] : @""; - // introductoryPrice = product.introductoryPrice != nil ? [NSString stringWithFormat:@"%@", product.introductoryPrice] : @""; - if product.introductoryPrice != nil { - formatter.locale = product.introductoryPrice?.priceLocale - - if let price = product.introductoryPrice?.price { - introductoryPrice = formatter.string(from: price) - } - - introductoryPriceAsAmountIOS = product.introductoryPrice?.price.stringValue ?? "" - - switch product.introductoryPrice?.paymentMode { - case .freeTrial: - introductoryPricePaymentMode = "FREETRIAL" - introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.subscriptionPeriod.numberOfUnits ?? 0).stringValue - - case .payAsYouGo: - introductoryPricePaymentMode = "PAYASYOUGO" - introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.numberOfPeriods ?? 0).stringValue - - case .payUpFront: - introductoryPricePaymentMode = "PAYUPFRONT" - introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.subscriptionPeriod.numberOfUnits ?? 0).stringValue - - default: - introductoryPricePaymentMode = "" - introductoryPriceNumberOfPeriods = "0" - } - - if product.introductoryPrice?.subscriptionPeriod.unit == .day { - introductoryPriceSubscriptionPeriod = "DAY" - } else if product.introductoryPrice?.subscriptionPeriod.unit == .week { - introductoryPriceSubscriptionPeriod = "WEEK" - } else if product.introductoryPrice?.subscriptionPeriod.unit == .month { - introductoryPriceSubscriptionPeriod = "MONTH" - } else if product.introductoryPrice?.subscriptionPeriod.unit == .year { - introductoryPriceSubscriptionPeriod = "YEAR" - } else { - introductoryPriceSubscriptionPeriod = "" - } - } else { - introductoryPrice = "" - introductoryPriceAsAmountIOS = "" - introductoryPricePaymentMode = "" - introductoryPriceNumberOfPeriods = "" - introductoryPriceSubscriptionPeriod = "" - } - - - - currencyCode = product.priceLocale.currencyCode - - countryCode = SKPaymentQueue.default().storefront?.countryCode - //countryCode = product.priceLocale.regionCode - - - var discounts: [[String: String?]]? - - discounts = getDiscountData(product) - - let obj: [String: Any?] = [ - "productId": product.productIdentifier, - "price": "\(product.price)", - "currency": currencyCode, - "countryCode": countryCode ?? "", - "type": itemType, - "title": product.localizedTitle != "" ? product.localizedTitle : "", - "description": product.localizedDescription != "" ? product.localizedDescription : "", - "localizedPrice": localizedPrice, - "subscriptionPeriodNumberIOS": periodNumberIOS, - "subscriptionPeriodUnitIOS": periodUnitIOS, - "introductoryPrice": introductoryPrice, - "introductoryPriceAsAmountIOS": introductoryPriceAsAmountIOS, - "introductoryPricePaymentModeIOS": introductoryPricePaymentMode, - "introductoryPriceNumberOfPeriodsIOS": introductoryPriceNumberOfPeriods, - "introductoryPriceSubscriptionPeriodIOS": introductoryPriceSubscriptionPeriod, - "discounts": discounts - ] - - return obj - } - - func getDiscountData(_ product: SKProduct) -> [[String: String?]]? { - var mappedDiscounts: [[String: String?]] = [] - var localizedPrice: String? - var paymendMode: String? - var subscriptionPeriods: String? - var discountType: String? - - for discount in product.discounts { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - let priceLocale: Locale? = discount.priceLocale - if let pLocale = priceLocale { - formatter.locale = pLocale - } - localizedPrice = formatter.string(from: discount.price) - var numberOfPeriods: String? - - switch discount.paymentMode { - case .freeTrial: - paymendMode = "FREETRIAL" - numberOfPeriods = NSNumber(value: discount.subscriptionPeriod.numberOfUnits ).stringValue - break - - case .payAsYouGo: - paymendMode = "PAYASYOUGO" - numberOfPeriods = NSNumber(value: discount.numberOfPeriods).stringValue - break - - case .payUpFront: - paymendMode = "PAYUPFRONT" - numberOfPeriods = NSNumber(value: discount.subscriptionPeriod.numberOfUnits ).stringValue - break - - default: - paymendMode = "" - numberOfPeriods = "0" - break - } - - switch discount.subscriptionPeriod.unit { - case .day: - subscriptionPeriods = "DAY" - - case .week: - subscriptionPeriods = "WEEK" - - case .month: - subscriptionPeriods = "MONTH" - - case .year: - subscriptionPeriods = "YEAR" - - default: - subscriptionPeriods = "" - } - - let discountIdentifier = discount.identifier - switch discount.type { - case SKProductDiscount.Type.introductory: - discountType = "INTRODUCTORY" - break - - case SKProductDiscount.Type.subscription: - discountType = "SUBSCRIPTION" - break - - default: - discountType = "" - break - } - - let discountObj = [ - "identifier": discountIdentifier, - "type": discountType, - "numberOfPeriods": numberOfPeriods, - "price": "\(discount.price)", - "localizedPrice": localizedPrice, - "paymentMode": paymendMode, - "subscriptionPeriod": subscriptionPeriods - ] - - mappedDiscounts.append(discountObj) - } - - return mappedDiscounts - } - func getPurchaseData(_ transaction: SKPaymentTransaction, withBlock block: @escaping (_ transactionDict: [String: Any]?) -> Void) { requestReceiptData(withBlock: false) { receiptData, _ in if receiptData == nil { diff --git a/src/iap.ts b/src/iap.ts index 645a011a7..75492c457 100644 --- a/src/iap.ts +++ b/src/iap.ts @@ -270,7 +270,7 @@ export const getAvailablePurchases = (): Promise< /** * Request a purchase for product. This will be received in `PurchaseUpdatedListener`. * @param {string} sku The product's sku/ID - * @param {string} [applicationUsername] The purchaser's user ID + * @param {string} [appAccountToken] UUID representing the purchaser * @param {boolean} [andDangerouslyFinishTransactionAutomaticallyIOS] You should set this to false and call finishTransaction manually when you have delivered the purchased goods to the user. It defaults to true to provide backwards compatibility. Will default to false in version 4.0.0. * @param {string} [obfuscatedAccountIdAndroid] Specifies an optional obfuscated string that is uniquely associated with the user's account in your app. * @param {string} [obfuscatedProfileIdAndroid] Specifies an optional obfuscated string that is uniquely associated with the user's profile in your app. @@ -285,7 +285,7 @@ export const requestPurchase = ({ andDangerouslyFinishTransactionAutomaticallyIOS = false, obfuscatedAccountIdAndroid, obfuscatedProfileIdAndroid, - applicationUsername, + appAccountToken, skus, // Android Billing V5 isOfferPersonalized = undefined, // Android Billing V5 quantity, @@ -303,7 +303,7 @@ export const requestPurchase = ({ return getIosModule().buyProduct( sku, andDangerouslyFinishTransactionAutomaticallyIOS, - applicationUsername, + appAccountToken, quantity, withOffer, ); @@ -326,7 +326,7 @@ export const requestPurchase = ({ /** * Request a purchase for product. This will be received in `PurchaseUpdatedListener`. * @param {string} [sku] The product's sku/ID - * @param {string} [applicationUsername] The purchaser's user ID + * @param {string} [appAccountToken] The purchaser's user ID * @param {boolean} [andDangerouslyFinishTransactionAutomaticallyIOS] You should set this to false and call finishTransaction manually when you have delivered the purchased goods to the user. It defaults to true to provide backwards compatibility. Will default to false in version 4.0.0. * @param {string} [purchaseTokenAndroid] purchaseToken that the user is upgrading or downgrading from (Android). * @param {ProrationModesAndroid} [prorationModeAndroid] UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY, IMMEDIATE_WITH_TIME_PRORATION, IMMEDIATE_AND_CHARGE_PRORATED_PRICE, IMMEDIATE_WITHOUT_PRORATION, DEFERRED @@ -344,7 +344,7 @@ export const requestSubscription = ({ obfuscatedProfileIdAndroid, subscriptionOffers = undefined, // Android Billing V5 isOfferPersonalized = undefined, // Android Billing V5 - applicationUsername, + appAccountToken, }: RequestSubscription): Promise => ( Platform.select({ @@ -358,7 +358,7 @@ export const requestSubscription = ({ return getIosModule().buyProduct( sku, andDangerouslyFinishTransactionAutomaticallyIOS, - applicationUsername, + appAccountToken, ); }, android: async () => { diff --git a/src/types/index.ts b/src/types/index.ts index 3c2f07016..614b3cbfd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -189,7 +189,10 @@ export interface RequestPurchaseAndroid extends RequestPurchaseBaseAndroid { export interface RequestPurchaseIOS { sku?: Sku; andDangerouslyFinishTransactionAutomaticallyIOS?: boolean; - applicationUsername?: string; + /** + * UUID representing user account + */ + appAccountToken?: string; quantity: number; withOffer: Apple.PaymentDiscount; } From 159f6b324a41383b187e0c7ba033037e7dfe87ec Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Mon, 15 Aug 2022 17:13:07 -0700 Subject: [PATCH 05/16] swiftlint --- IapUtils.swift | 289 ++++++++++++++++--------------- ios/RNIapIos.swift | 414 ++++++++++++++++++++++----------------------- 2 files changed, 351 insertions(+), 352 deletions(-) diff --git a/IapUtils.swift b/IapUtils.swift index de24e25ae..7e8b22773 100644 --- a/IapUtils.swift +++ b/IapUtils.swift @@ -33,7 +33,6 @@ extension Date { } } - func getProductObject(_ product: SKProduct) -> [String: Any?] { let formatter = NumberFormatter() formatter.numberStyle = .currency @@ -54,80 +53,80 @@ func getProductObject(_ product: SKProduct) -> [String: Any?] { var periodUnitIOS = "" var itemType = "iap" - let numOfUnits = UInt(product.subscriptionPeriod?.numberOfUnits ?? 0) - let unit = product.subscriptionPeriod?.unit - - if unit == .year { - periodUnitIOS = "YEAR" - } else if unit == .month { - periodUnitIOS = "MONTH" - } else if unit == .week { - periodUnitIOS = "WEEK" - } else if unit == .day { - periodUnitIOS = "DAY" + let numOfUnits = UInt(product.subscriptionPeriod?.numberOfUnits ?? 0) + let unit = product.subscriptionPeriod?.unit + + if unit == .year { + periodUnitIOS = "YEAR" + } else if unit == .month { + periodUnitIOS = "MONTH" + } else if unit == .week { + periodUnitIOS = "WEEK" + } else if unit == .day { + periodUnitIOS = "DAY" + } + + periodNumberIOS = String(format: "%lu", numOfUnits) + if numOfUnits != 0 { + itemType = "subs" + } + + // subscriptionPeriod = product.subscriptionPeriod ? [product.subscriptionPeriod stringValue] : @""; + // introductoryPrice = product.introductoryPrice != nil ? [NSString stringWithFormat:@"%@", product.introductoryPrice] : @""; + if product.introductoryPrice != nil { + formatter.locale = product.introductoryPrice?.priceLocale + + if let price = product.introductoryPrice?.price { + introductoryPrice = formatter.string(from: price) } - periodNumberIOS = String(format: "%lu", numOfUnits) - if numOfUnits != 0 { - itemType = "subs" + introductoryPriceAsAmountIOS = product.introductoryPrice?.price.stringValue ?? "" + + switch product.introductoryPrice?.paymentMode { + case .freeTrial: + introductoryPricePaymentMode = "FREETRIAL" + introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.subscriptionPeriod.numberOfUnits ?? 0).stringValue + + case .payAsYouGo: + introductoryPricePaymentMode = "PAYASYOUGO" + introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.numberOfPeriods ?? 0).stringValue + + case .payUpFront: + introductoryPricePaymentMode = "PAYUPFRONT" + introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.subscriptionPeriod.numberOfUnits ?? 0).stringValue + + default: + introductoryPricePaymentMode = "" + introductoryPriceNumberOfPeriods = "0" } - // subscriptionPeriod = product.subscriptionPeriod ? [product.subscriptionPeriod stringValue] : @""; - // introductoryPrice = product.introductoryPrice != nil ? [NSString stringWithFormat:@"%@", product.introductoryPrice] : @""; - if product.introductoryPrice != nil { - formatter.locale = product.introductoryPrice?.priceLocale - - if let price = product.introductoryPrice?.price { - introductoryPrice = formatter.string(from: price) - } - - introductoryPriceAsAmountIOS = product.introductoryPrice?.price.stringValue ?? "" - - switch product.introductoryPrice?.paymentMode { - case .freeTrial: - introductoryPricePaymentMode = "FREETRIAL" - introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.subscriptionPeriod.numberOfUnits ?? 0).stringValue - - case .payAsYouGo: - introductoryPricePaymentMode = "PAYASYOUGO" - introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.numberOfPeriods ?? 0).stringValue - - case .payUpFront: - introductoryPricePaymentMode = "PAYUPFRONT" - introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.subscriptionPeriod.numberOfUnits ?? 0).stringValue - - default: - introductoryPricePaymentMode = "" - introductoryPriceNumberOfPeriods = "0" - } - - if product.introductoryPrice?.subscriptionPeriod.unit == .day { - introductoryPriceSubscriptionPeriod = "DAY" - } else if product.introductoryPrice?.subscriptionPeriod.unit == .week { - introductoryPriceSubscriptionPeriod = "WEEK" - } else if product.introductoryPrice?.subscriptionPeriod.unit == .month { - introductoryPriceSubscriptionPeriod = "MONTH" - } else if product.introductoryPrice?.subscriptionPeriod.unit == .year { - introductoryPriceSubscriptionPeriod = "YEAR" - } else { - introductoryPriceSubscriptionPeriod = "" - } + if product.introductoryPrice?.subscriptionPeriod.unit == .day { + introductoryPriceSubscriptionPeriod = "DAY" + } else if product.introductoryPrice?.subscriptionPeriod.unit == .week { + introductoryPriceSubscriptionPeriod = "WEEK" + } else if product.introductoryPrice?.subscriptionPeriod.unit == .month { + introductoryPriceSubscriptionPeriod = "MONTH" + } else if product.introductoryPrice?.subscriptionPeriod.unit == .year { + introductoryPriceSubscriptionPeriod = "YEAR" } else { - introductoryPrice = "" - introductoryPriceAsAmountIOS = "" - introductoryPricePaymentMode = "" - introductoryPriceNumberOfPeriods = "" introductoryPriceSubscriptionPeriod = "" } + } else { + introductoryPrice = "" + introductoryPriceAsAmountIOS = "" + introductoryPricePaymentMode = "" + introductoryPriceNumberOfPeriods = "" + introductoryPriceSubscriptionPeriod = "" + } - currencyCode = product.priceLocale.currencyCode + currencyCode = product.priceLocale.currencyCode - countryCode = SKPaymentQueue.default().storefront?.countryCode - // countryCode = product.priceLocale.regionCode + countryCode = SKPaymentQueue.default().storefront?.countryCode + // countryCode = product.priceLocale.regionCode var discounts: [[String: String?]]? - discounts = getDiscountData(product) + discounts = getDiscountData(product) let obj: [String: Any?] = [ "productId": product.productIdentifier, @@ -152,88 +151,88 @@ func getProductObject(_ product: SKProduct) -> [String: Any?] { } func getDiscountData(_ product: SKProduct) -> [[String: String?]]? { - var mappedDiscounts: [[String: String?]] = [] - var localizedPrice: String? - var paymendMode: String? - var subscriptionPeriods: String? - var discountType: String? - - for discount in product.discounts { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - let priceLocale: Locale? = discount.priceLocale - if let pLocale = priceLocale { - formatter.locale = pLocale - } - localizedPrice = formatter.string(from: discount.price) - var numberOfPeriods: String? - - switch discount.paymentMode { - case .freeTrial: - paymendMode = "FREETRIAL" - numberOfPeriods = NSNumber(value: discount.subscriptionPeriod.numberOfUnits ).stringValue - break - - case .payAsYouGo: - paymendMode = "PAYASYOUGO" - numberOfPeriods = NSNumber(value: discount.numberOfPeriods).stringValue - break - - case .payUpFront: - paymendMode = "PAYUPFRONT" - numberOfPeriods = NSNumber(value: discount.subscriptionPeriod.numberOfUnits ).stringValue - break - - default: - paymendMode = "" - numberOfPeriods = "0" - break - } - - switch discount.subscriptionPeriod.unit { - case .day: - subscriptionPeriods = "DAY" - - case .week: - subscriptionPeriods = "WEEK" - - case .month: - subscriptionPeriods = "MONTH" - - case .year: - subscriptionPeriods = "YEAR" - - default: - subscriptionPeriods = "" - } - - let discountIdentifier = discount.identifier - switch discount.type { - case SKProductDiscount.Type.introductory: - discountType = "INTRODUCTORY" - break - - case SKProductDiscount.Type.subscription: - discountType = "SUBSCRIPTION" - break - - default: - discountType = "" - break - } - - let discountObj = [ - "identifier": discountIdentifier, - "type": discountType, - "numberOfPeriods": numberOfPeriods, - "price": "\(discount.price)", - "localizedPrice": localizedPrice, - "paymentMode": paymendMode, - "subscriptionPeriod": subscriptionPeriods - ] - - mappedDiscounts.append(discountObj) + var mappedDiscounts: [[String: String?]] = [] + var localizedPrice: String? + var paymendMode: String? + var subscriptionPeriods: String? + var discountType: String? + + for discount in product.discounts { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + let priceLocale: Locale? = discount.priceLocale + if let pLocale = priceLocale { + formatter.locale = pLocale } + localizedPrice = formatter.string(from: discount.price) + var numberOfPeriods: String? + + switch discount.paymentMode { + case .freeTrial: + paymendMode = "FREETRIAL" + numberOfPeriods = NSNumber(value: discount.subscriptionPeriod.numberOfUnits ).stringValue + break + + case .payAsYouGo: + paymendMode = "PAYASYOUGO" + numberOfPeriods = NSNumber(value: discount.numberOfPeriods).stringValue + break + + case .payUpFront: + paymendMode = "PAYUPFRONT" + numberOfPeriods = NSNumber(value: discount.subscriptionPeriod.numberOfUnits ).stringValue + break + + default: + paymendMode = "" + numberOfPeriods = "0" + break + } + + switch discount.subscriptionPeriod.unit { + case .day: + subscriptionPeriods = "DAY" + + case .week: + subscriptionPeriods = "WEEK" + + case .month: + subscriptionPeriods = "MONTH" + + case .year: + subscriptionPeriods = "YEAR" + + default: + subscriptionPeriods = "" + } + + let discountIdentifier = discount.identifier + switch discount.type { + case SKProductDiscount.Type.introductory: + discountType = "INTRODUCTORY" + break + + case SKProductDiscount.Type.subscription: + discountType = "SUBSCRIPTION" + break + + default: + discountType = "" + break + } + + let discountObj = [ + "identifier": discountIdentifier, + "type": discountType, + "numberOfPeriods": numberOfPeriods, + "price": "\(discount.price)", + "localizedPrice": localizedPrice, + "paymentMode": paymendMode, + "subscriptionPeriod": subscriptionPeriods + ] + + mappedDiscounts.append(discountObj) + } - return mappedDiscounts + return mappedDiscounts } diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index 606e55f1a..ac6f3ccae 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -10,12 +10,12 @@ extension SKProductsRequest { @objc(RNIapIos) class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver { private var promisesByKey: [String: [RNIapIosPromise]] - private var hasListeners = false - private var pendingTransactionWithAutoFinish = false // TODO: + private var hasListeners = false + private var pendingTransactionWithAutoFinish = false // TODO: private var receiptBlock: ((Data?, Error?) -> Void)? // Block to handle request the receipt async from delegate - private var products: [String: Product] - private var transactions: [String: Transaction] - var updateListenerTask: Task? + private var products: [String: Product] + private var transactions: [String: Transaction] + private var updateListenerTask: Task? private var promotedPayment: SKPayment? private var promotedProduct: SKProduct? private var productsRequest: SKProductsRequest? @@ -25,46 +25,46 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver override init() { promisesByKey = [String: [RNIapIosPromise]]() products = [String: Product]() - transactions = [String: Transaction]() + transactions = [String: Transaction]() super.init() - updateListenerTask = listenForTransactions() + updateListenerTask = listenForTransactions() } deinit { - updateListenerTask?.cancel() + updateListenerTask?.cancel() } override class func requiresMainQueueSetup() -> Bool { return true } - func listenForTransactions() -> Task { - return Task.detached { - // Iterate through any transactions that don't come from a direct call to `purchase()`. - for await result in Transaction.updates { - do { - let transaction = try self.checkVerified(result) + func listenForTransactions() -> Task { + return Task.detached { + // Iterate through any transactions that don't come from a direct call to `purchase()`. + for await result in Transaction.updates { + do { + let transaction = try self.checkVerified(result) - // Deliver products to the user. - await self.updateCustomerProductStatus() + // Deliver products to the user. + await self.updateCustomerProductStatus() - // Always finish a transaction. - await transaction.finish() - } catch { - // StoreKit has a transaction that fails verification. Don't deliver content to the user. - print("Transaction failed verification") - } - } + // Always finish a transaction. + await transaction.finish() + } catch { + // StoreKit has a transaction that fails verification. Don't deliver content to the user. + print("Transaction failed verification") } + } } + } - override func startObserving() { - hasListeners = true - } + override func startObserving() { + hasListeners = true + } - override func stopObserving() { - hasListeners = false - } + override func stopObserving() { + hasListeners = false + } override func addListener(_ eventName: String?) { super.addListener(eventName) @@ -109,9 +109,9 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { - promotedProduct = product + promotedProduct = product promotedPayment = payment - sendEvent(withName: "iap-promoted-product", body: product.productIdentifier) + sendEvent(withName: "iap-promoted-product", body: product.productIdentifier) return false } @@ -123,7 +123,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { - let canMakePayments = SKPaymentQueue.canMakePayments() resolve(NSNumber(value: canMakePayments)) } @@ -131,8 +130,8 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { - updateListenerTask?.cancel() - updateListenerTask = nil + updateListenerTask?.cancel() + updateListenerTask = nil resolve(nil) } @objc public func getItems( @@ -140,77 +139,78 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { - do { - let products: [[String: Any]] = try await Product.products(for: skus).map({ (product: Product) -> [String: Any] in - var prod = [String: Any]() - prod["displayName"] = product.displayName - prod["description"] = product.description - prod["id"] = product.id - prod["displayPrice"] = product.displayPrice - prod["price"] = product.price - prod["isFamilyShareable"] = product.isFamilyShareable - prod["subscription"] = product.subscription?.subscriptionGroupID - return prod - - }) - resolve(products) - } catch { + do { + let products: [[String: Any]] = try await Product.products(for: skus).map({ (product: Product) -> [String: Any] in + var prod = [String: Any]() + prod["displayName"] = product.displayName + prod["description"] = product.description + prod["id"] = product.id + prod["displayPrice"] = product.displayPrice + prod["price"] = product.price + prod["isFamilyShareable"] = product.isFamilyShareable + prod["subscription"] = product.subscription?.subscriptionGroupID + return prod + }) + resolve(products) + } catch { reject("E_UNKNOWN", "Error fetching items", nil) - } - + } } @objc public func getAvailableItems( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { - var purchasedItems: [Product] = [] - // Iterate through all of the user's purchased products. - for await result in Transaction.currentEntitlements { - do { - // Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. - let transaction = try checkVerified(result) - - // Check the `productType` of the transaction and get the corresponding product from the store. - switch transaction.productType { - case .nonConsumable: - if let product = products[transaction.productID] { - purchasedItems.append(product) - } - case .nonRenewable: - if let nonRenewable = products[transaction.productID], - transaction.productID == "nonRenewing.standard" { - // Non-renewing subscriptions have no inherent expiration date, so they're always - // contained in `Transaction.currentEntitlements` after the user purchases them. - // This app defines this non-renewing subscription's expiration date to be one year after purchase. - // If the current date is within one year of the `purchaseDate`, the user is still entitled to this - // product. - let currentDate = Date() - let expirationDate = Calendar(identifier: .gregorian).date(byAdding: DateComponents(year: 1), - to: transaction.purchaseDate)! - - if currentDate < expirationDate { - purchasedItems.append(nonRenewable) - } - } - case .autoRenewable: - if let subscription = products[transaction.productID] { - purchasedItems.append(subscription) - } - default: - break - } - } catch { - print() - reject("", "", nil) // TODO + var purchasedItems: [Product] = [] + // Iterate through all of the user's purchased products. + for await result in Transaction.currentEntitlements { + do { + // Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. + let transaction = try checkVerified(result) + + // Check the `productType` of the transaction and get the corresponding product from the store. + switch transaction.productType { + case .nonConsumable: + if let product = products[transaction.productID] { + purchasedItems.append(product) + } + + case .nonRenewable: + if let nonRenewable = products[transaction.productID], + transaction.productID == "nonRenewing.standard" { + // Non-renewing subscriptions have no inherent expiration date, so they're always + // contained in `Transaction.currentEntitlements` after the user purchases them. + // This app defines this non-renewing subscription's expiration date to be one year after purchase. + // If the current date is within one year of the `purchaseDate`, the user is still entitled to this + // product. + let currentDate = Date() + let expirationDate = Calendar(identifier: .gregorian).date(byAdding: DateComponents(year: 1), + to: transaction.purchaseDate)! + + if currentDate < expirationDate { + purchasedItems.append(nonRenewable) + } + } + + case .autoRenewable: + if let subscription = products[transaction.productID] { + purchasedItems.append(subscription) } + + default: + break + } + } catch { + print() + reject("", "", nil) // TODO } + } - // Check the `subscriptionGroupStatus` to learn the auto-renewable subscription state to determine whether the customer - // is new (never subscribed), active, or inactive (expired subscription). This app has only one subscription - // group, so products in the subscriptions array all belong to the same group. The statuses that - // `product.subscription.status` returns apply to the entire subscription group. - // subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state - resolve(purchasedItems) + // Check the `subscriptionGroupStatus` to learn the auto-renewable subscription state to determine whether the customer + // is new (never subscribed), active, or inactive (expired subscription). This app has only one subscription + // group, so products in the subscriptions array all belong to the same group. The statuses that + // `product.subscription.status` returns apply to the entire subscription group. + // subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state + resolve(purchasedItems) } @objc public func buyProduct( @@ -222,89 +222,89 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { - pendingTransactionWithAutoFinish = andDangerouslyFinishTransactionAutomatically - let product: Product? = products[sku] + pendingTransactionWithAutoFinish = andDangerouslyFinishTransactionAutomatically + let product: Product? = products[sku] if let product = product { - do { - var options: Set = [] - if quantity > -1 { - options.insert(.quantity(quantity)) - } - - let offerID = discountOffer["identifier"] - let keyID = discountOffer["keyIdentifier"] - let nonce = discountOffer["nonce"] - let signature = discountOffer["signature"] - let timestamp = discountOffer["timestamp"] - if let offerID = offerID, let keyID = keyID, let nonce = nonce, let nonce = UUID(uuidString: nonce), let signature = signature, let signature = signature.data(using: .utf8), let timestamp = timestamp, let timestamp = Int(timestamp) { - options.insert(.promotionalOffer(offerID: offerID, keyID: keyID, nonce: nonce, signature: signature, timestamp: timestamp )) - } - if let appAccountToken = appAccountToken, let appAccountToken = UUID(uuidString: appAccountToken) { - options.insert(.appAccountToken(appAccountToken)) - } + do { + var options: Set = [] + if quantity > -1 { + options.insert(.quantity(quantity)) + } - let result = try await product.purchase(options: options) - switch result { - case .success(let verification): - // Check whether the transaction is verified. If it isn't, - // this function rethrows the verification error. - let transaction = try checkVerified(verification) - - // The transaction is verified. Deliver content to the user. - // Do on JS :await updateCustomerProductStatus() - - // Always finish a transaction. - let transactionId = String(transaction.id) - if andDangerouslyFinishTransactionAutomatically { - await transaction.finish() - resolve(nil) - } else { - transactions[transactionId]=transaction - resolve(transactionId) - } - return - case .userCancelled, .pending: - reject("", "", nil) // TODO - return - default: - reject("", "", nil)// TODO - return - } - } catch { - reject("", "", nil)// TODO + let offerID = discountOffer["identifier"] + let keyID = discountOffer["keyIdentifier"] + let nonce = discountOffer["nonce"] + let signature = discountOffer["signature"] + let timestamp = discountOffer["timestamp"] + if let offerID = offerID, let keyID = keyID, let nonce = nonce, let nonce = UUID(uuidString: nonce), let signature = signature, let signature = signature.data(using: .utf8), let timestamp = timestamp, let timestamp = Int(timestamp) { + options.insert(.promotionalOffer(offerID: offerID, keyID: keyID, nonce: nonce, signature: signature, timestamp: timestamp )) } + if let appAccountToken = appAccountToken, let appAccountToken = UUID(uuidString: appAccountToken) { + options.insert(.appAccountToken(appAccountToken)) + } + + let result = try await product.purchase(options: options) + switch result { + case .success(let verification): + // Check whether the transaction is verified. If it isn't, + // this function rethrows the verification error. + let transaction = try checkVerified(verification) + + // The transaction is verified. Deliver content to the user. + // Do on JS :await updateCustomerProductStatus() + + // Always finish a transaction. + let transactionId = String(transaction.id) + if andDangerouslyFinishTransactionAutomatically { + await transaction.finish() + resolve(nil) + } else { + transactions[transactionId] = transaction + resolve(transactionId) + } + return + case .userCancelled, .pending: + reject("", "", nil) // TODO + return + + default: + reject("", "", nil)// TODO + return + } + } catch { + reject("", "", nil)// TODO + } } else { reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) } } - @MainActor - func updateCustomerProductStatus() async { + @MainActor + func updateCustomerProductStatus() async { + } - } + public enum StoreError: Error { + case failedVerification + } + func checkVerified(_ result: VerificationResult) throws -> T { + // Check whether the JWS passes StoreKit verification. + switch result { + case .unverified: + // StoreKit parses the JWS, but it fails verification. + throw StoreError.failedVerification - public enum StoreError: Error { - case failedVerification - } - func checkVerified(_ result: VerificationResult) throws -> T { - // Check whether the JWS passes StoreKit verification. - switch result { - case .unverified: - // StoreKit parses the JWS, but it fails verification. - throw StoreError.failedVerification - case .verified(let safe): - // The result is verified. Return the unwrapped value. - return safe - } + case .verified(let safe): + // The result is verified. Return the unwrapped value. + return safe } + } @objc public func clearTransaction( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { - countPendingTransaction = transactions.count debugMessage("clear remaining Transactions (\(countPendingTransaction)). Call this before make a new transaction") @@ -391,17 +391,17 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } } - /** - Should remain the same according to: - https://stackoverflow.com/a/72789651/570612 - */ + /** + Should remain the same according to: + https://stackoverflow.com/a/72789651/570612 + */ @objc public func presentCodeRedemptionSheet( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { #if !os(tvOS) - SKPaymentQueue.default().presentCodeRedemptionSheet() - resolve(nil) + SKPaymentQueue.default().presentCodeRedemptionSheet() + resolve(nil) #else reject(standardErrorCode(2), "This method is not available on tvOS", nil) #endif @@ -427,54 +427,54 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver case .deferred: debugMessage("Deferred (awaiting approval via parental controls, etc.)") - if hasListeners { - let err = [ - "debugMessage": "The payment was deferred (awaiting approval via parental controls for instance)", - "code": "E_DEFERRED_PAYMENT", - "message": "The payment was deferred (awaiting approval via parental controls for instance)", - "productId": transaction.payment.productIdentifier, - "quantity": "\(transaction.payment.quantity)" - ] + if hasListeners { + let err = [ + "debugMessage": "The payment was deferred (awaiting approval via parental controls for instance)", + "code": "E_DEFERRED_PAYMENT", + "message": "The payment was deferred (awaiting approval via parental controls for instance)", + "productId": transaction.payment.productIdentifier, + "quantity": "\(transaction.payment.quantity)" + ] - sendEvent(withName: "purchase-error", body: err) - } + sendEvent(withName: "purchase-error", body: err) + } - rejectPromises( - forKey: transaction.payment.productIdentifier, - code: "E_DEFERRED_PAYMENT", - message: "The payment was deferred (awaiting approval via parental controls for instance)", - error: nil) + rejectPromises( + forKey: transaction.payment.productIdentifier, + code: "E_DEFERRED_PAYMENT", + message: "The payment was deferred (awaiting approval via parental controls for instance)", + error: nil) case .failed: debugMessage("Purchase Failed") SKPaymentQueue.default().finishTransaction(transaction) - let nsError = transaction.error as NSError? - - if hasListeners { - let code = nsError?.code - let responseCode = String(code ?? 0) - let err = [ - "responseCode": responseCode, - "debugMessage": transaction.error?.localizedDescription, - "code": standardErrorCode(code), - "message": transaction.error?.localizedDescription, - "productId": transaction.payment.productIdentifier - ] + let nsError = transaction.error as NSError? + + if hasListeners { + let code = nsError?.code + let responseCode = String(code ?? 0) + let err = [ + "responseCode": responseCode, + "debugMessage": transaction.error?.localizedDescription, + "code": standardErrorCode(code), + "message": transaction.error?.localizedDescription, + "productId": transaction.payment.productIdentifier + ] - sendEvent(withName: "purchase-error", body: err) - } + sendEvent(withName: "purchase-error", body: err) + } - rejectPromises( - forKey: transaction.payment.productIdentifier, - code: standardErrorCode(nsError?.code), - message: nsError?.localizedDescription, - error: nsError) + rejectPromises( + forKey: transaction.payment.productIdentifier, + code: standardErrorCode(nsError?.code), + message: nsError?.localizedDescription, + error: nsError) break @unknown default: - fatalError() + fatalError() } } } @@ -509,11 +509,11 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { - rejectPromises( - forKey: "availableItems", - code: standardErrorCode((error as NSError).code), - message: error.localizedDescription, - error: error) + rejectPromises( + forKey: "availableItems", + code: standardErrorCode((error as NSError).code), + message: error.localizedDescription, + error: error) debugMessage("restoreCompletedTransactionsFailedWithError") } From b0517fd0fa4194d25b6af676db09486342b3e01a Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Tue, 16 Aug 2022 15:55:37 -0700 Subject: [PATCH 06/16] continue migration, purchases --- IapUtils.swift | 35 +++++++++++++++++ ios/RNIapIos.swift | 98 +++++++++++++++++----------------------------- src/types/index.ts | 4 +- 3 files changed, 73 insertions(+), 64 deletions(-) diff --git a/IapUtils.swift b/IapUtils.swift index 7e8b22773..3fdeea9a4 100644 --- a/IapUtils.swift +++ b/IapUtils.swift @@ -33,6 +33,41 @@ extension Date { } } +func addPromise(forKey key: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + var promises: [RNIapIosPromise]? = promisesByKey[key] + + if promises == nil { + promises = [] + } + + promises?.append((resolve, reject)) + promisesByKey[key] = promises +} + +func resolvePromises(forKey key: String?, value: Any?) { + let promises: [RNIapIosPromise]? = promisesByKey[key ?? ""] + + if let promises = promises { + for tuple in promises { + let resolveBlck = tuple.0 + resolveBlck(value) + } + promisesByKey[key ?? ""] = nil + } +} + +func rejectPromises(forKey key: String, code: String?, message: String?, error: Error?) { + let promises = promisesByKey[key] + + if let promises = promises { + for tuple in promises { + let reject = tuple.1 + reject(code, message, error) + } + promisesByKey[key] = nil + } +} + func getProductObject(_ product: SKProduct) -> [String: Any?] { let formatter = NumberFormatter() formatter.numberStyle = .currency diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index ac6f3ccae..765cbbeab 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -1,3 +1,4 @@ +import Foundation import React import StoreKit @@ -8,7 +9,7 @@ extension SKProductsRequest { } @objc(RNIapIos) -class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver { +class RNIapIos: RCTEventEmitter, SKRequestDelegate { private var promisesByKey: [String: [RNIapIosPromise]] private var hasListeners = false private var pendingTransactionWithAutoFinish = false // TODO: @@ -20,23 +21,34 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver private var promotedProduct: SKProduct? private var productsRequest: SKProductsRequest? private var countPendingTransaction: Int = 0 - private var hasTransactionObserver = false override init() { promisesByKey = [String: [RNIapIosPromise]]() products = [String: Product]() transactions = [String: Transaction]() super.init() - updateListenerTask = listenForTransactions() + addTransactionObserver() } deinit { - updateListenerTask?.cancel() + removeTransactionObserver() } override class func requiresMainQueueSetup() -> Bool { return true } + func addTransactionObserver() { + if updateListenerTask == nil { + updateListenerTask = listenForTransactions() + } + } + + func removeTransactionObserver() { + if updateListenerTask != nil { + updateListenerTask?.cancel() + updateListenerTask = nil + } + } func listenForTransactions() -> Task { return Task.detached { @@ -44,15 +56,29 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver for await result in Transaction.updates { do { let transaction = try self.checkVerified(result) - // Deliver products to the user. await self.updateCustomerProductStatus() - + let transactionId = String(transaction.id) // Always finish a transaction. - await transaction.finish() + self.transactions[transactionId] = transaction + if self.hasListeners { + self.sendEvent(withName: "purchase-updated", body: transaction) // TODO: serialize transaction + } + // await transaction.finish() //TODO: Document } catch { // StoreKit has a transaction that fails verification. Don't deliver content to the user. print("Transaction failed verification") + if self.hasListeners { + let err = [ // TODO: add info + "responseCode": "-1", + "debugMessage": error.localizedDescription, + "code": "E_RECEIPT_FINISHED_FAILED", + "message": error.localizedDescription, + "productId": "" + ] + + self.sendEvent(withName: "purchase-error", body: err) + } } } } @@ -73,41 +99,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver } } - func addPromise(forKey key: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - var promises: [RNIapIosPromise]? = promisesByKey[key] - - if promises == nil { - promises = [] - } - - promises?.append((resolve, reject)) - promisesByKey[key] = promises - } - - func resolvePromises(forKey key: String?, value: Any?) { - let promises: [RNIapIosPromise]? = promisesByKey[key ?? ""] - - if let promises = promises { - for tuple in promises { - let resolveBlck = tuple.0 - resolveBlck(value) - } - promisesByKey[key ?? ""] = nil - } - } - - func rejectPromises(forKey key: String, code: String?, message: String?, error: Error?) { - let promises = promisesByKey[key] - - if let promises = promises { - for tuple in promises { - let reject = tuple.1 - reject(code, message, error) - } - promisesByKey[key] = nil - } - } - func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { promotedProduct = product promotedPayment = payment @@ -123,7 +114,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { - let canMakePayments = SKPaymentQueue.canMakePayments() + let canMakePayments = AppStore.canMakePayments resolve(NSNumber(value: canMakePayments)) } @objc public func endConnection( @@ -166,7 +157,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver do { // Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. let transaction = try checkVerified(result) - // Check the `productType` of the transaction and get the corresponding product from the store. switch transaction.productType { case .nonConsumable: @@ -260,7 +250,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver await transaction.finish() resolve(nil) } else { - transactions[transactionId] = transaction + self.transactions[transactionId] = transaction resolve(transactionId) } return @@ -416,7 +406,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver case .purchased: debugMessage("Purchase Successful") - purchaseProcess(transaction) + // purchaseProcess(transaction) break case .restored: @@ -518,22 +508,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver debugMessage("restoreCompletedTransactionsFailedWithError") } - func purchaseProcess(_ transaction: SKPaymentTransaction) { - if pendingTransactionWithAutoFinish { - SKPaymentQueue.default().finishTransaction(transaction) - pendingTransactionWithAutoFinish = false - } - - getPurchaseData(transaction) { [self] purchase in - resolvePromises(forKey: transaction.payment.productIdentifier, value: purchase) - - // additionally send event - if hasListeners { - sendEvent(withName: "purchase-updated", body: purchase) - } - } - } - func standardErrorCode(_ code: Int?) -> String? { let descriptions = [ "E_UNKNOWN", diff --git a/src/types/index.ts b/src/types/index.ts index 614b3cbfd..fd7ea3db2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -193,8 +193,8 @@ export interface RequestPurchaseIOS { * UUID representing user account */ appAccountToken?: string; - quantity: number; - withOffer: Apple.PaymentDiscount; + quantity?: number; + withOffer?: Apple.PaymentDiscount; } export type RequestPurchase = RequestPurchaseAndroid & RequestPurchaseIOS; From 5c708681bd7cb4135d155d1ececb1e6df104c3b2 Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Tue, 16 Aug 2022 16:18:38 -0700 Subject: [PATCH 07/16] return promises to class --- IapUtils.swift | 35 ----------------------------------- ios/RNIapIos.swift | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/IapUtils.swift b/IapUtils.swift index 3fdeea9a4..7e8b22773 100644 --- a/IapUtils.swift +++ b/IapUtils.swift @@ -33,41 +33,6 @@ extension Date { } } -func addPromise(forKey key: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - var promises: [RNIapIosPromise]? = promisesByKey[key] - - if promises == nil { - promises = [] - } - - promises?.append((resolve, reject)) - promisesByKey[key] = promises -} - -func resolvePromises(forKey key: String?, value: Any?) { - let promises: [RNIapIosPromise]? = promisesByKey[key ?? ""] - - if let promises = promises { - for tuple in promises { - let resolveBlck = tuple.0 - resolveBlck(value) - } - promisesByKey[key ?? ""] = nil - } -} - -func rejectPromises(forKey key: String, code: String?, message: String?, error: Error?) { - let promises = promisesByKey[key] - - if let promises = promises { - for tuple in promises { - let reject = tuple.1 - reject(code, message, error) - } - promisesByKey[key] = nil - } -} - func getProductObject(_ product: SKProduct) -> [String: Any?] { let formatter = NumberFormatter() formatter.numberStyle = .currency diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index 765cbbeab..7edc4b944 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -629,4 +629,40 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { } } } + + // Promises: + func addPromise(forKey key: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + var promises: [RNIapIosPromise]? = promisesByKey[key] + + if promises == nil { + promises = [] + } + + promises?.append((resolve, reject)) + promisesByKey[key] = promises + } + + func resolvePromises(forKey key: String?, value: Any?) { + let promises: [RNIapIosPromise]? = promisesByKey[key ?? ""] + + if let promises = promises { + for tuple in promises { + let resolveBlck = tuple.0 + resolveBlck(value) + } + promisesByKey[key ?? ""] = nil + } + } + + func rejectPromises(forKey key: String, code: String?, message: String?, error: Error?) { + let promises = promisesByKey[key] + + if let promises = promises { + for tuple in promises { + let reject = tuple.1 + reject(code, message, error) + } + promisesByKey[key] = nil + } + } } From fd0fb9bbdb974affc2567619edfef2a20078e49d Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Tue, 16 Aug 2022 16:23:33 -0700 Subject: [PATCH 08/16] moved utils to ios --- IapUtils.swift => ios/IapUtils.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename IapUtils.swift => ios/IapUtils.swift (100%) diff --git a/IapUtils.swift b/ios/IapUtils.swift similarity index 100% rename from IapUtils.swift rename to ios/IapUtils.swift From 245338779cb598cbb0bf5bfb1f77f4affac56cfb Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Wed, 17 Aug 2022 14:14:50 -0700 Subject: [PATCH 09/16] clean up promises and error codes --- ios/RNIapIos.swift | 357 ++++++++++----------------------------------- 1 file changed, 76 insertions(+), 281 deletions(-) diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index 7edc4b944..156769f14 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -50,20 +50,24 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { } } + func addTransaction(_ transaction: Transaction) { + let transactionId = String(transaction.id) + self.transactions[transactionId] = transaction + } + func listenForTransactions() -> Task { return Task.detached { // Iterate through any transactions that don't come from a direct call to `purchase()`. for await result in Transaction.updates { do { let transaction = try self.checkVerified(result) + self.addTransaction(transaction) // Deliver products to the user. - await self.updateCustomerProductStatus() - let transactionId = String(transaction.id) - // Always finish a transaction. - self.transactions[transactionId] = transaction + // await self.updateCustomerProductStatus() if self.hasListeners { self.sendEvent(withName: "purchase-updated", body: transaction) // TODO: serialize transaction } + // Always finish a transaction. // await transaction.finish() //TODO: Document } catch { // StoreKit has a transaction that fails verification. Don't deliver content to the user. @@ -115,6 +119,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { let canMakePayments = AppStore.canMakePayments + addTransactionObserver() resolve(NSNumber(value: canMakePayments)) } @objc public func endConnection( @@ -222,21 +227,25 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { options.insert(.quantity(quantity)) } - let offerID = discountOffer["identifier"] + let offerID = discountOffer["identifier"] // TODO: Change names to match Native API let keyID = discountOffer["keyIdentifier"] let nonce = discountOffer["nonce"] let signature = discountOffer["signature"] let timestamp = discountOffer["timestamp"] + if let offerID = offerID, let keyID = keyID, let nonce = nonce, let nonce = UUID(uuidString: nonce), let signature = signature, let signature = signature.data(using: .utf8), let timestamp = timestamp, let timestamp = Int(timestamp) { options.insert(.promotionalOffer(offerID: offerID, keyID: keyID, nonce: nonce, signature: signature, timestamp: timestamp )) } if let appAccountToken = appAccountToken, let appAccountToken = UUID(uuidString: appAccountToken) { options.insert(.appAccountToken(appAccountToken)) } + debugMessage("Purchase Started") let result = try await product.purchase(options: options) switch result { case .success(let verification): + debugMessage("Purchase Successful") + // Check whether the transaction is verified. If it isn't, // this function rethrows the verification error. let transaction = try checkVerified(verification) @@ -245,18 +254,35 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { // Do on JS :await updateCustomerProductStatus() // Always finish a transaction. - let transactionId = String(transaction.id) if andDangerouslyFinishTransactionAutomatically { await transaction.finish() resolve(nil) } else { - self.transactions[transactionId] = transaction - resolve(transactionId) + self.addTransaction(transaction) + resolve(transaction) } return case .userCancelled, .pending: - reject("", "", nil) // TODO + debugMessage("Deferred (awaiting approval via parental controls, etc.)") + + if hasListeners { + let err = [ + "debugMessage": "The payment was deferred (awaiting approval via parental controls for instance)", + "code": "E_DEFERRED_PAYMENT", + "message": "The payment was deferred (awaiting approval via parental controls for instance)", + "productId": sku, + "quantity": "\(quantity)" + ] + + sendEvent(withName: "purchase-error", body: err) + } + + reject( + "E_DEFERRED_PAYMENT", + "The payment was deferred for \(sku) (awaiting approval via parental controls for instance)", + nil) + return default: @@ -264,7 +290,26 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { return } } catch { - reject("", "", nil)// TODO + debugMessage("Purchase Failed") + + if hasListeners { + let code = IapErrors.E_PURCHASE_ERROR + let responseCode = code.rawValue + let err = [ + "responseCode": responseCode, + "debugMessage": error.localizedDescription, + "code": "\(code.asInt())", + "message": error.localizedDescription, + "productId": sku + ] + + sendEvent(withName: "purchase-error", body: err) + } + + reject( + IapErrors.E_UNKNOWN.rawValue, + "Purchased failed for sku:\(sku): \(error.localizedDescription)", + error) } } else { reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) @@ -291,25 +336,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { } } - @objc public func clearTransaction( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) async { - countPendingTransaction = transactions.count - - debugMessage("clear remaining Transactions (\(countPendingTransaction)). Call this before make a new transaction") - - if countPendingTransaction > 0 { - addPromise(forKey: "cleaningTransactions", resolve: resolve, reject: reject) - for transaction in transactions { - await transaction.value.finish() - transactions.removeValue(forKey: transaction.key) - } - } else { - resolve(nil) - } - } - @objc public func promotedProduct( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } @@ -326,7 +352,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { debugMessage("buy promoted product") SKPaymentQueue.default().add(promoPayment) } else { - reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) + reject(IapErrors.E_DEVELOPER_ERROR.rawValue, "Invalid product ID.", nil) } } @@ -335,11 +361,11 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { - requestReceiptData(withBlock: refresh) { [self] receiptData, error in + requestReceiptData(withBlock: refresh) { receiptData, error in if error == nil { resolve(receiptData?.base64EncodedString(options: [])) } else { - reject(standardErrorCode(9), "Invalid receipt", nil) + reject(IapErrors.E_RECEIPT_FAILED.rawValue, "Invalid receipt", nil) } } } @@ -348,8 +374,9 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { _ transactionIdentifier: String, resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - finishTransaction(withIdentifier: transactionIdentifier) + ) async { + await transactions[transactionIdentifier]?.finish() + transactions.removeValue(forKey: transactionIdentifier) resolve(nil) } @@ -357,28 +384,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { - requestReceiptData(withBlock: false) { receiptData, _ in - var output: [AnyHashable] = [] - - if let receipt = receiptData { - let transactions = SKPaymentQueue.default().transactions - - for item in transactions { - let timestamp = item.transactionDate?.millisecondsSince1970 == nil ? nil : String(item.transactionDate!.millisecondsSince1970) - let purchase = [ - "transactionDate": timestamp, - "transactionId": item.transactionIdentifier, - "productId": item.payment.productIdentifier, - "quantity": "\(item.payment.quantity)", - "transactionReceipt": receipt.base64EncodedString(options: []) - ] - - output.append(purchase) - } - } - - resolve(output) - } + resolve(transactions.values) } /** @@ -397,163 +403,20 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { #endif } - func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { - for transaction in transactions { - switch transaction.transactionState { - case .purchasing: - debugMessage("Purchase Started") - break - - case .purchased: - debugMessage("Purchase Successful") - // purchaseProcess(transaction) - break - - case .restored: - debugMessage("Restored") - SKPaymentQueue.default().finishTransaction(transaction) - break - - case .deferred: - debugMessage("Deferred (awaiting approval via parental controls, etc.)") - - if hasListeners { - let err = [ - "debugMessage": "The payment was deferred (awaiting approval via parental controls for instance)", - "code": "E_DEFERRED_PAYMENT", - "message": "The payment was deferred (awaiting approval via parental controls for instance)", - "productId": transaction.payment.productIdentifier, - "quantity": "\(transaction.payment.quantity)" - ] - - sendEvent(withName: "purchase-error", body: err) - } - - rejectPromises( - forKey: transaction.payment.productIdentifier, - code: "E_DEFERRED_PAYMENT", - message: "The payment was deferred (awaiting approval via parental controls for instance)", - error: nil) - - case .failed: - debugMessage("Purchase Failed") - - SKPaymentQueue.default().finishTransaction(transaction) - - let nsError = transaction.error as NSError? - - if hasListeners { - let code = nsError?.code - let responseCode = String(code ?? 0) - let err = [ - "responseCode": responseCode, - "debugMessage": transaction.error?.localizedDescription, - "code": standardErrorCode(code), - "message": transaction.error?.localizedDescription, - "productId": transaction.payment.productIdentifier - ] - - sendEvent(withName: "purchase-error", body: err) - } - - rejectPromises( - forKey: transaction.payment.productIdentifier, - code: standardErrorCode(nsError?.code), - message: nsError?.localizedDescription, - error: nsError) - - break - @unknown default: - fatalError() - } - } - } - - func finishTransaction(withIdentifier transactionIdentifier: String?) { - let queue = SKPaymentQueue.default() - - for transaction in queue.transactions { - if transaction.transactionIdentifier == transactionIdentifier { - SKPaymentQueue.default().finishTransaction(transaction) - } - } - } - - func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { - debugMessage("PaymentQueueRestoreCompletedTransactionsFinished") - var items = [[String: Any?]]() - - for transaction in queue.transactions { - if transaction.transactionState == .restored || transaction.transactionState == .purchased { - getPurchaseData(transaction) { restored in - if let restored = restored { - items.append(restored) - } - - SKPaymentQueue.default().finishTransaction(transaction) - } - } - } - - resolvePromises(forKey: "availableItems", value: items) - } - - func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { - rejectPromises( - forKey: "availableItems", - code: standardErrorCode((error as NSError).code), - message: error.localizedDescription, - error: error) - - debugMessage("restoreCompletedTransactionsFailedWithError") - } - - func standardErrorCode(_ code: Int?) -> String? { - let descriptions = [ - "E_UNKNOWN", - "E_SERVICE_ERROR", - "E_USER_CANCELLED", - "E_USER_ERROR", - "E_USER_ERROR", - "E_ITEM_UNAVAILABLE", - "E_REMOTE_ERROR", - "E_NETWORK_ERROR", - "E_SERVICE_ERROR", - "E_RECEIPT_FAILED", - "E_RECEIPT_FINISHED_FAILED" - ] - - guard let code = code else { - return descriptions[0] - } - - if code > descriptions.count - 1 || code < 0 { // Fix crash app without internet connection - return descriptions[0] - } - - return descriptions[code] - } - - func getPurchaseData(_ transaction: SKPaymentTransaction, withBlock block: @escaping (_ transactionDict: [String: Any]?) -> Void) { - requestReceiptData(withBlock: false) { receiptData, _ in - if receiptData == nil { - block(nil) - } else { - var purchase = [ - "transactionDate": transaction.transactionDate?.millisecondsSince1970String, - "transactionId": transaction.transactionIdentifier, - "productId": transaction.payment.productIdentifier, - "transactionReceipt": receiptData?.base64EncodedString(options: []) - ] - - // originalTransaction is available for restore purchase and purchase of cancelled/expired subscriptions - if let originalTransaction = transaction.original { - purchase["originalTransactionDateIOS"] = originalTransaction.transactionDate?.millisecondsSince1970String - purchase["originalTransactionIdentifierIOS"] = originalTransaction.transactionIdentifier - } - - block(purchase as [String: Any]) - } + enum IapErrors: String, CaseIterable { + case E_UNKNOWN = "E_UNKNOWN" + case E_SERVICE_ERROR = "E_SERVICE_ERROR" + case E_USER_CANCELLED = "E_USER_CANCELLED" + case E_USER_ERROR = "E_USER_ERROR" + case E_ITEM_UNAVAILABLE = "E_ITEM_UNAVAILABLE" + case E_REMOTE_ERROR = "E_REMOTE_ERROR" + case E_NETWORK_ERROR = "E_NETWORK_ERROR" + case E_RECEIPT_FAILED = "E_RECEIPT_FAILED" + case E_RECEIPT_FINISHED_FAILED = "E_RECEIPT_FINISHED_FAILED" + case E_DEVELOPER_ERROR = "E_DEVELOPER_ERROR" + case E_PURCHASE_ERROR = "E_PURCHASE_ERROR" + func asInt() -> Int { + return IapErrors.allCases.firstIndex(of: self)! } } @@ -597,72 +460,4 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { return receiptData } - - func requestDidFinish(_ request: SKRequest) { - if request is SKReceiptRefreshRequest { - if isReceiptPresent() == true { - debugMessage("Receipt refreshed success") - - if let receiptBlock = receiptBlock { - receiptBlock(receiptData(), nil) - } - } else if let receiptBlock = receiptBlock { - debugMessage("Finished but receipt refreshed failed") - - let error = NSError(domain: "Receipt request finished but it failed!", code: 10, userInfo: nil) - receiptBlock(nil, error) - } - - receiptBlock = nil - } - } - - func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) { - debugMessage("removedTransactions - countPendingTransactions \(countPendingTransaction)") - - if countPendingTransaction > 0 { - countPendingTransaction -= transactions.count - - if countPendingTransaction <= 0 { - resolvePromises(forKey: "cleaningTransactions", value: nil) - countPendingTransaction = 0 - } - } - } - - // Promises: - func addPromise(forKey key: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - var promises: [RNIapIosPromise]? = promisesByKey[key] - - if promises == nil { - promises = [] - } - - promises?.append((resolve, reject)) - promisesByKey[key] = promises - } - - func resolvePromises(forKey key: String?, value: Any?) { - let promises: [RNIapIosPromise]? = promisesByKey[key ?? ""] - - if let promises = promises { - for tuple in promises { - let resolveBlck = tuple.0 - resolveBlck(value) - } - promisesByKey[key ?? ""] = nil - } - } - - func rejectPromises(forKey key: String, code: String?, message: String?, error: Error?) { - let promises = promisesByKey[key] - - if let promises = promises { - for tuple in promises { - let reject = tuple.1 - reject(code, message, error) - } - promisesByKey[key] = nil - } - } } From 543528aab929e58c23d2bb1bb8936190397baaaa Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Wed, 17 Aug 2022 17:19:19 -0700 Subject: [PATCH 10/16] serialized Transactions --- ios/IapUtils.swift | 86 ++++++++++++++++++++++++++++++++++++++++++++++ ios/RNIapIos.swift | 19 +++------- 2 files changed, 90 insertions(+), 15 deletions(-) diff --git a/ios/IapUtils.swift b/ios/IapUtils.swift index 7e8b22773..ff3a1704b 100644 --- a/ios/IapUtils.swift +++ b/ios/IapUtils.swift @@ -236,3 +236,89 @@ func getDiscountData(_ product: SKProduct) -> [[String: String?]]? { return mappedDiscounts } + +func serialize(_ p: Product) -> [String: Any?] { + return ["displayName": p.displayName, + "description": p.description, + "id": p.id, + "displayPrice": p.displayPrice, + "price": p.price, + "isFamilyShareable": p.isFamilyShareable, + "subscription": p.subscription?.subscriptionGroupID, + "jsonRepresentation": p.jsonRepresentation, + "debugDescription": p.debugDescription, + "subscription": serialize(p.subscription) + ] +} + +func serialize(_ ot: Product.SubscriptionInfo?) -> String? { + return nil + // TODO: switch ot{ + // case .none: + // return nil + // case .some(.promotional): return "promotional" + // case .some(.introductory): return "introductory" + // case .some(.code): return "code" + // case .some(_): return nil + // + // } +} + +func serialize(_ t: Transaction) -> [String: Any?] { + return ["id": t.id, + "appBundleID": t.appBundleID, + "offerID": t.offerID, + "subscriptionGroupID": t.subscriptionGroupID, + "appAccountToken": t.appAccountToken, + "debugDescription": t.debugDescription, + "deviceVerification": t.deviceVerification, + "deviceVerificationNonce": t.deviceVerificationNonce, + "expirationDate": t.expirationDate, + "isUpgraded": t.isUpgraded, + "jsonRepresentation": t.jsonRepresentation, + "offerType": serialize(t.offerType), + "expirationDate": t.expirationDate, + "originalID": t.originalID, + "originalPurchaseDate": t.originalPurchaseDate, + "ownershipType": serialize(t.ownershipType), + "productType": serialize(t.productType), + "productID": t.productID, + "purchasedQuantity": t.purchasedQuantity, + "revocationDate": t.revocationDate, + "revocationReason": t.revocationReason, + "purchaseDate": t.purchaseDate, + "signedDate": t.signedDate, + "webOrderLineItemID": t.webOrderLineItemID + ] +} + +func serialize(_ ot: Transaction.OfferType?) -> String? { + switch ot { + case .none: + return nil + case .some(.promotional): return "promotional" + case .some(.introductory): return "introductory" + case .some(.code): return "code" + case .some: return nil + } +} +func serialize(_ ot: Transaction.OwnershipType?) -> String? { + switch ot { + case .none: + return nil + case .some(.purchased): return "purchased" + case .some(.familyShared): return "familyShared" + case .some: return nil + } +} +func serialize(_ pt: Product.ProductType?) -> String? { + switch pt { + case .none: + return nil + case .some(.autoRenewable): return "autoRenewable" + case .some(.consumable): return "consumable" + case .some(.nonConsumable): return "nonConsumable" + case .some(.nonRenewable): return "nonRenewable" + case .some: return nil + } +} diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index 156769f14..2de526863 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -10,7 +10,6 @@ extension SKProductsRequest { @objc(RNIapIos) class RNIapIos: RCTEventEmitter, SKRequestDelegate { - private var promisesByKey: [String: [RNIapIosPromise]] private var hasListeners = false private var pendingTransactionWithAutoFinish = false // TODO: private var receiptBlock: ((Data?, Error?) -> Void)? // Block to handle request the receipt async from delegate @@ -19,11 +18,8 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { private var updateListenerTask: Task? private var promotedPayment: SKPayment? private var promotedProduct: SKProduct? - private var productsRequest: SKProductsRequest? - private var countPendingTransaction: Int = 0 override init() { - promisesByKey = [String: [RNIapIosPromise]]() products = [String: Product]() transactions = [String: Transaction]() super.init() @@ -64,8 +60,9 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { self.addTransaction(transaction) // Deliver products to the user. // await self.updateCustomerProductStatus() + if self.hasListeners { - self.sendEvent(withName: "purchase-updated", body: transaction) // TODO: serialize transaction + self.sendEvent(withName: "purchase-updated", body: serialize(transaction)) } // Always finish a transaction. // await transaction.finish() //TODO: Document @@ -136,16 +133,8 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { do { - let products: [[String: Any]] = try await Product.products(for: skus).map({ (product: Product) -> [String: Any] in - var prod = [String: Any]() - prod["displayName"] = product.displayName - prod["description"] = product.description - prod["id"] = product.id - prod["displayPrice"] = product.displayPrice - prod["price"] = product.price - prod["isFamilyShareable"] = product.isFamilyShareable - prod["subscription"] = product.subscription?.subscriptionGroupID - return prod + let products: [[String: Any?]] = try await Product.products(for: skus).map({ (prod: Product) -> [String: Any?] in + return serialize(prod) }) resolve(products) } catch { From 053fb5dcff2f60b718a095cd3f0ea9860d264545 Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Thu, 18 Aug 2022 16:55:22 -0700 Subject: [PATCH 11/16] removed remaining old methods, added serialization --- ios/IapUtils.swift | 307 ++++++++++----------------------------------- ios/RNIapIos.swift | 215 ++++++++----------------------- 2 files changed, 117 insertions(+), 405 deletions(-) diff --git a/ios/IapUtils.swift b/ios/IapUtils.swift index ff3a1704b..1524d62c4 100644 --- a/ios/IapUtils.swift +++ b/ios/IapUtils.swift @@ -10,6 +10,11 @@ import StoreKit typealias RNIapIosPromise = (RCTPromiseResolveBlock, RCTPromiseRejectBlock) +struct ProductOrError { + let product: Product? + let error: Error? +} + public func debugMessage(_ object: Any...) { #if DEBUG for item in object { @@ -18,223 +23,23 @@ public func debugMessage(_ object: Any...) { #endif } -// Based on https://stackoverflow.com/a/40135192/570612 -extension Date { - var millisecondsSince1970: Int64 { - return Int64((self.timeIntervalSince1970 * 1000.0).rounded()) - } - - var millisecondsSince1970String: String { - return String((self.timeIntervalSince1970 * 1000.0).rounded()) - } - - init(milliseconds: Int64) { - self = Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000) - } -} - -func getProductObject(_ product: SKProduct) -> [String: Any?] { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.locale = product.priceLocale - - let localizedPrice = formatter.string(from: product.price) - var introductoryPrice = localizedPrice - var introductoryPriceAsAmountIOS = "\(product.price)" - - var introductoryPricePaymentMode = "" - var introductoryPriceNumberOfPeriods = "" - - var introductoryPriceSubscriptionPeriod = "" - - var currencyCode: String? = "" - var countryCode: String? = "" - var periodNumberIOS = "0" - var periodUnitIOS = "" - var itemType = "iap" - - let numOfUnits = UInt(product.subscriptionPeriod?.numberOfUnits ?? 0) - let unit = product.subscriptionPeriod?.unit - - if unit == .year { - periodUnitIOS = "YEAR" - } else if unit == .month { - periodUnitIOS = "MONTH" - } else if unit == .week { - periodUnitIOS = "WEEK" - } else if unit == .day { - periodUnitIOS = "DAY" - } - - periodNumberIOS = String(format: "%lu", numOfUnits) - if numOfUnits != 0 { - itemType = "subs" - } - - // subscriptionPeriod = product.subscriptionPeriod ? [product.subscriptionPeriod stringValue] : @""; - // introductoryPrice = product.introductoryPrice != nil ? [NSString stringWithFormat:@"%@", product.introductoryPrice] : @""; - if product.introductoryPrice != nil { - formatter.locale = product.introductoryPrice?.priceLocale - - if let price = product.introductoryPrice?.price { - introductoryPrice = formatter.string(from: price) - } - - introductoryPriceAsAmountIOS = product.introductoryPrice?.price.stringValue ?? "" - - switch product.introductoryPrice?.paymentMode { - case .freeTrial: - introductoryPricePaymentMode = "FREETRIAL" - introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.subscriptionPeriod.numberOfUnits ?? 0).stringValue - - case .payAsYouGo: - introductoryPricePaymentMode = "PAYASYOUGO" - introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.numberOfPeriods ?? 0).stringValue - - case .payUpFront: - introductoryPricePaymentMode = "PAYUPFRONT" - introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.subscriptionPeriod.numberOfUnits ?? 0).stringValue - - default: - introductoryPricePaymentMode = "" - introductoryPriceNumberOfPeriods = "0" - } - - if product.introductoryPrice?.subscriptionPeriod.unit == .day { - introductoryPriceSubscriptionPeriod = "DAY" - } else if product.introductoryPrice?.subscriptionPeriod.unit == .week { - introductoryPriceSubscriptionPeriod = "WEEK" - } else if product.introductoryPrice?.subscriptionPeriod.unit == .month { - introductoryPriceSubscriptionPeriod = "MONTH" - } else if product.introductoryPrice?.subscriptionPeriod.unit == .year { - introductoryPriceSubscriptionPeriod = "YEAR" - } else { - introductoryPriceSubscriptionPeriod = "" - } - } else { - introductoryPrice = "" - introductoryPriceAsAmountIOS = "" - introductoryPricePaymentMode = "" - introductoryPriceNumberOfPeriods = "" - introductoryPriceSubscriptionPeriod = "" - } - - currencyCode = product.priceLocale.currencyCode - - countryCode = SKPaymentQueue.default().storefront?.countryCode - // countryCode = product.priceLocale.regionCode - - var discounts: [[String: String?]]? - - discounts = getDiscountData(product) - - let obj: [String: Any?] = [ - "productId": product.productIdentifier, - "price": "\(product.price)", - "currency": currencyCode, - "countryCode": countryCode ?? "", - "type": itemType, - "title": product.localizedTitle != "" ? product.localizedTitle : "", - "description": product.localizedDescription != "" ? product.localizedDescription : "", - "localizedPrice": localizedPrice, - "subscriptionPeriodNumberIOS": periodNumberIOS, - "subscriptionPeriodUnitIOS": periodUnitIOS, - "introductoryPrice": introductoryPrice, - "introductoryPriceAsAmountIOS": introductoryPriceAsAmountIOS, - "introductoryPricePaymentModeIOS": introductoryPricePaymentMode, - "introductoryPriceNumberOfPeriodsIOS": introductoryPriceNumberOfPeriods, - "introductoryPriceSubscriptionPeriodIOS": introductoryPriceSubscriptionPeriod, - "discounts": discounts - ] - - return obj -} - -func getDiscountData(_ product: SKProduct) -> [[String: String?]]? { - var mappedDiscounts: [[String: String?]] = [] - var localizedPrice: String? - var paymendMode: String? - var subscriptionPeriods: String? - var discountType: String? - - for discount in product.discounts { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - let priceLocale: Locale? = discount.priceLocale - if let pLocale = priceLocale { - formatter.locale = pLocale - } - localizedPrice = formatter.string(from: discount.price) - var numberOfPeriods: String? - - switch discount.paymentMode { - case .freeTrial: - paymendMode = "FREETRIAL" - numberOfPeriods = NSNumber(value: discount.subscriptionPeriod.numberOfUnits ).stringValue - break - - case .payAsYouGo: - paymendMode = "PAYASYOUGO" - numberOfPeriods = NSNumber(value: discount.numberOfPeriods).stringValue - break - - case .payUpFront: - paymendMode = "PAYUPFRONT" - numberOfPeriods = NSNumber(value: discount.subscriptionPeriod.numberOfUnits ).stringValue - break - - default: - paymendMode = "" - numberOfPeriods = "0" - break - } - - switch discount.subscriptionPeriod.unit { - case .day: - subscriptionPeriods = "DAY" - - case .week: - subscriptionPeriods = "WEEK" - - case .month: - subscriptionPeriods = "MONTH" - - case .year: - subscriptionPeriods = "YEAR" - - default: - subscriptionPeriods = "" - } - - let discountIdentifier = discount.identifier - switch discount.type { - case SKProductDiscount.Type.introductory: - discountType = "INTRODUCTORY" - break - - case SKProductDiscount.Type.subscription: - discountType = "SUBSCRIPTION" - break - - default: - discountType = "" - break - } - - let discountObj = [ - "identifier": discountIdentifier, - "type": discountType, - "numberOfPeriods": numberOfPeriods, - "price": "\(discount.price)", - "localizedPrice": localizedPrice, - "paymentMode": paymendMode, - "subscriptionPeriod": subscriptionPeriods - ] - - mappedDiscounts.append(discountObj) +enum IapErrors: String, CaseIterable { + case E_UNKNOWN = "E_UNKNOWN" + case E_SERVICE_ERROR = "E_SERVICE_ERROR" + case E_USER_CANCELLED = "E_USER_CANCELLED" + case E_USER_ERROR = "E_USER_ERROR" + case E_ITEM_UNAVAILABLE = "E_ITEM_UNAVAILABLE" + case E_REMOTE_ERROR = "E_REMOTE_ERROR" + case E_NETWORK_ERROR = "E_NETWORK_ERROR" + case E_RECEIPT_FAILED = "E_RECEIPT_FAILED" + case E_RECEIPT_FINISHED_FAILED = "E_RECEIPT_FINISHED_FAILED" + case E_DEVELOPER_ERROR = "E_DEVELOPER_ERROR" + case E_PURCHASE_ERROR = "E_PURCHASE_ERROR" + case E_SYNC_ERROR = "E_SYNC_ERROR" + case E_DEFERRED_PAYMENT = "E_DEFERRED_PAYMENT" + func asInt() -> Int { + return IapErrors.allCases.firstIndex(of: self)! } - - return mappedDiscounts } func serialize(_ p: Product) -> [String: Any?] { @@ -251,19 +56,37 @@ func serialize(_ p: Product) -> [String: Any?] { ] } -func serialize(_ ot: Product.SubscriptionInfo?) -> String? { - return nil - // TODO: switch ot{ - // case .none: - // return nil - // case .some(.promotional): return "promotional" - // case .some(.introductory): return "introductory" - // case .some(.code): return "code" - // case .some(_): return nil - // - // } +func serialize(_ e: Error?) -> [String: Any?]? { + guard let e = e else {return nil} + return ["localizedDescription": e.localizedDescription] +} + +func serialize(_ si: Product.SubscriptionInfo?) -> [String: Any?]? { + guard let si = si else {return nil} + return [ + "subscriptionGroupID": si.subscriptionGroupID, + // TODO: "isEligibleForIntroOffer":si?.isEligibleForIntroOffer, + "promotionalOffers": si.promotionalOffers.map {(offer: Product.SubscriptionOffer) in serialize(offer)}, + "introductoryOffer": serialize(si.introductoryOffer), + // TODO: "status":si.status, + "subscriptionPeriod": si.subscriptionPeriod + ] +} + +func serialize(_ so: Product.SubscriptionOffer?) -> [String: Any?]? { + guard let so = so else {return nil} + return [ + "id": so.id, + "price": so.price, + "displayPrice": so.displayPrice, + "type": so.type, + "paymentMode": so.paymentMode, + "period": so.period, + "periodCount": so.periodCount + ] } +// Transaction func serialize(_ t: Transaction) -> [String: Any?] { return ["id": t.id, "appBundleID": t.appBundleID, @@ -293,32 +116,32 @@ func serialize(_ t: Transaction) -> [String: Any?] { } func serialize(_ ot: Transaction.OfferType?) -> String? { + guard let ot = ot else {return nil} switch ot { - case .none: + case .promotional: return "promotional" + case .introductory: return "introductory" + case .code: return "code" + default: return nil - case .some(.promotional): return "promotional" - case .some(.introductory): return "introductory" - case .some(.code): return "code" - case .some: return nil } } func serialize(_ ot: Transaction.OwnershipType?) -> String? { + guard let ot = ot else {return nil} switch ot { - case .none: + case .purchased: return "purchased" + case .familyShared: return "familyShared" + default: return nil - case .some(.purchased): return "purchased" - case .some(.familyShared): return "familyShared" - case .some: return nil } } func serialize(_ pt: Product.ProductType?) -> String? { + guard let pt = pt else {return nil} switch pt { - case .none: + case .autoRenewable: return "autoRenewable" + case .consumable: return "consumable" + case .nonConsumable: return "nonConsumable" + case .nonRenewable: return "nonRenewable" + default: return nil - case .some(.autoRenewable): return "autoRenewable" - case .some(.consumable): return "consumable" - case .some(.nonConsumable): return "nonConsumable" - case .some(.nonRenewable): return "nonRenewable" - case .some: return nil } } diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index 2de526863..d0e27df78 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -12,12 +12,9 @@ extension SKProductsRequest { class RNIapIos: RCTEventEmitter, SKRequestDelegate { private var hasListeners = false private var pendingTransactionWithAutoFinish = false // TODO: - private var receiptBlock: ((Data?, Error?) -> Void)? // Block to handle request the receipt async from delegate private var products: [String: Product] private var transactions: [String: Transaction] private var updateListenerTask: Task? - private var promotedPayment: SKPayment? - private var promotedProduct: SKProduct? override init() { products = [String: Product]() @@ -62,7 +59,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { // await self.updateCustomerProductStatus() if self.hasListeners { - self.sendEvent(withName: "purchase-updated", body: serialize(transaction)) + self.sendEvent(withName: "transaction-updated", body: ["transaction": serialize(transaction)]) } // Always finish a transaction. // await transaction.finish() //TODO: Document @@ -70,15 +67,14 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { // StoreKit has a transaction that fails verification. Don't deliver content to the user. print("Transaction failed verification") if self.hasListeners { - let err = [ // TODO: add info + let err = [ "responseCode": "-1", "debugMessage": error.localizedDescription, "code": "E_RECEIPT_FINISHED_FAILED", - "message": error.localizedDescription, - "productId": "" + "message": error.localizedDescription ] - self.sendEvent(withName: "purchase-error", body: err) + self.sendEvent(withName: "transaction-updated", body: ["error": err]) } } } @@ -94,30 +90,18 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { } override func addListener(_ eventName: String?) { super.addListener(eventName) - - if (eventName == "iap-promoted-product") && promotedPayment != nil { - sendEvent(withName: "iap-promoted-product", body: promotedPayment?.productIdentifier) - } - } - - func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { - promotedProduct = product - promotedPayment = payment - sendEvent(withName: "iap-promoted-product", body: product.productIdentifier) - return false } override func supportedEvents() -> [String]? { - return ["iap-promoted-product", "purchase-updated", "purchase-error"] + return [ "transaction-updated"] } @objc public func initConnection( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { - let canMakePayments = AppStore.canMakePayments addTransactionObserver() - resolve(NSNumber(value: canMakePayments)) + resolve(AppStore.canMakePayments) } @objc public func endConnection( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, @@ -127,25 +111,27 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { updateListenerTask = nil resolve(nil) } - @objc public func getItems( + + @objc public func products( // TODO: renamed from getItems _ skus: [String], resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { do { - let products: [[String: Any?]] = try await Product.products(for: skus).map({ (prod: Product) -> [String: Any?] in + let products: [[String: Any?]] = try await Product.products(for: skus).map({ (prod: Product) -> [String: Any?]? in return serialize(prod) - }) + }).compactMap({$0}) resolve(products) } catch { - reject("E_UNKNOWN", "Error fetching items", nil) + reject(IapErrors.E_UNKNOWN.rawValue, "Error fetching items", error) } } - @objc public func getAvailableItems( + + @objc public func currentEntitlements( // TODO: renamed from getAvailableItems _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { - var purchasedItems: [Product] = [] + var purchasedItems: [ProductOrError] = [] // Iterate through all of the user's purchased products. for await result in Transaction.currentEntitlements { do { @@ -155,12 +141,11 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { switch transaction.productType { case .nonConsumable: if let product = products[transaction.productID] { - purchasedItems.append(product) + purchasedItems.append(ProductOrError(product: product, error: nil)) } case .nonRenewable: - if let nonRenewable = products[transaction.productID], - transaction.productID == "nonRenewing.standard" { + if let nonRenewable = products[transaction.productID] { // Non-renewing subscriptions have no inherent expiration date, so they're always // contained in `Transaction.currentEntitlements` after the user purchases them. // This app defines this non-renewing subscription's expiration date to be one year after purchase. @@ -171,21 +156,21 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { to: transaction.purchaseDate)! if currentDate < expirationDate { - purchasedItems.append(nonRenewable) + purchasedItems.append(ProductOrError(product: nonRenewable, error: nil)) } } case .autoRenewable: if let subscription = products[transaction.productID] { - purchasedItems.append(subscription) + purchasedItems.append(ProductOrError(product: subscription, error: nil)) } default: break } } catch { - print() - reject("", "", nil) // TODO + print(error) + purchasedItems.append(ProductOrError(product: nil, error: error)) } } @@ -194,10 +179,10 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { // group, so products in the subscriptions array all belong to the same group. The statuses that // `product.subscription.status` returns apply to the entire subscription group. // subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state - resolve(purchasedItems) + resolve(purchasedItems.map({(p: ProductOrError) in ["product": p.product.flatMap { serialize($0)}, "error": serialize(p.error)]})) } - @objc public func buyProduct( + @objc public func purchase( // TODO: renamed from buyProduct _ sku: String, andDangerouslyFinishTransactionAutomatically: Bool, appAccountToken: String?, @@ -216,8 +201,8 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { options.insert(.quantity(quantity)) } - let offerID = discountOffer["identifier"] // TODO: Change names to match Native API - let keyID = discountOffer["keyIdentifier"] + let offerID = discountOffer["offerID"] // TODO: Adjust JS to match these new names + let keyID = discountOffer["keyID"] let nonce = discountOffer["nonce"] let signature = discountOffer["signature"] let timestamp = discountOffer["timestamp"] @@ -248,52 +233,43 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { resolve(nil) } else { self.addTransaction(transaction) - resolve(transaction) + resolve(serialize(transaction)) } return case .userCancelled, .pending: debugMessage("Deferred (awaiting approval via parental controls, etc.)") - if hasListeners { - let err = [ - "debugMessage": "The payment was deferred (awaiting approval via parental controls for instance)", - "code": "E_DEFERRED_PAYMENT", - "message": "The payment was deferred (awaiting approval via parental controls for instance)", - "productId": sku, - "quantity": "\(quantity)" - ] - - sendEvent(withName: "purchase-error", body: err) - } + let err = [ + "debugMessage": "The payment was deferred (awaiting approval via parental controls for instance)", + "code": IapErrors.E_DEFERRED_PAYMENT.rawValue, + "message": "The payment was deferred (awaiting approval via parental controls for instance)", + "productId": sku, + "quantity": "\(quantity)" + ] + print(err) reject( - "E_DEFERRED_PAYMENT", + IapErrors.E_DEFERRED_PAYMENT.rawValue, "The payment was deferred for \(sku) (awaiting approval via parental controls for instance)", nil) return default: - reject("", "", nil)// TODO + reject(IapErrors.E_UNKNOWN.rawValue, "Unknown response from purchase", nil) return } } catch { debugMessage("Purchase Failed") - if hasListeners { - let code = IapErrors.E_PURCHASE_ERROR - let responseCode = code.rawValue - let err = [ - "responseCode": responseCode, - "debugMessage": error.localizedDescription, - "code": "\(code.asInt())", - "message": error.localizedDescription, - "productId": sku - ] - - sendEvent(withName: "purchase-error", body: err) - } + let err = [ + "responseCode": IapErrors.E_PURCHASE_ERROR.rawValue, + "debugMessage": error.localizedDescription, + "message": error.localizedDescription, + "productId": sku + ] + print(err) reject( IapErrors.E_UNKNOWN.rawValue, @@ -305,10 +281,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { } } - @MainActor - func updateCustomerProductStatus() async { - } - public enum StoreError: Error { case failedVerification } @@ -325,40 +297,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { } } - @objc public func promotedProduct( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - debugMessage("get promoted product") - resolve((promotedProduct != nil) ? getProductObject(promotedProduct!) : nil) - } - - @objc public func buyPromotedProduct( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - if let promoPayment = promotedPayment { - debugMessage("buy promoted product") - SKPaymentQueue.default().add(promoPayment) - } else { - reject(IapErrors.E_DEVELOPER_ERROR.rawValue, "Invalid product ID.", nil) - } - } - - @objc public func requestReceipt( - _ refresh: Bool, - resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - requestReceiptData(withBlock: refresh) { receiptData, error in - if error == nil { - resolve(receiptData?.base64EncodedString(options: [])) - } else { - reject(IapErrors.E_RECEIPT_FAILED.rawValue, "Invalid receipt", nil) - } - } - } - @objc public func finishTransaction( _ transactionIdentifier: String, resolve: @escaping RCTPromiseResolveBlock = { _ in }, @@ -373,7 +311,16 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { - resolve(transactions.values) + resolve(transactions.values.map({(t: Transaction) in serialize(t)})) + } + + // TODO: New method + @objc public func sync(_ resolve: @escaping RCTPromiseResolveBlock = { _ in}, reject: @escaping RCTPromiseRejectBlock = {_, _, _ in}) async { + do { + try await AppStore.sync() + } catch { + reject(IapErrors.E_SYNC_ERROR.rawValue, "Error synchronizing with the AppStore", error) + } } /** @@ -391,62 +338,4 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { reject(standardErrorCode(2), "This method is not available on tvOS", nil) #endif } - - enum IapErrors: String, CaseIterable { - case E_UNKNOWN = "E_UNKNOWN" - case E_SERVICE_ERROR = "E_SERVICE_ERROR" - case E_USER_CANCELLED = "E_USER_CANCELLED" - case E_USER_ERROR = "E_USER_ERROR" - case E_ITEM_UNAVAILABLE = "E_ITEM_UNAVAILABLE" - case E_REMOTE_ERROR = "E_REMOTE_ERROR" - case E_NETWORK_ERROR = "E_NETWORK_ERROR" - case E_RECEIPT_FAILED = "E_RECEIPT_FAILED" - case E_RECEIPT_FINISHED_FAILED = "E_RECEIPT_FINISHED_FAILED" - case E_DEVELOPER_ERROR = "E_DEVELOPER_ERROR" - case E_PURCHASE_ERROR = "E_PURCHASE_ERROR" - func asInt() -> Int { - return IapErrors.allCases.firstIndex(of: self)! - } - } - - func requestReceiptData(withBlock forceRefresh: Bool, withBlock block: @escaping (_ data: Data?, _ error: Error?) -> Void) { - debugMessage("requestReceiptDataWithBlock with force refresh: \(forceRefresh ? "YES" : "NO")") - - if forceRefresh || isReceiptPresent() == false { - let refreshRequest = SKReceiptRefreshRequest() - refreshRequest.delegate = self - refreshRequest.start() - receiptBlock = block - } else { - receiptBlock = nil - block(receiptData(), nil) - } - } - - func isReceiptPresent() -> Bool { - let receiptURL = Bundle.main.appStoreReceiptURL - var canReachError: Error? - - do { - try _ = receiptURL?.checkResourceIsReachable() - } catch let error { - canReachError = error - } - - return canReachError == nil - } - - func receiptData() -> Data? { - let receiptURL = Bundle.main.appStoreReceiptURL - var receiptData: Data? - - if let receiptURL = receiptURL { - do { - try receiptData = Data(contentsOf: receiptURL) - } catch _ { - } - } - - return receiptData - } } From f19cd5617d264698ff0cb4146556948ffe5170f3 Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Thu, 18 Aug 2022 16:58:31 -0700 Subject: [PATCH 12/16] default to Xcode 4 spaces --- .swiftlint.yml | 1 - ios/IapUtils.swift | 208 +++++++-------- ios/RNIapIos.swift | 614 ++++++++++++++++++++++----------------------- 3 files changed, 411 insertions(+), 412 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index ad9f471c7..cb1d5094d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -6,6 +6,5 @@ opt_in_rules: - operator_usage_whitespace - redundant_type_annotation -indentation: 2 vertical_whitespace_closing_braces: true vertical_whitespace_opening_braces: true diff --git a/ios/IapUtils.swift b/ios/IapUtils.swift index 1524d62c4..fb41ee602 100644 --- a/ios/IapUtils.swift +++ b/ios/IapUtils.swift @@ -11,137 +11,137 @@ import StoreKit typealias RNIapIosPromise = (RCTPromiseResolveBlock, RCTPromiseRejectBlock) struct ProductOrError { - let product: Product? - let error: Error? + let product: Product? + let error: Error? } public func debugMessage(_ object: Any...) { - #if DEBUG - for item in object { - print("[react-native-iap] \(item)") - } - #endif + #if DEBUG + for item in object { + print("[react-native-iap] \(item)") + } + #endif } enum IapErrors: String, CaseIterable { - case E_UNKNOWN = "E_UNKNOWN" - case E_SERVICE_ERROR = "E_SERVICE_ERROR" - case E_USER_CANCELLED = "E_USER_CANCELLED" - case E_USER_ERROR = "E_USER_ERROR" - case E_ITEM_UNAVAILABLE = "E_ITEM_UNAVAILABLE" - case E_REMOTE_ERROR = "E_REMOTE_ERROR" - case E_NETWORK_ERROR = "E_NETWORK_ERROR" - case E_RECEIPT_FAILED = "E_RECEIPT_FAILED" - case E_RECEIPT_FINISHED_FAILED = "E_RECEIPT_FINISHED_FAILED" - case E_DEVELOPER_ERROR = "E_DEVELOPER_ERROR" - case E_PURCHASE_ERROR = "E_PURCHASE_ERROR" - case E_SYNC_ERROR = "E_SYNC_ERROR" - case E_DEFERRED_PAYMENT = "E_DEFERRED_PAYMENT" - func asInt() -> Int { - return IapErrors.allCases.firstIndex(of: self)! - } + case E_UNKNOWN = "E_UNKNOWN" + case E_SERVICE_ERROR = "E_SERVICE_ERROR" + case E_USER_CANCELLED = "E_USER_CANCELLED" + case E_USER_ERROR = "E_USER_ERROR" + case E_ITEM_UNAVAILABLE = "E_ITEM_UNAVAILABLE" + case E_REMOTE_ERROR = "E_REMOTE_ERROR" + case E_NETWORK_ERROR = "E_NETWORK_ERROR" + case E_RECEIPT_FAILED = "E_RECEIPT_FAILED" + case E_RECEIPT_FINISHED_FAILED = "E_RECEIPT_FINISHED_FAILED" + case E_DEVELOPER_ERROR = "E_DEVELOPER_ERROR" + case E_PURCHASE_ERROR = "E_PURCHASE_ERROR" + case E_SYNC_ERROR = "E_SYNC_ERROR" + case E_DEFERRED_PAYMENT = "E_DEFERRED_PAYMENT" + func asInt() -> Int { + return IapErrors.allCases.firstIndex(of: self)! + } } func serialize(_ p: Product) -> [String: Any?] { - return ["displayName": p.displayName, - "description": p.description, - "id": p.id, - "displayPrice": p.displayPrice, - "price": p.price, - "isFamilyShareable": p.isFamilyShareable, - "subscription": p.subscription?.subscriptionGroupID, - "jsonRepresentation": p.jsonRepresentation, - "debugDescription": p.debugDescription, - "subscription": serialize(p.subscription) - ] + return ["displayName": p.displayName, + "description": p.description, + "id": p.id, + "displayPrice": p.displayPrice, + "price": p.price, + "isFamilyShareable": p.isFamilyShareable, + "subscription": p.subscription?.subscriptionGroupID, + "jsonRepresentation": p.jsonRepresentation, + "debugDescription": p.debugDescription, + "subscription": serialize(p.subscription) + ] } func serialize(_ e: Error?) -> [String: Any?]? { - guard let e = e else {return nil} - return ["localizedDescription": e.localizedDescription] + guard let e = e else {return nil} + return ["localizedDescription": e.localizedDescription] } func serialize(_ si: Product.SubscriptionInfo?) -> [String: Any?]? { - guard let si = si else {return nil} - return [ - "subscriptionGroupID": si.subscriptionGroupID, - // TODO: "isEligibleForIntroOffer":si?.isEligibleForIntroOffer, - "promotionalOffers": si.promotionalOffers.map {(offer: Product.SubscriptionOffer) in serialize(offer)}, - "introductoryOffer": serialize(si.introductoryOffer), - // TODO: "status":si.status, - "subscriptionPeriod": si.subscriptionPeriod - ] + guard let si = si else {return nil} + return [ + "subscriptionGroupID": si.subscriptionGroupID, + // TODO: "isEligibleForIntroOffer":si?.isEligibleForIntroOffer, + "promotionalOffers": si.promotionalOffers.map {(offer: Product.SubscriptionOffer) in serialize(offer)}, + "introductoryOffer": serialize(si.introductoryOffer), + // TODO: "status":si.status, + "subscriptionPeriod": si.subscriptionPeriod + ] } func serialize(_ so: Product.SubscriptionOffer?) -> [String: Any?]? { - guard let so = so else {return nil} - return [ - "id": so.id, - "price": so.price, - "displayPrice": so.displayPrice, - "type": so.type, - "paymentMode": so.paymentMode, - "period": so.period, - "periodCount": so.periodCount - ] + guard let so = so else {return nil} + return [ + "id": so.id, + "price": so.price, + "displayPrice": so.displayPrice, + "type": so.type, + "paymentMode": so.paymentMode, + "period": so.period, + "periodCount": so.periodCount + ] } // Transaction func serialize(_ t: Transaction) -> [String: Any?] { - return ["id": t.id, - "appBundleID": t.appBundleID, - "offerID": t.offerID, - "subscriptionGroupID": t.subscriptionGroupID, - "appAccountToken": t.appAccountToken, - "debugDescription": t.debugDescription, - "deviceVerification": t.deviceVerification, - "deviceVerificationNonce": t.deviceVerificationNonce, - "expirationDate": t.expirationDate, - "isUpgraded": t.isUpgraded, - "jsonRepresentation": t.jsonRepresentation, - "offerType": serialize(t.offerType), - "expirationDate": t.expirationDate, - "originalID": t.originalID, - "originalPurchaseDate": t.originalPurchaseDate, - "ownershipType": serialize(t.ownershipType), - "productType": serialize(t.productType), - "productID": t.productID, - "purchasedQuantity": t.purchasedQuantity, - "revocationDate": t.revocationDate, - "revocationReason": t.revocationReason, - "purchaseDate": t.purchaseDate, - "signedDate": t.signedDate, - "webOrderLineItemID": t.webOrderLineItemID - ] + return ["id": t.id, + "appBundleID": t.appBundleID, + "offerID": t.offerID, + "subscriptionGroupID": t.subscriptionGroupID, + "appAccountToken": t.appAccountToken, + "debugDescription": t.debugDescription, + "deviceVerification": t.deviceVerification, + "deviceVerificationNonce": t.deviceVerificationNonce, + "expirationDate": t.expirationDate, + "isUpgraded": t.isUpgraded, + "jsonRepresentation": t.jsonRepresentation, + "offerType": serialize(t.offerType), + "expirationDate": t.expirationDate, + "originalID": t.originalID, + "originalPurchaseDate": t.originalPurchaseDate, + "ownershipType": serialize(t.ownershipType), + "productType": serialize(t.productType), + "productID": t.productID, + "purchasedQuantity": t.purchasedQuantity, + "revocationDate": t.revocationDate, + "revocationReason": t.revocationReason, + "purchaseDate": t.purchaseDate, + "signedDate": t.signedDate, + "webOrderLineItemID": t.webOrderLineItemID + ] } func serialize(_ ot: Transaction.OfferType?) -> String? { - guard let ot = ot else {return nil} - switch ot { - case .promotional: return "promotional" - case .introductory: return "introductory" - case .code: return "code" - default: - return nil - } + guard let ot = ot else {return nil} + switch ot { + case .promotional: return "promotional" + case .introductory: return "introductory" + case .code: return "code" + default: + return nil + } } func serialize(_ ot: Transaction.OwnershipType?) -> String? { - guard let ot = ot else {return nil} - switch ot { - case .purchased: return "purchased" - case .familyShared: return "familyShared" - default: - return nil - } + guard let ot = ot else {return nil} + switch ot { + case .purchased: return "purchased" + case .familyShared: return "familyShared" + default: + return nil + } } func serialize(_ pt: Product.ProductType?) -> String? { - guard let pt = pt else {return nil} - switch pt { - case .autoRenewable: return "autoRenewable" - case .consumable: return "consumable" - case .nonConsumable: return "nonConsumable" - case .nonRenewable: return "nonRenewable" - default: - return nil - } + guard let pt = pt else {return nil} + switch pt { + case .autoRenewable: return "autoRenewable" + case .consumable: return "consumable" + case .nonConsumable: return "nonConsumable" + case .nonRenewable: return "nonRenewable" + default: + return nil + } } diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index d0e27df78..4f2ca2caa 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -3,339 +3,339 @@ import React import StoreKit extension SKProductsRequest { - var key: String { - return String(self.hashValue) - } + var key: String { + return String(self.hashValue) + } } @objc(RNIapIos) class RNIapIos: RCTEventEmitter, SKRequestDelegate { - private var hasListeners = false - private var pendingTransactionWithAutoFinish = false // TODO: - private var products: [String: Product] - private var transactions: [String: Transaction] - private var updateListenerTask: Task? - - override init() { - products = [String: Product]() - transactions = [String: Transaction]() - super.init() - addTransactionObserver() - } - - deinit { - removeTransactionObserver() - } - - override class func requiresMainQueueSetup() -> Bool { - return true - } - func addTransactionObserver() { - if updateListenerTask == nil { - updateListenerTask = listenForTransactions() + private var hasListeners = false + private var pendingTransactionWithAutoFinish = false // TODO: + private var products: [String: Product] + private var transactions: [String: Transaction] + private var updateListenerTask: Task? + + override init() { + products = [String: Product]() + transactions = [String: Transaction]() + super.init() + addTransactionObserver() } - } - func removeTransactionObserver() { - if updateListenerTask != nil { - updateListenerTask?.cancel() - updateListenerTask = nil + deinit { + removeTransactionObserver() } - } - func addTransaction(_ transaction: Transaction) { - let transactionId = String(transaction.id) - self.transactions[transactionId] = transaction - } + override class func requiresMainQueueSetup() -> Bool { + return true + } + func addTransactionObserver() { + if updateListenerTask == nil { + updateListenerTask = listenForTransactions() + } + } - func listenForTransactions() -> Task { - return Task.detached { - // Iterate through any transactions that don't come from a direct call to `purchase()`. - for await result in Transaction.updates { - do { - let transaction = try self.checkVerified(result) - self.addTransaction(transaction) - // Deliver products to the user. - // await self.updateCustomerProductStatus() - - if self.hasListeners { - self.sendEvent(withName: "transaction-updated", body: ["transaction": serialize(transaction)]) - } - // Always finish a transaction. - // await transaction.finish() //TODO: Document - } catch { - // StoreKit has a transaction that fails verification. Don't deliver content to the user. - print("Transaction failed verification") - if self.hasListeners { - let err = [ - "responseCode": "-1", - "debugMessage": error.localizedDescription, - "code": "E_RECEIPT_FINISHED_FAILED", - "message": error.localizedDescription - ] - - self.sendEvent(withName: "transaction-updated", body: ["error": err]) - } + func removeTransactionObserver() { + if updateListenerTask != nil { + updateListenerTask?.cancel() + updateListenerTask = nil } - } } - } - - override func startObserving() { - hasListeners = true - } - - override func stopObserving() { - hasListeners = false - } - override func addListener(_ eventName: String?) { - super.addListener(eventName) - } - - override func supportedEvents() -> [String]? { - return [ "transaction-updated"] - } - - @objc public func initConnection( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - addTransactionObserver() - resolve(AppStore.canMakePayments) - } - @objc public func endConnection( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - updateListenerTask?.cancel() - updateListenerTask = nil - resolve(nil) - } - - @objc public func products( // TODO: renamed from getItems - _ skus: [String], - resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) async { - do { - let products: [[String: Any?]] = try await Product.products(for: skus).map({ (prod: Product) -> [String: Any?]? in - return serialize(prod) - }).compactMap({$0}) - resolve(products) - } catch { - reject(IapErrors.E_UNKNOWN.rawValue, "Error fetching items", error) + + func addTransaction(_ transaction: Transaction) { + let transactionId = String(transaction.id) + self.transactions[transactionId] = transaction } - } - - @objc public func currentEntitlements( // TODO: renamed from getAvailableItems - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) async { - var purchasedItems: [ProductOrError] = [] - // Iterate through all of the user's purchased products. - for await result in Transaction.currentEntitlements { - do { - // Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. - let transaction = try checkVerified(result) - // Check the `productType` of the transaction and get the corresponding product from the store. - switch transaction.productType { - case .nonConsumable: - if let product = products[transaction.productID] { - purchasedItems.append(ProductOrError(product: product, error: nil)) - } - - case .nonRenewable: - if let nonRenewable = products[transaction.productID] { - // Non-renewing subscriptions have no inherent expiration date, so they're always - // contained in `Transaction.currentEntitlements` after the user purchases them. - // This app defines this non-renewing subscription's expiration date to be one year after purchase. - // If the current date is within one year of the `purchaseDate`, the user is still entitled to this - // product. - let currentDate = Date() - let expirationDate = Calendar(identifier: .gregorian).date(byAdding: DateComponents(year: 1), - to: transaction.purchaseDate)! - - if currentDate < expirationDate { - purchasedItems.append(ProductOrError(product: nonRenewable, error: nil)) + + func listenForTransactions() -> Task { + return Task.detached { + // Iterate through any transactions that don't come from a direct call to `purchase()`. + for await result in Transaction.updates { + do { + let transaction = try self.checkVerified(result) + self.addTransaction(transaction) + // Deliver products to the user. + // await self.updateCustomerProductStatus() + + if self.hasListeners { + self.sendEvent(withName: "transaction-updated", body: ["transaction": serialize(transaction)]) + } + // Always finish a transaction. + // await transaction.finish() //TODO: Document + } catch { + // StoreKit has a transaction that fails verification. Don't deliver content to the user. + print("Transaction failed verification") + if self.hasListeners { + let err = [ + "responseCode": "-1", + "debugMessage": error.localizedDescription, + "code": "E_RECEIPT_FINISHED_FAILED", + "message": error.localizedDescription + ] + + self.sendEvent(withName: "transaction-updated", body: ["error": err]) + } + } } - } + } + } - case .autoRenewable: - if let subscription = products[transaction.productID] { - purchasedItems.append(ProductOrError(product: subscription, error: nil)) - } + override func startObserving() { + hasListeners = true + } + + override func stopObserving() { + hasListeners = false + } + override func addListener(_ eventName: String?) { + super.addListener(eventName) + } + + override func supportedEvents() -> [String]? { + return [ "transaction-updated"] + } + + @objc public func initConnection( + _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) { + addTransactionObserver() + resolve(AppStore.canMakePayments) + } + @objc public func endConnection( + _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) { + updateListenerTask?.cancel() + updateListenerTask = nil + resolve(nil) + } - default: - break + @objc public func products( // TODO: renamed from getItems + _ skus: [String], + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + do { + let products: [[String: Any?]] = try await Product.products(for: skus).map({ (prod: Product) -> [String: Any?]? in + return serialize(prod) + }).compactMap({$0}) + resolve(products) + } catch { + reject(IapErrors.E_UNKNOWN.rawValue, "Error fetching items", error) } - } catch { - print(error) - purchasedItems.append(ProductOrError(product: nil, error: error)) - } } - // Check the `subscriptionGroupStatus` to learn the auto-renewable subscription state to determine whether the customer - // is new (never subscribed), active, or inactive (expired subscription). This app has only one subscription - // group, so products in the subscriptions array all belong to the same group. The statuses that - // `product.subscription.status` returns apply to the entire subscription group. - // subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state - resolve(purchasedItems.map({(p: ProductOrError) in ["product": p.product.flatMap { serialize($0)}, "error": serialize(p.error)]})) - } - - @objc public func purchase( // TODO: renamed from buyProduct - _ sku: String, - andDangerouslyFinishTransactionAutomatically: Bool, - appAccountToken: String?, - quantity: Int, - withOffer discountOffer: [String: String], - resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) async { - pendingTransactionWithAutoFinish = andDangerouslyFinishTransactionAutomatically - let product: Product? = products[sku] - - if let product = product { - do { - var options: Set = [] - if quantity > -1 { - options.insert(.quantity(quantity)) + @objc public func currentEntitlements( // TODO: renamed from getAvailableItems + _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + var purchasedItems: [ProductOrError] = [] + // Iterate through all of the user's purchased products. + for await result in Transaction.currentEntitlements { + do { + // Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. + let transaction = try checkVerified(result) + // Check the `productType` of the transaction and get the corresponding product from the store. + switch transaction.productType { + case .nonConsumable: + if let product = products[transaction.productID] { + purchasedItems.append(ProductOrError(product: product, error: nil)) + } + + case .nonRenewable: + if let nonRenewable = products[transaction.productID] { + // Non-renewing subscriptions have no inherent expiration date, so they're always + // contained in `Transaction.currentEntitlements` after the user purchases them. + // This app defines this non-renewing subscription's expiration date to be one year after purchase. + // If the current date is within one year of the `purchaseDate`, the user is still entitled to this + // product. + let currentDate = Date() + let expirationDate = Calendar(identifier: .gregorian).date(byAdding: DateComponents(year: 1), + to: transaction.purchaseDate)! + + if currentDate < expirationDate { + purchasedItems.append(ProductOrError(product: nonRenewable, error: nil)) + } + } + + case .autoRenewable: + if let subscription = products[transaction.productID] { + purchasedItems.append(ProductOrError(product: subscription, error: nil)) + } + + default: + break + } + } catch { + print(error) + purchasedItems.append(ProductOrError(product: nil, error: error)) + } } - let offerID = discountOffer["offerID"] // TODO: Adjust JS to match these new names - let keyID = discountOffer["keyID"] - let nonce = discountOffer["nonce"] - let signature = discountOffer["signature"] - let timestamp = discountOffer["timestamp"] + // Check the `subscriptionGroupStatus` to learn the auto-renewable subscription state to determine whether the customer + // is new (never subscribed), active, or inactive (expired subscription). This app has only one subscription + // group, so products in the subscriptions array all belong to the same group. The statuses that + // `product.subscription.status` returns apply to the entire subscription group. + // subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state + resolve(purchasedItems.map({(p: ProductOrError) in ["product": p.product.flatMap { serialize($0)}, "error": serialize(p.error)]})) + } - if let offerID = offerID, let keyID = keyID, let nonce = nonce, let nonce = UUID(uuidString: nonce), let signature = signature, let signature = signature.data(using: .utf8), let timestamp = timestamp, let timestamp = Int(timestamp) { - options.insert(.promotionalOffer(offerID: offerID, keyID: keyID, nonce: nonce, signature: signature, timestamp: timestamp )) - } - if let appAccountToken = appAccountToken, let appAccountToken = UUID(uuidString: appAccountToken) { - options.insert(.appAccountToken(appAccountToken)) + @objc public func purchase( // TODO: renamed from buyProduct + _ sku: String, + andDangerouslyFinishTransactionAutomatically: Bool, + appAccountToken: String?, + quantity: Int, + withOffer discountOffer: [String: String], + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + pendingTransactionWithAutoFinish = andDangerouslyFinishTransactionAutomatically + let product: Product? = products[sku] + + if let product = product { + do { + var options: Set = [] + if quantity > -1 { + options.insert(.quantity(quantity)) + } + + let offerID = discountOffer["offerID"] // TODO: Adjust JS to match these new names + let keyID = discountOffer["keyID"] + let nonce = discountOffer["nonce"] + let signature = discountOffer["signature"] + let timestamp = discountOffer["timestamp"] + + if let offerID = offerID, let keyID = keyID, let nonce = nonce, let nonce = UUID(uuidString: nonce), let signature = signature, let signature = signature.data(using: .utf8), let timestamp = timestamp, let timestamp = Int(timestamp) { + options.insert(.promotionalOffer(offerID: offerID, keyID: keyID, nonce: nonce, signature: signature, timestamp: timestamp )) + } + if let appAccountToken = appAccountToken, let appAccountToken = UUID(uuidString: appAccountToken) { + options.insert(.appAccountToken(appAccountToken)) + } + debugMessage("Purchase Started") + + let result = try await product.purchase(options: options) + switch result { + case .success(let verification): + debugMessage("Purchase Successful") + + // Check whether the transaction is verified. If it isn't, + // this function rethrows the verification error. + let transaction = try checkVerified(verification) + + // The transaction is verified. Deliver content to the user. + // Do on JS :await updateCustomerProductStatus() + + // Always finish a transaction. + if andDangerouslyFinishTransactionAutomatically { + await transaction.finish() + resolve(nil) + } else { + self.addTransaction(transaction) + resolve(serialize(transaction)) + } + return + + case .userCancelled, .pending: + debugMessage("Deferred (awaiting approval via parental controls, etc.)") + + let err = [ + "debugMessage": "The payment was deferred (awaiting approval via parental controls for instance)", + "code": IapErrors.E_DEFERRED_PAYMENT.rawValue, + "message": "The payment was deferred (awaiting approval via parental controls for instance)", + "productId": sku, + "quantity": "\(quantity)" + ] + print(err) + + reject( + IapErrors.E_DEFERRED_PAYMENT.rawValue, + "The payment was deferred for \(sku) (awaiting approval via parental controls for instance)", + nil) + + return + + default: + reject(IapErrors.E_UNKNOWN.rawValue, "Unknown response from purchase", nil) + return + } + } catch { + debugMessage("Purchase Failed") + + let err = [ + "responseCode": IapErrors.E_PURCHASE_ERROR.rawValue, + "debugMessage": error.localizedDescription, + "message": error.localizedDescription, + "productId": sku + ] + print(err) + + reject( + IapErrors.E_UNKNOWN.rawValue, + "Purchased failed for sku:\(sku): \(error.localizedDescription)", + error) + } + } else { + reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) } - debugMessage("Purchase Started") + } - let result = try await product.purchase(options: options) + public enum StoreError: Error { + case failedVerification + } + func checkVerified(_ result: VerificationResult) throws -> T { + // Check whether the JWS passes StoreKit verification. switch result { - case .success(let verification): - debugMessage("Purchase Successful") - - // Check whether the transaction is verified. If it isn't, - // this function rethrows the verification error. - let transaction = try checkVerified(verification) - - // The transaction is verified. Deliver content to the user. - // Do on JS :await updateCustomerProductStatus() - - // Always finish a transaction. - if andDangerouslyFinishTransactionAutomatically { - await transaction.finish() - resolve(nil) - } else { - self.addTransaction(transaction) - resolve(serialize(transaction)) - } - return - - case .userCancelled, .pending: - debugMessage("Deferred (awaiting approval via parental controls, etc.)") - - let err = [ - "debugMessage": "The payment was deferred (awaiting approval via parental controls for instance)", - "code": IapErrors.E_DEFERRED_PAYMENT.rawValue, - "message": "The payment was deferred (awaiting approval via parental controls for instance)", - "productId": sku, - "quantity": "\(quantity)" - ] - print(err) - - reject( - IapErrors.E_DEFERRED_PAYMENT.rawValue, - "The payment was deferred for \(sku) (awaiting approval via parental controls for instance)", - nil) - - return - - default: - reject(IapErrors.E_UNKNOWN.rawValue, "Unknown response from purchase", nil) - return + case .unverified: + // StoreKit parses the JWS, but it fails verification. + throw StoreError.failedVerification + + case .verified(let safe): + // The result is verified. Return the unwrapped value. + return safe } - } catch { - debugMessage("Purchase Failed") - - let err = [ - "responseCode": IapErrors.E_PURCHASE_ERROR.rawValue, - "debugMessage": error.localizedDescription, - "message": error.localizedDescription, - "productId": sku - ] - print(err) - - reject( - IapErrors.E_UNKNOWN.rawValue, - "Purchased failed for sku:\(sku): \(error.localizedDescription)", - error) - } - } else { - reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) } - } - - public enum StoreError: Error { - case failedVerification - } - func checkVerified(_ result: VerificationResult) throws -> T { - // Check whether the JWS passes StoreKit verification. - switch result { - case .unverified: - // StoreKit parses the JWS, but it fails verification. - throw StoreError.failedVerification - - case .verified(let safe): - // The result is verified. Return the unwrapped value. - return safe + + @objc public func finishTransaction( + _ transactionIdentifier: String, + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + await transactions[transactionIdentifier]?.finish() + transactions.removeValue(forKey: transactionIdentifier) + resolve(nil) + } + + @objc public func getPendingTransactions ( + _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) { + resolve(transactions.values.map({(t: Transaction) in serialize(t)})) } - } - - @objc public func finishTransaction( - _ transactionIdentifier: String, - resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) async { - await transactions[transactionIdentifier]?.finish() - transactions.removeValue(forKey: transactionIdentifier) - resolve(nil) - } - - @objc public func getPendingTransactions ( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - resolve(transactions.values.map({(t: Transaction) in serialize(t)})) - } - - // TODO: New method - @objc public func sync(_ resolve: @escaping RCTPromiseResolveBlock = { _ in}, reject: @escaping RCTPromiseRejectBlock = {_, _, _ in}) async { - do { - try await AppStore.sync() - } catch { - reject(IapErrors.E_SYNC_ERROR.rawValue, "Error synchronizing with the AppStore", error) + + // TODO: New method + @objc public func sync(_ resolve: @escaping RCTPromiseResolveBlock = { _ in}, reject: @escaping RCTPromiseRejectBlock = {_, _, _ in}) async { + do { + try await AppStore.sync() + } catch { + reject(IapErrors.E_SYNC_ERROR.rawValue, "Error synchronizing with the AppStore", error) + } + } + + /** + Should remain the same according to: + https://stackoverflow.com/a/72789651/570612 + */ + @objc public func presentCodeRedemptionSheet( + _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) { + #if !os(tvOS) + SKPaymentQueue.default().presentCodeRedemptionSheet() + resolve(nil) + #else + reject(standardErrorCode(2), "This method is not available on tvOS", nil) + #endif } - } - - /** - Should remain the same according to: - https://stackoverflow.com/a/72789651/570612 - */ - @objc public func presentCodeRedemptionSheet( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - #if !os(tvOS) - SKPaymentQueue.default().presentCodeRedemptionSheet() - resolve(nil) - #else - reject(standardErrorCode(2), "This method is not available on tvOS", nil) - #endif - } } From fd75f7f290d249ab1da7e7d5a6dfc96d2b2aef07 Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Thu, 18 Aug 2022 17:24:13 -0700 Subject: [PATCH 13/16] Split files --- ios/IapSerializationUtils.swift | 113 +++++++++++++++++++++++++++ ios/IapTypes.swift | 35 +++++++++ ios/IapUtils.swift | 130 -------------------------------- 3 files changed, 148 insertions(+), 130 deletions(-) create mode 100644 ios/IapSerializationUtils.swift create mode 100644 ios/IapTypes.swift diff --git a/ios/IapSerializationUtils.swift b/ios/IapSerializationUtils.swift new file mode 100644 index 000000000..2ade4c80a --- /dev/null +++ b/ios/IapSerializationUtils.swift @@ -0,0 +1,113 @@ +// +// IapSerializationUtils.swift +// RNIap +// +// Created by Aguilar Andres on 8/18/22. +// + +import Foundation +import StoreKit + +func serialize(_ p: Product) -> [String: Any?] { + return ["displayName": p.displayName, + "description": p.description, + "id": p.id, + "displayPrice": p.displayPrice, + "price": p.price, + "isFamilyShareable": p.isFamilyShareable, + "subscription": p.subscription?.subscriptionGroupID, + "jsonRepresentation": p.jsonRepresentation, + "debugDescription": p.debugDescription, + "subscription": serialize(p.subscription) + ] +} + +func serialize(_ e: Error?) -> [String: Any?]? { + guard let e = e else {return nil} + return ["localizedDescription": e.localizedDescription] +} + +func serialize(_ si: Product.SubscriptionInfo?) -> [String: Any?]? { + guard let si = si else {return nil} + return [ + "subscriptionGroupID": si.subscriptionGroupID, + // TODO: "isEligibleForIntroOffer":si?.isEligibleForIntroOffer, + "promotionalOffers": si.promotionalOffers.map {(offer: Product.SubscriptionOffer) in serialize(offer)}, + "introductoryOffer": serialize(si.introductoryOffer), + // TODO: "status":si.status, + "subscriptionPeriod": si.subscriptionPeriod + ] +} + +func serialize(_ so: Product.SubscriptionOffer?) -> [String: Any?]? { + guard let so = so else {return nil} + return [ + "id": so.id, + "price": so.price, + "displayPrice": so.displayPrice, + "type": so.type, + "paymentMode": so.paymentMode, + "period": so.period, + "periodCount": so.periodCount + ] +} + +// Transaction +func serialize(_ t: Transaction) -> [String: Any?] { + return ["id": t.id, + "appBundleID": t.appBundleID, + "offerID": t.offerID, + "subscriptionGroupID": t.subscriptionGroupID, + "appAccountToken": t.appAccountToken, + "debugDescription": t.debugDescription, + "deviceVerification": t.deviceVerification, + "deviceVerificationNonce": t.deviceVerificationNonce, + "expirationDate": t.expirationDate, + "isUpgraded": t.isUpgraded, + "jsonRepresentation": t.jsonRepresentation, + "offerType": serialize(t.offerType), + "expirationDate": t.expirationDate, + "originalID": t.originalID, + "originalPurchaseDate": t.originalPurchaseDate, + "ownershipType": serialize(t.ownershipType), + "productType": serialize(t.productType), + "productID": t.productID, + "purchasedQuantity": t.purchasedQuantity, + "revocationDate": t.revocationDate, + "revocationReason": t.revocationReason, + "purchaseDate": t.purchaseDate, + "signedDate": t.signedDate, + "webOrderLineItemID": t.webOrderLineItemID + ] +} + +func serialize(_ ot: Transaction.OfferType?) -> String? { + guard let ot = ot else {return nil} + switch ot { + case .promotional: return "promotional" + case .introductory: return "introductory" + case .code: return "code" + default: + return nil + } +} +func serialize(_ ot: Transaction.OwnershipType?) -> String? { + guard let ot = ot else {return nil} + switch ot { + case .purchased: return "purchased" + case .familyShared: return "familyShared" + default: + return nil + } +} +func serialize(_ pt: Product.ProductType?) -> String? { + guard let pt = pt else {return nil} + switch pt { + case .autoRenewable: return "autoRenewable" + case .consumable: return "consumable" + case .nonConsumable: return "nonConsumable" + case .nonRenewable: return "nonRenewable" + default: + return nil + } +} diff --git a/ios/IapTypes.swift b/ios/IapTypes.swift new file mode 100644 index 000000000..c869af0dc --- /dev/null +++ b/ios/IapTypes.swift @@ -0,0 +1,35 @@ +// +// IapTypes.swift +// RNIap +// +// Created by Aguilar Andres on 8/18/22. +// + +import Foundation +import StoreKit + +typealias RNIapIosPromise = (RCTPromiseResolveBlock, RCTPromiseRejectBlock) + +struct ProductOrError { + let product: Product? + let error: Error? +} + +enum IapErrors: String, CaseIterable { + case E_UNKNOWN = "E_UNKNOWN" + case E_SERVICE_ERROR = "E_SERVICE_ERROR" + case E_USER_CANCELLED = "E_USER_CANCELLED" + case E_USER_ERROR = "E_USER_ERROR" + case E_ITEM_UNAVAILABLE = "E_ITEM_UNAVAILABLE" + case E_REMOTE_ERROR = "E_REMOTE_ERROR" + case E_NETWORK_ERROR = "E_NETWORK_ERROR" + case E_RECEIPT_FAILED = "E_RECEIPT_FAILED" + case E_RECEIPT_FINISHED_FAILED = "E_RECEIPT_FINISHED_FAILED" + case E_DEVELOPER_ERROR = "E_DEVELOPER_ERROR" + case E_PURCHASE_ERROR = "E_PURCHASE_ERROR" + case E_SYNC_ERROR = "E_SYNC_ERROR" + case E_DEFERRED_PAYMENT = "E_DEFERRED_PAYMENT" + func asInt() -> Int { + return IapErrors.allCases.firstIndex(of: self)! + } +} diff --git a/ios/IapUtils.swift b/ios/IapUtils.swift index fb41ee602..850da3d80 100644 --- a/ios/IapUtils.swift +++ b/ios/IapUtils.swift @@ -8,13 +8,6 @@ import Foundation import StoreKit -typealias RNIapIosPromise = (RCTPromiseResolveBlock, RCTPromiseRejectBlock) - -struct ProductOrError { - let product: Product? - let error: Error? -} - public func debugMessage(_ object: Any...) { #if DEBUG for item in object { @@ -22,126 +15,3 @@ public func debugMessage(_ object: Any...) { } #endif } - -enum IapErrors: String, CaseIterable { - case E_UNKNOWN = "E_UNKNOWN" - case E_SERVICE_ERROR = "E_SERVICE_ERROR" - case E_USER_CANCELLED = "E_USER_CANCELLED" - case E_USER_ERROR = "E_USER_ERROR" - case E_ITEM_UNAVAILABLE = "E_ITEM_UNAVAILABLE" - case E_REMOTE_ERROR = "E_REMOTE_ERROR" - case E_NETWORK_ERROR = "E_NETWORK_ERROR" - case E_RECEIPT_FAILED = "E_RECEIPT_FAILED" - case E_RECEIPT_FINISHED_FAILED = "E_RECEIPT_FINISHED_FAILED" - case E_DEVELOPER_ERROR = "E_DEVELOPER_ERROR" - case E_PURCHASE_ERROR = "E_PURCHASE_ERROR" - case E_SYNC_ERROR = "E_SYNC_ERROR" - case E_DEFERRED_PAYMENT = "E_DEFERRED_PAYMENT" - func asInt() -> Int { - return IapErrors.allCases.firstIndex(of: self)! - } -} - -func serialize(_ p: Product) -> [String: Any?] { - return ["displayName": p.displayName, - "description": p.description, - "id": p.id, - "displayPrice": p.displayPrice, - "price": p.price, - "isFamilyShareable": p.isFamilyShareable, - "subscription": p.subscription?.subscriptionGroupID, - "jsonRepresentation": p.jsonRepresentation, - "debugDescription": p.debugDescription, - "subscription": serialize(p.subscription) - ] -} - -func serialize(_ e: Error?) -> [String: Any?]? { - guard let e = e else {return nil} - return ["localizedDescription": e.localizedDescription] -} - -func serialize(_ si: Product.SubscriptionInfo?) -> [String: Any?]? { - guard let si = si else {return nil} - return [ - "subscriptionGroupID": si.subscriptionGroupID, - // TODO: "isEligibleForIntroOffer":si?.isEligibleForIntroOffer, - "promotionalOffers": si.promotionalOffers.map {(offer: Product.SubscriptionOffer) in serialize(offer)}, - "introductoryOffer": serialize(si.introductoryOffer), - // TODO: "status":si.status, - "subscriptionPeriod": si.subscriptionPeriod - ] -} - -func serialize(_ so: Product.SubscriptionOffer?) -> [String: Any?]? { - guard let so = so else {return nil} - return [ - "id": so.id, - "price": so.price, - "displayPrice": so.displayPrice, - "type": so.type, - "paymentMode": so.paymentMode, - "period": so.period, - "periodCount": so.periodCount - ] -} - -// Transaction -func serialize(_ t: Transaction) -> [String: Any?] { - return ["id": t.id, - "appBundleID": t.appBundleID, - "offerID": t.offerID, - "subscriptionGroupID": t.subscriptionGroupID, - "appAccountToken": t.appAccountToken, - "debugDescription": t.debugDescription, - "deviceVerification": t.deviceVerification, - "deviceVerificationNonce": t.deviceVerificationNonce, - "expirationDate": t.expirationDate, - "isUpgraded": t.isUpgraded, - "jsonRepresentation": t.jsonRepresentation, - "offerType": serialize(t.offerType), - "expirationDate": t.expirationDate, - "originalID": t.originalID, - "originalPurchaseDate": t.originalPurchaseDate, - "ownershipType": serialize(t.ownershipType), - "productType": serialize(t.productType), - "productID": t.productID, - "purchasedQuantity": t.purchasedQuantity, - "revocationDate": t.revocationDate, - "revocationReason": t.revocationReason, - "purchaseDate": t.purchaseDate, - "signedDate": t.signedDate, - "webOrderLineItemID": t.webOrderLineItemID - ] -} - -func serialize(_ ot: Transaction.OfferType?) -> String? { - guard let ot = ot else {return nil} - switch ot { - case .promotional: return "promotional" - case .introductory: return "introductory" - case .code: return "code" - default: - return nil - } -} -func serialize(_ ot: Transaction.OwnershipType?) -> String? { - guard let ot = ot else {return nil} - switch ot { - case .purchased: return "purchased" - case .familyShared: return "familyShared" - default: - return nil - } -} -func serialize(_ pt: Product.ProductType?) -> String? { - guard let pt = pt else {return nil} - switch pt { - case .autoRenewable: return "autoRenewable" - case .consumable: return "consumable" - case .nonConsumable: return "nonConsumable" - case .nonRenewable: return "nonRenewable" - default: - return nil - } -} From e905ff90d8b0502de8d151b9c810d02a106fe447 Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Fri, 19 Aug 2022 13:16:11 -0700 Subject: [PATCH 14/16] Added more transaction methods --- ios/IapSerializationUtils.swift | 5 +- ios/IapTypes.swift | 4 ++ ios/IapUtils.swift | 13 +++++ ios/RNIapIos.m | 30 +++++----- ios/RNIapIos.swift | 98 ++++++++++++++++++++++++++------- 5 files changed, 114 insertions(+), 36 deletions(-) diff --git a/ios/IapSerializationUtils.swift b/ios/IapSerializationUtils.swift index 2ade4c80a..170b31f10 100644 --- a/ios/IapSerializationUtils.swift +++ b/ios/IapSerializationUtils.swift @@ -18,7 +18,8 @@ func serialize(_ p: Product) -> [String: Any?] { "subscription": p.subscription?.subscriptionGroupID, "jsonRepresentation": p.jsonRepresentation, "debugDescription": p.debugDescription, - "subscription": serialize(p.subscription) + "subscription": serialize(p.subscription), + "type": serialize(p.type) ] } @@ -31,10 +32,8 @@ func serialize(_ si: Product.SubscriptionInfo?) -> [String: Any?]? { guard let si = si else {return nil} return [ "subscriptionGroupID": si.subscriptionGroupID, - // TODO: "isEligibleForIntroOffer":si?.isEligibleForIntroOffer, "promotionalOffers": si.promotionalOffers.map {(offer: Product.SubscriptionOffer) in serialize(offer)}, "introductoryOffer": serialize(si.introductoryOffer), - // TODO: "status":si.status, "subscriptionPeriod": si.subscriptionPeriod ] } diff --git a/ios/IapTypes.swift b/ios/IapTypes.swift index c869af0dc..93e6e752a 100644 --- a/ios/IapTypes.swift +++ b/ios/IapTypes.swift @@ -15,6 +15,10 @@ struct ProductOrError { let error: Error? } +public enum StoreError: Error { + case failedVerification +} + enum IapErrors: String, CaseIterable { case E_UNKNOWN = "E_UNKNOWN" case E_SERVICE_ERROR = "E_SERVICE_ERROR" diff --git a/ios/IapUtils.swift b/ios/IapUtils.swift index 850da3d80..e5fbbdab8 100644 --- a/ios/IapUtils.swift +++ b/ios/IapUtils.swift @@ -15,3 +15,16 @@ public func debugMessage(_ object: Any...) { } #endif } + +func checkVerified(_ result: VerificationResult) throws -> T { + // Check whether the JWS passes StoreKit verification. + switch result { + case .unverified: + // StoreKit parses the JWS, but it fails verification. + throw StoreError.failedVerification + + case .verified(let safe): + // The result is verified. Return the unwrapped value. + return safe + } +} diff --git a/ios/RNIapIos.m b/ios/RNIapIos.m index f7c67cba0..f50038e32 100644 --- a/ios/RNIapIos.m +++ b/ios/RNIapIos.m @@ -12,16 +12,16 @@ @interface RCT_EXTERN_MODULE (RNIapIos, NSObject) (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(getItems: +RCT_EXTERN_METHOD(products: (NSArray*)skus resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(getAvailableItems: +RCT_EXTERN_METHOD(currentEntitlements: (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(buyProduct: +RCT_EXTERN_METHOD(purchase: (NSString*)sku andDangerouslyFinishTransactionAutomatically:(BOOL)andDangerouslyFinishTransactionAutomatically appAccountToken:(NSString*)appAccountToken @@ -30,27 +30,31 @@ @interface RCT_EXTERN_MODULE (RNIapIos, NSObject) resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(clearTransaction: - (RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(promotedProduct: - (RCTPromiseResolveBlock)resolve +RCT_EXTERN_METHOD(isEligibleForIntroOffer: + (NSString*)groupID + resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(buyPromotedProduct:(RCTPromiseResolveBlock)resolve +RCT_EXTERN_METHOD(currentEntitlement: + (NSString*)sku + resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(requestReceipt: - (BOOL)refresh +RCT_EXTERN_METHOD(latestTransaction: + (NSString*)sku resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD(finishTransaction: (NSString*)transactionIdentifier resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(getPendingTransactions: +RCT_EXTERN_METHOD(pendingTransactions: + (RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(sync: (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index 4f2ca2caa..891d79d19 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -37,10 +37,8 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { } func removeTransactionObserver() { - if updateListenerTask != nil { - updateListenerTask?.cancel() - updateListenerTask = nil - } + updateListenerTask?.cancel() + updateListenerTask = nil } func addTransaction(_ transaction: Transaction) { @@ -53,7 +51,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { // Iterate through any transactions that don't come from a direct call to `purchase()`. for await result in Transaction.updates { do { - let transaction = try self.checkVerified(result) + let transaction = try checkVerified(result) self.addTransaction(transaction) // Deliver products to the user. // await self.updateCustomerProductStatus() @@ -168,8 +166,10 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { default: break } + } catch StoreError.failedVerification { + purchasedItems.append(ProductOrError(product: nil, error: StoreError.failedVerification)) } catch { - print(error) + debugMessage(error) purchasedItems.append(ProductOrError(product: nil, error: error)) } } @@ -281,19 +281,75 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { } } - public enum StoreError: Error { - case failedVerification + @objc public func isEligibleForIntroOffer( // TODO: new method + _ groupID: String, + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + let isEligibleForIntroOffer = await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID) + resolve(isEligibleForIntroOffer) + } + + @objc public func subscriptionStatus( // TODO: new method + _ sku: String, + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + do { + let status = try await products[sku]?.subscription?.status + resolve(status) + } catch { + reject("", "", error) + } + } + + @objc public func currentEntitlement( // TODO: new method + _ sku: String, + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + if let product = products[sku] { + if let result = await product.currentEntitlement { + do { + // Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. + let transaction = try checkVerified(result) + resolve(serialize(transaction)) + } catch StoreError.failedVerification { + reject(IapErrors.E_UNKNOWN.rawValue, "Failed to verify transaction for sku \(sku)", StoreError.failedVerification) + } catch { + debugMessage(error) + reject(IapErrors.E_UNKNOWN.rawValue, "Error fetching entitlement for sku \(sku)", error) + } + } else { + reject(IapErrors.E_DEVELOPER_ERROR.rawValue, "Can't find entitlement for sku \(sku)", nil) + } + } else { + reject(IapErrors.E_DEVELOPER_ERROR.rawValue, "Can't find product for sku \(sku)", nil) + } } - func checkVerified(_ result: VerificationResult) throws -> T { - // Check whether the JWS passes StoreKit verification. - switch result { - case .unverified: - // StoreKit parses the JWS, but it fails verification. - throw StoreError.failedVerification - - case .verified(let safe): - // The result is verified. Return the unwrapped value. - return safe + + @objc public func latestTransaction( // TODO: new method + _ sku: String, + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + if let product = products[sku] { + if let result = await product.latestTransaction { + do { + // Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. + let transaction = try checkVerified(result) + resolve(serialize(transaction)) + } catch StoreError.failedVerification { + reject(IapErrors.E_UNKNOWN.rawValue, "Failed to verify transaction for sku \(sku)", StoreError.failedVerification) + } catch { + debugMessage(error) + reject(IapErrors.E_UNKNOWN.rawValue, "Error fetching latest transaction for sku \(sku)", error) + } + } else { + reject(IapErrors.E_DEVELOPER_ERROR.rawValue, "Can't find latest transaction for sku \(sku)", nil) + } + } else { + reject(IapErrors.E_DEVELOPER_ERROR.rawValue, "Can't find product for sku \(sku)", nil) } } @@ -307,7 +363,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { resolve(nil) } - @objc public func getPendingTransactions ( + @objc public func pendingTransactions ( _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) { @@ -315,7 +371,9 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { } // TODO: New method - @objc public func sync(_ resolve: @escaping RCTPromiseResolveBlock = { _ in}, reject: @escaping RCTPromiseRejectBlock = {_, _, _ in}) async { + @objc public func sync(_ resolve: @escaping RCTPromiseResolveBlock = { _ in}, + reject: @escaping RCTPromiseRejectBlock = {_, _, _ in} + ) async { do { try await AppStore.sync() } catch { From 414fc335c55c39dc3c3bf13167c46e3dfc05813d Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Fri, 19 Aug 2022 14:05:29 -0700 Subject: [PATCH 15/16] removed global autofinish --- ios/RNIapIos.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index 891d79d19..49461121a 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -11,7 +11,6 @@ extension SKProductsRequest { @objc(RNIapIos) class RNIapIos: RCTEventEmitter, SKRequestDelegate { private var hasListeners = false - private var pendingTransactionWithAutoFinish = false // TODO: private var products: [String: Product] private var transactions: [String: Transaction] private var updateListenerTask: Task? @@ -63,7 +62,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { // await transaction.finish() //TODO: Document } catch { // StoreKit has a transaction that fails verification. Don't deliver content to the user. - print("Transaction failed verification") + debugMessage("Transaction failed verification") if self.hasListeners { let err = [ "responseCode": "-1", @@ -191,7 +190,6 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { resolve: @escaping RCTPromiseResolveBlock = { _ in }, reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } ) async { - pendingTransactionWithAutoFinish = andDangerouslyFinishTransactionAutomatically let product: Product? = products[sku] if let product = product { @@ -247,7 +245,7 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { "productId": sku, "quantity": "\(quantity)" ] - print(err) + debugMessage(err) reject( IapErrors.E_DEFERRED_PAYMENT.rawValue, @@ -371,8 +369,9 @@ class RNIapIos: RCTEventEmitter, SKRequestDelegate { } // TODO: New method - @objc public func sync(_ resolve: @escaping RCTPromiseResolveBlock = { _ in}, - reject: @escaping RCTPromiseRejectBlock = {_, _, _ in} + @objc public func sync( + _ resolve: @escaping RCTPromiseResolveBlock = { _ in}, + reject: @escaping RCTPromiseRejectBlock = {_, _, _ in} ) async { do { try await AppStore.sync() From 20a98602d1c2a8768056c07ceb4096ec28193afd Mon Sep 17 00:00:00 2001 From: Andres Aguilar Date: Fri, 19 Aug 2022 14:08:18 -0700 Subject: [PATCH 16/16] fix lint on doc --- docs/docs/usage_instructions/receipt_validation.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/docs/usage_instructions/receipt_validation.md b/docs/docs/usage_instructions/receipt_validation.md index 896d3e36c..f2c218c4b 100644 --- a/docs/docs/usage_instructions/receipt_validation.md +++ b/docs/docs/usage_instructions/receipt_validation.md @@ -57,7 +57,6 @@ flow) or unstable internet connections. For these cases we have a convenience method `getReceiptIOS()` which gets the latest receipt for the app at any given time. The response is base64 encoded. - ### Example backend (Node.js) [Here](https://github.com/mifi/in-app-subscription-example) you can find an example backend for idempotent validating of receipts on both iOS/Android and storing and serving subscription state to the client.