php验证iap收据,iOS内购IAP(十五) —— IAP的收据验证(二)

本文详细介绍了如何在iOS应用中进行内购收据的验证过程,包括读取ASN1编码的数据,解析收据中的产品信息,如购买日期、订阅过期日期等。还展示了如何处理IAPReceipt结构,以及整个收据验证流程,从加载到验证签名,再到读取和验证收据内容。
摘要由CSDN通过智能技术生成

版本记录

版本号

时间

V1.0

2019.01.09

前言

源码

1. Swift

首先看下工程组织结构

925a09ea73e1

接着看下sb中的内容

925a09ea73e1

下面就是源码部分了

1. ASN1Helpers.swift

import UIKit

func readASN1Data(ptr: UnsafePointer, length: Int) -> Data {

return Data(bytes: ptr, count: length)

}

func readASN1Integer(ptr: inout UnsafePointer?, maxLength: Int) -> Int? {

var type: Int32 = 0

var xclass: Int32 = 0

var length: Int = 0

ASN1_get_object(&ptr, &length, &type, &xclass, maxLength)

guard type == V_ASN1_INTEGER else {

return nil

}

let integerObject = c2i_ASN1_INTEGER(nil, &ptr, length)

let intValue = ASN1_INTEGER_get(integerObject)

ASN1_INTEGER_free(integerObject)

return intValue

}

func readASN1String(ptr: inout UnsafePointer?, maxLength: Int) -> String? {

var strClass: Int32 = 0

var strLength = 0

var strType: Int32 = 0

var strPointer = ptr

ASN1_get_object(&strPointer, &strLength, &strType, &strClass, maxLength)

if strType == V_ASN1_UTF8STRING {

let p = UnsafeMutableRawPointer(mutating: strPointer!)

let utfString = String(bytesNoCopy: p, length: strLength, encoding: .utf8, freeWhenDone: false)

return utfString

}

if strType == V_ASN1_IA5STRING {

let p = UnsafeMutablePointer(mutating: strPointer!)

let ia5String = String(bytesNoCopy: p, length: strLength, encoding: .ascii, freeWhenDone: false)

return ia5String

}

return nil

}

func readASN1Date(ptr: inout UnsafePointer?, maxLength: Int) -> Date? {

var str_xclass: Int32 = 0

var str_length = 0

var str_type: Int32 = 0

// A date formatter to handle RFC 3339 dates in the GMT time zone

let formatter = DateFormatter()

formatter.locale = Locale(identifier: "en_US_POSIX")

formatter.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'"

formatter.timeZone = TimeZone(abbreviation: "GMT")

var strPointer = ptr

ASN1_get_object(&strPointer, &str_length, &str_type, &str_xclass, maxLength)

guard str_type == V_ASN1_IA5STRING else {

return nil

}

let p = UnsafeMutableRawPointer(mutating: strPointer!)

if let dateString = String(bytesNoCopy: p, length: str_length, encoding: .ascii, freeWhenDone: false) {

return formatter.date(from: dateString)

}

return nil

}

2. IAPReceipt.swift

import Foundation

struct IAPReceipt {

var quantity: Int?

var productIdentifier: String?

var transactionIdentifer: String?

var originalTransactionIdentifier: String?

var purchaseDate: Date?

var originalPurchaseDate: Date?

var subscriptionExpirationDate: Date?

var subscriptionIntroductoryPricePeriod: Int?

var subscriptionCancellationDate: Date?

var webOrderLineId: Int?

init?(with pointer: inout UnsafePointer?, payloadLength: Int) {

let endPointer = pointer!.advanced(by: payloadLength)

var type: Int32 = 0

var xclass: Int32 = 0

var length = 0

ASN1_get_object(&pointer, &length, &type, &xclass, payloadLength)

guard type == V_ASN1_SET else {

return nil

}

while pointer! < endPointer {

ASN1_get_object(&pointer, &length, &type, &xclass, pointer!.distance(to: endPointer))

guard type == V_ASN1_SEQUENCE else {

return nil

}

guard let attributeType = readASN1Integer(ptr: &pointer,

maxLength: pointer!.distance(to: endPointer))

else {

return nil

}

// Attribute version must be an integer, but not using the value

guard let _ = readASN1Integer(ptr: &pointer,

maxLength: pointer!.distance(to: endPointer))

else {

return nil

}

ASN1_get_object(&pointer, &length, &type, &xclass, pointer!.distance(to: endPointer))

guard type == V_ASN1_OCTET_STRING else {

return nil

}

switch attributeType {

case 1701:

var p = pointer

quantity = readASN1Integer(ptr: &p, maxLength: length)

case 1702:

var p = pointer

productIdentifier = readASN1String(ptr: &p, maxLength: length)

case 1703:

var p = pointer

transactionIdentifer = readASN1String(ptr: &p, maxLength: length)

case 1705:

var p = pointer

originalTransactionIdentifier = readASN1String(ptr: &p, maxLength: length)

case 1704:

var p = pointer

purchaseDate = readASN1Date(ptr: &p, maxLength: length)

case 1706:

var p = pointer

originalPurchaseDate = readASN1Date(ptr: &p, maxLength: length)

case 1708:

var p = pointer

subscriptionExpirationDate = readASN1Date(ptr: &p, maxLength: length)

case 1712:

var p = pointer

subscriptionCancellationDate = readASN1Date(ptr: &p, maxLength: length)

case 1711:

var p = pointer

webOrderLineId = readASN1Integer(ptr: &p, maxLength: length)

default:

break

}

pointer = pointer!.advanced(by: length)

}

}

}

3. Receipt.swift

import UIKit

enum ReceiptStatus: String {

case validationSuccess = "This receipt is valid."

case noReceiptPresent = "A receipt was not found on this device."

case unknownFailure = "An unexpected failure occurred during verification."

case unknownReceiptFormat = "The receipt is not in PKCS7 format."

case invalidPKCS7Signature = "Invalid PKCS7 Signature."

case invalidPKCS7Type = "Invalid PKCS7 Type."

case invalidAppleRootCertificate = "Public Apple root certificate not found."

case failedAppleSignature = "Receipt not signed by Apple."

case unexpectedASN1Type = "Unexpected ASN1 Type."

case missingComponent = "Expected component was not found."

case invalidBundleIdentifier = "Receipt bundle identifier does not match application bundle identifier."

case invalidVersionIdentifier = "Receipt version identifier does not match application version."

case invalidHash = "Receipt failed hash check."

case invalidExpired = "Receipt has expired."

}

class Receipt {

var receiptStatus: ReceiptStatus?

var bundleIdString: String?

var bundleVersionString: String?

var bundleIdData: Data?

var hashData: Data?

var opaqueData: Data?

var expirationDate: Date?

var receiptCreationDate: Date?

var originalAppVersion: String?

var inAppReceipts: [IAPReceipt] = []

static public func isReceiptPresent() -> Bool {

if let receiptUrl = Bundle.main.appStoreReceiptURL,

let canReach = try? receiptUrl.checkResourceIsReachable(),

canReach {

return true

}

return false

}

init() {

guard let payload = loadReceipt() else {

return

}

guard validateSigning(payload) else {

return

}

readReceipt(payload)

validateReceipt()

}

private func loadReceipt() -> UnsafeMutablePointer? {

// Load the receipt into a Data object

guard

let receiptUrl = Bundle.main.appStoreReceiptURL,

let receiptData = try? Data(contentsOf: receiptUrl)

else {

receiptStatus = .noReceiptPresent

return nil

}

// 1

let receiptBIO = BIO_new(BIO_s_mem())

let receiptBytes: [UInt8] = .init(receiptData)

BIO_write(receiptBIO, receiptBytes, Int32(receiptData.count))

// 2

let receiptPKCS7 = d2i_PKCS7_bio(receiptBIO, nil)

BIO_free(receiptBIO)

// 3

guard receiptPKCS7 != nil else {

receiptStatus = .unknownReceiptFormat

return nil

}

// Check that the container has a signature

guard OBJ_obj2nid(receiptPKCS7!.pointee.type) == NID_pkcs7_signed else {

receiptStatus = .invalidPKCS7Signature

return nil

}

// Check that the container contains data

let receiptContents = receiptPKCS7!.pointee.d.sign.pointee.contents

guard OBJ_obj2nid(receiptContents?.pointee.type) == NID_pkcs7_data else {

receiptStatus = .invalidPKCS7Type

return nil

}

return receiptPKCS7

}

private func validateSigning(_ receipt: UnsafeMutablePointer?) -> Bool {

guard

let rootCertUrl = Bundle.main

.url(forResource: "AppleIncRootCertificate", withExtension: "cer"),

let rootCertData = try? Data(contentsOf: rootCertUrl)

else {

receiptStatus = .invalidAppleRootCertificate

return false

}

let rootCertBio = BIO_new(BIO_s_mem())

let rootCertBytes: [UInt8] = .init(rootCertData)

BIO_write(rootCertBio, rootCertBytes, Int32(rootCertData.count))

let rootCertX509 = d2i_X509_bio(rootCertBio, nil)

BIO_free(rootCertBio)

// 1

let store = X509_STORE_new()

X509_STORE_add_cert(store, rootCertX509)

// 2

OPENSSL_init_crypto(UInt64(OPENSSL_INIT_ADD_ALL_DIGESTS), nil)

// 3

let verificationResult = PKCS7_verify(receipt, nil, store, nil, nil, 0)

guard verificationResult == 1 else {

receiptStatus = .failedAppleSignature

return false

}

return true

}

private func readReceipt(_ receiptPKCS7: UnsafeMutablePointer?) {

// Get a pointer to the start and end of the ASN.1 payload

let receiptSign = receiptPKCS7?.pointee.d.sign

let octets = receiptSign?.pointee.contents.pointee.d.data

var ptr = UnsafePointer(octets?.pointee.data)

let end = ptr!.advanced(by: Int(octets!.pointee.length))

var type: Int32 = 0

var xclass: Int32 = 0

var length: Int = 0

ASN1_get_object(&ptr, &length, &type, &xclass, ptr!.distance(to: end))

guard type == V_ASN1_SET else {

receiptStatus = .unexpectedASN1Type

return

}

// 1

while ptr! < end {

// 2

ASN1_get_object(&ptr, &length, &type, &xclass, ptr!.distance(to: end))

guard type == V_ASN1_SEQUENCE else {

receiptStatus = .unexpectedASN1Type

return

}

// 3

guard let attributeType = readASN1Integer(ptr: &ptr, maxLength: length) else {

receiptStatus = .unexpectedASN1Type

return

}

// 4

guard let _ = readASN1Integer(ptr: &ptr, maxLength: ptr!.distance(to: end)) else {

receiptStatus = .unexpectedASN1Type

return

}

// 5

ASN1_get_object(&ptr, &length, &type, &xclass, ptr!.distance(to: end))

guard type == V_ASN1_OCTET_STRING else {

receiptStatus = .unexpectedASN1Type

return

}

switch attributeType {

case 2: // The bundle identifier

var stringStartPtr = ptr

bundleIdString = readASN1String(ptr: &stringStartPtr, maxLength: length)

bundleIdData = readASN1Data(ptr: ptr!, length: length)

case 3: // Bundle version

var stringStartPtr = ptr

bundleVersionString = readASN1String(ptr: &stringStartPtr, maxLength: length)

case 4: // Opaque value

let dataStartPtr = ptr!

opaqueData = readASN1Data(ptr: dataStartPtr, length: length)

case 5: // Computed GUID (SHA-1 Hash)

let dataStartPtr = ptr!

hashData = readASN1Data(ptr: dataStartPtr, length: length)

case 12: // Receipt Creation Date

var dateStartPtr = ptr

receiptCreationDate = readASN1Date(ptr: &dateStartPtr, maxLength: length)

case 17: // IAP Receipt

var iapStartPtr = ptr

let parsedReceipt = IAPReceipt(with: &iapStartPtr, payloadLength: length)

if let newReceipt = parsedReceipt {

inAppReceipts.append(newReceipt)

}

case 19: // Original App Version

var stringStartPtr = ptr

originalAppVersion = readASN1String(ptr: &stringStartPtr, maxLength: length)

case 21: // Expiration Date

var dateStartPtr = ptr

expirationDate = readASN1Date(ptr: &dateStartPtr, maxLength: length)

default: // Ignore other attributes in receipt

print("Not processing attribute type: \(attributeType)")

}

// Advance pointer to the next item

ptr = ptr!.advanced(by: length)

}

}

private func validateReceipt() {

guard

let idString = bundleIdString,

let version = bundleVersionString,

let _ = opaqueData,

let hash = hashData

else {

receiptStatus = .missingComponent

return

}

// Check the bundle identifier

guard let appBundleId = Bundle.main.bundleIdentifier else {

receiptStatus = .unknownFailure

return

}

guard idString == appBundleId else {

receiptStatus = .invalidBundleIdentifier

return

}

// Check the version

guard let appVersionString =

Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String else {

receiptStatus = .unknownFailure

return

}

guard version == appVersionString else {

receiptStatus = .invalidVersionIdentifier

return

}

// Check the GUID hash

let guidHash = computeHash()

guard hash == guidHash else {

receiptStatus = .invalidHash

return

}

// Check the expiration attribute if it's present

let currentDate = Date()

if let expirationDate = expirationDate {

if expirationDate < currentDate {

receiptStatus = .invalidExpired

return

}

}

// All checks passed so validation is a success

receiptStatus = .validationSuccess

}

private func getDeviceIdentifier() -> Data {

let device = UIDevice.current

var uuid = device.identifierForVendor!.uuid

let addr = withUnsafePointer(to: &uuid) { (p) -> UnsafeRawPointer in

UnsafeRawPointer(p)

}

let data = Data(bytes: addr, count: 16)

return data

}

private func computeHash() -> Data {

let identifierData = getDeviceIdentifier()

var ctx = SHA_CTX()

SHA1_Init(&ctx)

let identifierBytes: [UInt8] = .init(identifierData)

SHA1_Update(&ctx, identifierBytes, identifierData.count)

let opaqueBytes: [UInt8] = .init(opaqueData!)

SHA1_Update(&ctx, opaqueBytes, opaqueData!.count)

let bundleBytes: [UInt8] = .init(bundleIdData!)

SHA1_Update(&ctx, bundleBytes, bundleIdData!.count)

var hash: [UInt8] = .init(repeating: 0, count: 20)

SHA1_Final(&hash, &ctx)

return Data(bytes: hash, count: 20)

}

}

4. ViewController.swift

import UIKit

import StoreKit

class ViewController: UIViewController {

@IBOutlet weak var bundleIdentifier: UILabel!

@IBOutlet weak var bundleVersion: UILabel!

@IBOutlet weak var expirationDate: UILabel!

@IBOutlet weak var verificationStatus: UILabel!

@IBOutlet weak var buyButton: UIButton!

@IBOutlet weak var iapTableView: UITableView!

@IBOutlet weak var receiptCreationDate: UILabel!

@IBOutlet weak var originalAppVersion: UILabel!

// Receipt

var receipt: Receipt?

// Store

public static let storeItem1 = "com.billmorefield.receiptverification.consumable"

public static let storeItem2 = "com.billmorefield.receiptverification.nonconsumable"

public static let storeItem3 = "com.billmorefield.receiptverification.nonconsumable2"

private static let productIdentifiers: Set = [ViewController.storeItem1, ViewController.storeItem2, ViewController.storeItem3]

public static let store = IAPHelper(productIds: ViewController.productIdentifiers)

var products: [SKProduct] = []

private lazy var dateFormatter: DateFormatter = {

let formatter = DateFormatter()

formatter.timeStyle = .short

formatter.dateStyle = .medium

return formatter

}()

override func viewDidLoad() {

super.viewDidLoad()

// Set table delegate

iapTableView.dataSource = self

// Set up store if payments allowed

if IAPHelper.canMakePayments() {

NotificationCenter.default.addObserver(self,

selector: #selector(purchaseMade(notification:)),

name: Notification.Name("IAPHelperPurchaseNotification"),

object: nil)

ViewController.store.requestProducts { (success, products) in

if success {

self.products = products!

DispatchQueue.main.async {

self.buyButton.isEnabled = true

}

}

}

}

// If a receipt is present validate it, otherwise request to refresh it

if Receipt.isReceiptPresent() {

validateReceipt()

} else {

refreshReceipt()

}

}

func refreshReceipt() {

verificationStatus.text = "Requesting refresh of receipt."

verificationStatus.textColor = .green

print("Requesting refresh of receipt.")

let refreshRequest = SKReceiptRefreshRequest()

refreshRequest.delegate = self

refreshRequest.start()

}

func formatDateForUI(_ date: Date) -> String {

let formatter = DateFormatter()

formatter.dateStyle = .medium

formatter.timeStyle = .none

return formatter.string(from: date)

}

func validateReceipt() {

verificationStatus.text = "Validating Receipt..."

verificationStatus.textColor = .green

receipt = Receipt()

if let receiptStatus = receipt?.receiptStatus {

verificationStatus.text = receiptStatus.rawValue

guard receiptStatus == .validationSuccess else {

// If verification didn't succeed, then show status in red and clear other fields

verificationStatus.textColor = .red

bundleIdentifier.text = ""

bundleVersion.text = ""

expirationDate.text = ""

originalAppVersion.text = ""

receiptCreationDate.text = ""

return

}

// If verification succeed, we show information contained in the receipt

verificationStatus.textColor = .green

bundleIdentifier.text = "Bundle Identifier: \(receipt!.bundleIdString!)"

bundleVersion.text = "Bundle Version: \(receipt!.bundleVersionString!)"

if let originalVersion = receipt?.originalAppVersion {

originalAppVersion.text = "Original Version: \(originalVersion)"

} else {

originalAppVersion.text = "Not Provided"

}

if let receiptExpirationDate = receipt?.expirationDate {

expirationDate.text = "Expiration Date: \(formatDateForUI(receiptExpirationDate))"

} else {

expirationDate.text = "Not Provided."

}

if let receiptCreation = receipt?.receiptCreationDate {

receiptCreationDate.text = "Receipt Creation Date: \(formatDateForUI(receiptCreation))"

} else {

receiptCreationDate.text = "Not Provided."

}

iapTableView.reloadData()

}

}

// MARK: - Buttons

@IBAction func buyButtonTouched(_ sender: Any) {

let alert = UIAlertController(title: "Select Purchcase",

message: "Choose the item you wish to purchase",

preferredStyle: .actionSheet)

for product in products {

alert.addAction(UIAlertAction(title: product.localizedTitle, style: .default) { _ in

ViewController.store.buyProduct(product)

})

}

alert.addAction(UIAlertAction(title: "Restore Purchases", style: .default) { _ in

ViewController.store.restorePurchases()

})

alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

present(alert, animated: true)

}

@IBAction func restoreButtonTouched(_ sender: Any) {

ViewController.store.restorePurchases()

}

@IBAction func refreshReceiptTouched(_ sender: Any) {

refreshReceipt()

}

// MARK: - Notification Handler

@objc func purchaseMade(notification: NSNotification) {

}

}

// MARK: - SKRequestDelegate extension

extension ViewController: SKRequestDelegate {

func requestDidFinish(_ request: SKRequest) {

if Receipt.isReceiptPresent() {

print("Verifying newly refreshed receipt.")

validateReceipt()

}

}

func request(_ request: SKRequest, didFailWithError error: Error) {

verificationStatus.text = error.localizedDescription

print("StoreKit request failed: \(error.localizedDescription)")

verificationStatus.textColor = .red

}

}

// MARK: - UITableViewDataSource extension

extension ViewController: UITableViewDataSource {

func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {

return "In App Purchases in Receipt"

}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {

if let iapItems = receipt?.inAppReceipts {

return iapItems.count

}

return 0

}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

let cell = tableView.dequeueReusableCell(withIdentifier: "IAPCell", for: indexPath) as! IAPTableViewCell

guard let iapItem = receipt?.inAppReceipts[indexPath.row] else {

cell.productIdentifier.text = "Unknown"

cell.purchaseDate.text = ""

return cell

}

cell.productIdentifier.text = iapItem.productIdentifier ?? "Unknown"

if let date = iapItem.purchaseDate {

cell.purchaseDate.text = dateFormatter.string(from: date)

} else {

cell.purchaseDate.text = ""

}

return cell

}

}

5. IAPTableViewCell.swift

import UIKit

class IAPTableViewCell: UITableViewCell {

@IBOutlet weak var productIdentifier: UILabel!

@IBOutlet weak var purchaseDate: UILabel!

}

6. IAPHelper.swift

import StoreKit

public typealias ProductIdentifier = String

public typealias ProductsRequestCompletionHandler = (_ success: Bool, _ products: [SKProduct]?) -> Void

extension Notification.Name {

static let IAPHelperPurchaseNotification = Notification.Name("IAPHelperPurchaseNotification")

}

open class IAPHelper: NSObject {

private let productIdentifiers: Set

private var purchasedProductIdentifiers: Set = []

private var productsRequest: SKProductsRequest?

private var productsRequestCompletionHandler: ProductsRequestCompletionHandler?

public init(productIds: Set) {

productIdentifiers = productIds

for productIdentifier in productIds {

if UserDefaults.standard.bool(forKey: productIdentifier) {

purchasedProductIdentifiers.insert(productIdentifier)

print("Previously purchased: \(productIdentifier)")

} else {

print("Not purchased: \(productIdentifier)")

}

}

super.init()

SKPaymentQueue.default().add(self)

}

}

// MARK: - StoreKit API

extension IAPHelper {

public func requestProducts(_ completionHandler: @escaping ProductsRequestCompletionHandler) {

productsRequest?.cancel()

productsRequestCompletionHandler = completionHandler

productsRequest = SKProductsRequest(productIdentifiers: productIdentifiers)

productsRequest!.delegate = self

productsRequest!.start()

}

public func buyProduct(_ product: SKProduct) {

print("Buying \(product.productIdentifier)...")

let payment = SKPayment(product: product)

SKPaymentQueue.default().add(payment)

}

public func isProductPurchased(_ productIdentifier: ProductIdentifier) -> Bool {

return purchasedProductIdentifiers.contains(productIdentifier)

}

public class func canMakePayments() -> Bool {

return SKPaymentQueue.canMakePayments()

}

public func restorePurchases() {

SKPaymentQueue.default().restoreCompletedTransactions()

}

}

// MARK: - SKProductsRequestDelegate

extension IAPHelper: SKProductsRequestDelegate {

public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {

print("Loaded list of products...")

let products = response.products

productsRequestCompletionHandler?(true, products)

clearRequestAndHandler()

for product in products {

print("Found product: \(product.productIdentifier) \(product.localizedTitle) \(product.price.floatValue)")

}

}

public func request(_ request: SKRequest, didFailWithError error: Error) {

print("Failed to load list of products.")

print("Error: \(error.localizedDescription)")

productsRequestCompletionHandler?(false, nil)

clearRequestAndHandler()

}

private func clearRequestAndHandler() {

productsRequest = nil

productsRequestCompletionHandler = nil

}

}

// MARK: - SKPaymentTransactionObserver

extension IAPHelper: SKPaymentTransactionObserver {

public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {

for transaction in transactions {

switch (transaction.transactionState) {

case .purchased:

complete(transaction: transaction)

break

case .failed:

fail(transaction: transaction)

break

case .restored:

restore(transaction: transaction)

break

case .deferred:

break

case .purchasing:

break

}

}

}

private func complete(transaction: SKPaymentTransaction) {

print("complete...")

deliverPurchaseNotificationFor(identifier: transaction.payment.productIdentifier)

SKPaymentQueue.default().finishTransaction(transaction)

}

private func restore(transaction: SKPaymentTransaction) {

guard let productIdentifier = transaction.original?.payment.productIdentifier else { return }

print("restore... \(productIdentifier)")

deliverPurchaseNotificationFor(identifier: productIdentifier)

SKPaymentQueue.default().finishTransaction(transaction)

}

private func fail(transaction: SKPaymentTransaction) {

print("fail...")

if let transactionError = transaction.error as NSError?,

let localizedDescription = transaction.error?.localizedDescription,

transactionError.code != SKError.paymentCancelled.rawValue {

print("Transaction Error: \(localizedDescription)")

}

SKPaymentQueue.default().finishTransaction(transaction)

}

private func deliverPurchaseNotificationFor(identifier: String?) {

guard let identifier = identifier else { return }

purchasedProductIdentifiers.insert(identifier)

UserDefaults.standard.set(true, forKey: identifier)

NotificationCenter.default.post(name: .IAPHelperPurchaseNotification, object: identifier)

}

}

后记

本篇主要讲述了收据验证,感兴趣的给个赞或者关注~~~

925a09ea73e1

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值