Commit 698482e3 authored by CZ1004's avatar CZ1004

Merge branch 'Advertisement' of…

Merge branch 'Advertisement' of http://gitlab.zhangxindiet.com/ShuMing/phonemanager into Advertisement

* 'Advertisement' of http://gitlab.zhangxindiet.com/ShuMing/phonemanager:
  fix bugs
parents 2a2d3610 7a003868
//
// IAPManager.swift
// PhoneManager
//
// Created by edy on 2025/4/30.
//
import Foundation
import UIKit
import StoreKit
import SVProgressHUD
// MARK: - Error Types
enum IAPError: Error {
case noProductsFound
case purchaseFailure(Error?)
case receiptValidationFailure(Error?)
case networkError(Error?)
case userCancelled
case restoreFailed(Error?)
var localizedDescription: String {
switch self {
case .noProductsFound:
return "未找到可购买的商品"
case .purchaseFailure:
return "购买失败"
case .receiptValidationFailure:
return "订单验证失败"
case .networkError:
return "网络连接错误"
case .userCancelled:
return "用户取消购买"
case .restoreFailed:
return "恢复购买失败"
}
}
}
// MARK: - IAPManager
class IAPManager: NSObject {
static let share = IAPManager()
private let userDefaults = UserDefaults.standard
private let productCacheKey = "IAPProductCache"
private let receiptValidationTimeout: TimeInterval = 30
private let productRequestTimeout: TimeInterval = 15
private let alert = PMLoadingHUD.share
enum PayState {
case subscribe
case nonConsumable
}
// MARK: - Properties
private struct ProductID {
static let weekMember = "com.app.phonemanager.week.member"
static let lifetimeMember = "com.app.phonemanager.lifetime.member"
static var all: [String] {
return [weekMember, lifetimeMember]
}
}
// 周订阅
private var weekProduct: SKProduct?
// 永久订阅
private var lifetimeProduct: SKProduct?
private var state: PayState = .subscribe
private var purchaseCompletion: ((Result<Bool, IAPError>) -> Void)?
private var restoreCompletion: ((Result<Bool, IAPError>) -> Void)?
private var productRequestCompletion: (((SKProduct,SKProduct)?) -> Void)?
var isSubscribed = false {
didSet {
if isSubscribed != oldValue {
NotificationCenter.default.post(name: .subscriptionStatusChanged, object: nil)
}
}
}
var isNoAd = false{
didSet {
if isSubscribed != oldValue {
NotificationCenter.default.post(name: .subscriptionStatusChanged, object: nil)
}
}
}
// MARK: - Initialization
private override init() {
super.init()
SKPaymentQueue.default().add(self)
}
deinit {
SKPaymentQueue.default().remove(self)
}
}
// MARK: - Public Methods
extension IAPManager {
// 获取订阅内购商品信息
func fetchProducts(completion: @escaping (_ weekPord:SKProduct,_ lifePord:SKProduct) -> Void) {
// 先检查缓存
if let cachedProducts = getCachedProducts() {
// completion(cachedProducts)
// 后台更新最新数据
refreshProducts()
return
}
// productRequestCompletion = completion
let request = SKProductsRequest(productIdentifiers: Set(ProductID.all))
request.delegate = self
request.start()
BackgroundTaskManager.share.startTask()
// 设置超时
DispatchQueue.global().asyncAfter(deadline: .now() + productRequestTimeout) { [weak self] in
guard let weakSelf = self else { return }
request.cancel()
// self?.productRequestCompletion?(nil)
BackgroundTaskManager.share.endTask()
}
}
func purchase(_ state: PayState = .subscribe, completion: @escaping (Result<Bool, IAPError>) -> Void) {
if state == .subscribe,weekProduct == nil,SKPaymentQueue.canMakePayments(){
completion(.failure(.noProductsFound))
return
}
if state == .nonConsumable,lifetimeProduct == nil,SKPaymentQueue.canMakePayments(){
completion(.failure(.noProductsFound))
return
}
self.state = state
self.purchaseCompletion = completion
let payment = SKPayment(product: state == .subscribe ? weekProduct! : lifetimeProduct!)
SKPaymentQueue.default().add(payment)
alert.show("正在处理购买请求...", "")
}
func restore(_ state: PayState = .subscribe, completion: @escaping (Result<Bool, IAPError>) -> Void) {
self.state = state
self.restoreCompletion = completion
SKPaymentQueue.default().restoreCompletedTransactions()
alert.show("正在恢复购买...", "")
}
}
// MARK: - Private Methods
private extension IAPManager {
func refreshProducts() {
let request = SKProductsRequest(productIdentifiers: Set())
request.delegate = self
request.start()
}
func getCachedProducts() -> [SKProduct]? {
guard let cache = userDefaults.object(forKey: productCacheKey) as? [[String: Any]] else {
return nil
}
return cache.compactMap { item -> SKProduct? in
let product = SKProduct()
product.setValue(item["productIdentifier"] as? String, forKey: "productIdentifier")
product.setValue(item["localizedTitle"] as? String, forKey: "localizedTitle")
product.setValue(item["localizedDescription"] as? String, forKey: "localizedDescription")
product.setValue(NSDecimalNumber(value: item["price"] as? Double ?? 0), forKey: "price")
product.setValue(Locale(identifier: item["priceLocale"] as? String ?? ""), forKey: "priceLocale")
return product
}
}
func cacheProducts(_ products: [SKProduct]) {
let cache = products.map { product -> [String: Any] in
return [
"productIdentifier": product.productIdentifier,
"localizedTitle": product.localizedTitle,
"localizedDescription": product.localizedDescription,
"price": product.price.doubleValue,
"priceLocale": product.priceLocale.identifier
]
}
userDefaults.set(cache, forKey: productCacheKey)
}
func handlePurchaseResult(_ transaction: SKPaymentTransaction) {
switch transaction.transactionState {
case .purchased, .restored:
verifyPurchase(transaction)
case .failed:
handleFailedTransaction(transaction)
case .purchasing:
Print("购买处理中...")
default:
break
}
}
func verifyPurchase(_ transaction: SKPaymentTransaction) {
verifyReceiptWithApple { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let receipt):
let status = self.checkSubscriptionStatus(receiptInfo: receipt)
self.isNoAd = status.isActive
self.isSubscribed = status.isActive
if transaction.transactionState == .purchased {
self.purchaseCompletion?(.success(true))
} else {
self.restoreCompletion?(.success(true))
}
case .failure(let error):
if transaction.transactionState == .purchased {
self.purchaseCompletion?(.failure(.receiptValidationFailure(error)))
} else {
self.restoreCompletion?(.failure(.receiptValidationFailure(error)))
}
}
self.alert.disMiss()
SKPaymentQueue.default().finishTransaction(transaction)
}
}
func handleFailedTransaction(_ transaction: SKPaymentTransaction) {
alert.disMiss()
if let error = transaction.error as? SKError {
switch error.code {
case .paymentCancelled:
purchaseCompletion?(.failure(.userCancelled))
default:
purchaseCompletion?(.failure(.purchaseFailure(error)))
}
} else {
purchaseCompletion?(.failure(.purchaseFailure(transaction.error)))
}
SKPaymentQueue.default().finishTransaction(transaction)
}
func verifyReceiptWithApple(completion: @escaping (Result<[String: Any], Error>) -> Void) {
guard let receiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: receiptURL) else {
completion(.failure(IAPError.receiptValidationFailure(nil)))
return
}
let requestData: [String: Any] = [
"receipt-data": receiptData.base64EncodedString(),
"password": "3cbeb6f5ace84f5b98571263da74c192",
"exclude-old-transactions": true
]
let productionURL = URL(string: "https://buy.itunes.apple.com/verifyReceipt")!
var request = URLRequest(url: productionURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: requestData)
request.timeoutInterval = receiptValidationTimeout
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
completion(.failure(IAPError.receiptValidationFailure(nil)))
return
}
if let status = json["status"] as? Int, status == 21007 {
// 沙盒环境验证
self.verifySandboxReceipt(receiptData: receiptData, completion: completion)
} else {
completion(.success(json))
}
}
task.resume()
}
func verifySandboxReceipt(receiptData: Data, completion: @escaping (Result<[String: Any], Error>) -> Void) {
let requestData: [String: Any] = [
"receipt-data": receiptData.base64EncodedString(),
"password": "3cbeb6f5ace84f5b98571263da74c192",
"exclude-old-transactions": true
]
let sandboxURL = URL(string: "https://sandbox.itunes.apple.com/verifyReceipt")!
var request = URLRequest(url: sandboxURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: requestData)
request.timeoutInterval = receiptValidationTimeout
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
completion(.failure(IAPError.receiptValidationFailure(nil)))
return
}
completion(.success(json))
}
task.resume()
}
func checkSubscriptionStatus(receiptInfo: [String: Any]) -> (isActive: Bool, isTrial: Bool, expiresDate: Date?) {
guard let latestReceiptInfo = receiptInfo["latest_receipt_info"] as? [[String: Any]],
let lastReceipt = latestReceiptInfo.first else {
return (false, false, nil)
}
let isTrial = (lastReceipt["is_trial_period"] as? String) == "true"
var expiresDate: Date?
if let expiresDateMs = lastReceipt["expires_date_ms"] as? String,
let timeInterval = TimeInterval(expiresDateMs) {
expiresDate = Date(timeIntervalSince1970: timeInterval / 1000.0)
} else if let expiresDateString = lastReceipt["expires_date"] as? String {
let formatter = ISO8601DateFormatter()
expiresDate = formatter.date(from: expiresDateString)
}
let isActive = expiresDate?.compare(Date()) == .orderedDescending
#if DEBUG
if let expDate = expiresDate {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
Print("订阅状态:\(isActive ? "已订阅" : "未订阅"),到期时间:\(formatter.string(from: expDate))")
}
#endif
return (isActive, isTrial, expiresDate)
}
}
// MARK: - SKProductsRequestDelegate
extension IAPManager: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
let products = response.products
guard !products.isEmpty else {
productRequestCompletion?(nil)
BackgroundTaskManager.share.endTask()
return
}
let week = products.filter{$0.productIdentifier == ProductID.weekMember}.first
let life = products.filter{$0.productIdentifier == ProductID.lifetimeMember}.first
// var sortedProducts = products
// if products.count >= 2 {
// sortedProducts = products.sorted { $0.productIdentifier == productIdentifiers.first }
// }
//
// self.products = sortedProducts
// cacheProducts(sortedProducts)
//
DispatchQueue.main.async { [weak self] in
// self?.productRequestCompletion?((week,life))
BackgroundTaskManager.share.endTask()
}
}
func request(_ request: SKRequest, didFailWithError error: Error) {
productRequestCompletion?(nil)
BackgroundTaskManager.share.endTask()
}
}
// MARK: - SKPaymentTransactionObserver
extension IAPManager: SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
guard let transaction = transactions.first else { return }
handlePurchaseResult(transaction)
}
func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
restoreCompletion?(.success(true))
alert.disMiss()
}
func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
restoreCompletion?(.failure(.restoreFailed(error)))
alert.disMiss()
}
}
// MARK: - Notification Names
extension Notification.Name {
static let subscriptionStatusChanged = Notification.Name("IAPSubscriptionStatusChanged")
}
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
<rect key="frame" x="177" y="100" width="189" height="189"/> <rect key="frame" x="177" y="100" width="189" height="189"/>
</imageView> </imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Access permission is required to start scan" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ci8-h6-fiE"> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Access permission is required to start scan" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ci8-h6-fiE">
<rect key="frame" x="22" y="297" width="499" height="20"/> <rect key="frame" x="5" y="297" width="533" height="20"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="16"/> <fontDescription key="fontDescription" type="boldSystem" pointSize="16"/>
<color key="textColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <color key="textColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
...@@ -56,14 +56,14 @@ ...@@ -56,14 +56,14 @@
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints> <constraints>
<constraint firstItem="fRi-wi-J8I" firstAttribute="centerX" secondItem="qhV-IF-vsx" secondAttribute="centerX" id="06a-VZ-yHW"/> <constraint firstItem="fRi-wi-J8I" firstAttribute="centerX" secondItem="qhV-IF-vsx" secondAttribute="centerX" id="06a-VZ-yHW"/>
<constraint firstItem="ci8-h6-fiE" firstAttribute="leading" secondItem="Z86-W8-437" secondAttribute="leading" constant="22" id="2pS-Hl-sgb"/> <constraint firstItem="ci8-h6-fiE" firstAttribute="leading" secondItem="Z86-W8-437" secondAttribute="leading" constant="5" id="2pS-Hl-sgb"/>
<constraint firstItem="ci8-h6-fiE" firstAttribute="centerX" secondItem="qhV-IF-vsx" secondAttribute="centerX" id="Fv3-iO-5BI"/> <constraint firstItem="ci8-h6-fiE" firstAttribute="centerX" secondItem="qhV-IF-vsx" secondAttribute="centerX" id="Fv3-iO-5BI"/>
<constraint firstItem="e8v-Xy-wvP" firstAttribute="centerX" secondItem="ci8-h6-fiE" secondAttribute="centerX" id="OZD-Bq-U2s"/> <constraint firstItem="e8v-Xy-wvP" firstAttribute="centerX" secondItem="ci8-h6-fiE" secondAttribute="centerX" id="OZD-Bq-U2s"/>
<constraint firstItem="qhV-IF-vsx" firstAttribute="top" secondItem="EwM-B0-fXo" secondAttribute="top" constant="100" id="fx5-wC-f0q"/> <constraint firstItem="qhV-IF-vsx" firstAttribute="top" secondItem="EwM-B0-fXo" secondAttribute="top" constant="100" id="fx5-wC-f0q"/>
<constraint firstItem="fRi-wi-J8I" firstAttribute="top" secondItem="e8v-Xy-wvP" secondAttribute="bottom" constant="13" id="jys-rP-uUI"/> <constraint firstItem="fRi-wi-J8I" firstAttribute="top" secondItem="e8v-Xy-wvP" secondAttribute="bottom" constant="13" id="jys-rP-uUI"/>
<constraint firstItem="e8v-Xy-wvP" firstAttribute="top" secondItem="ci8-h6-fiE" secondAttribute="bottom" constant="3" id="tgi-kq-3Ap"/> <constraint firstItem="e8v-Xy-wvP" firstAttribute="top" secondItem="ci8-h6-fiE" secondAttribute="bottom" constant="3" id="tgi-kq-3Ap"/>
<constraint firstItem="ci8-h6-fiE" firstAttribute="top" secondItem="qhV-IF-vsx" secondAttribute="bottom" constant="8" id="uKW-kw-KCJ"/> <constraint firstItem="ci8-h6-fiE" firstAttribute="top" secondItem="qhV-IF-vsx" secondAttribute="bottom" constant="8" id="uKW-kw-KCJ"/>
<constraint firstItem="Z86-W8-437" firstAttribute="trailing" secondItem="ci8-h6-fiE" secondAttribute="trailing" constant="22" id="wMs-cZ-eGL"/> <constraint firstItem="Z86-W8-437" firstAttribute="trailing" secondItem="ci8-h6-fiE" secondAttribute="trailing" constant="5" id="wMs-cZ-eGL"/>
</constraints> </constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<point key="canvasLocation" x="295.41984732824426" y="-329.22535211267609"/> <point key="canvasLocation" x="295.41984732824426" y="-329.22535211267609"/>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment