diff --git a/client/platforms/ios/StoreKit2Helper.swift b/client/platforms/ios/StoreKit2Helper.swift index 6f7a2eb5b..d1cb6de52 100644 --- a/client/platforms/ios/StoreKit2Helper.swift +++ b/client/platforms/ios/StoreKit2Helper.swift @@ -7,24 +7,47 @@ public class StoreKit2Helper: NSObject { public static let shared = StoreKit2Helper() + private struct EntitlementInfo { + let transactionId: UInt64 + let originalTransactionId: UInt64 + let productId: String + let purchaseDate: Date + + var dictionary: NSDictionary { + [ + "transactionId": String(transactionId), + "originalTransactionId": String(originalTransactionId), + "productId": productId + ] + } + } + public func fetchCurrentEntitlements(completion: @escaping (Bool, [NSDictionary]?, NSError?) -> Void) { - Task { - var entitlements: [NSDictionary] = [] + Task { @MainActor in do { + try await AppStore.sync() + + var entitlements: [EntitlementInfo] = [] for await result in Transaction.currentEntitlements { switch result { case .verified(let transaction): - let info: NSDictionary = [ - "transactionId": String(transaction.id), - "originalTransactionId": String(transaction.originalID), - "productId": transaction.productID - ] - entitlements.append(info) + entitlements.append(EntitlementInfo(transactionId: transaction.id, + originalTransactionId: transaction.originalID, + productId: transaction.productID, + purchaseDate: transaction.purchaseDate)) case .unverified(_, let error): print("[IAP][StoreKit2] Unverified transaction skipped: \(error.localizedDescription)") } } - DispatchQueue.main.async { completion(true, entitlements, nil) } + let sortedEntitlements = entitlements.sorted { lhs, rhs in + if lhs.purchaseDate != rhs.purchaseDate { + return lhs.purchaseDate > rhs.purchaseDate + } + return lhs.transactionId > rhs.transactionId + }.map { $0.dictionary } + completion(true, sortedEntitlements, nil) + } catch { + completion(false, nil, error as NSError) } } } diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index fda53b245..f5b85cc64 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -576,52 +576,88 @@ bool ApiConfigsController::restoreSerivceFromAppStore() return false; } - const QVariantMap &transaction = restoredTransactions.first(); - const QString originalTransactionId = transaction.value(QStringLiteral("originalTransactionId")).toString(); - const QString transactionId = transaction.value(QStringLiteral("transactionId")).toString(); - const QString productId = transaction.value(QStringLiteral("productId")).toString(); + const bool isTestPurchase = IosController::Instance()->isTestFlight(); + const QString serviceType = m_apiServicesModel->getSelectedServiceType(); + const QString serviceProtocol = m_apiServicesModel->getSelectedServiceProtocol(); + const QString countryCode = m_apiServicesModel->getCountryCode(); + const QString appLanguage = m_settings->getAppLanguage().name().split("_").first(); + const QString installationUuid = m_settings->getInstallationUuid(true); + + bool hasInstalledConfig = false; + bool duplicateConfigAlreadyPresent = false; + int duplicateServerIndex = -1; + QSet processedOriginalTransactionIds; + + for (const QVariantMap &transaction : restoredTransactions) { + const QString originalTransactionId = transaction.value(QStringLiteral("originalTransactionId")).toString(); + const QString transactionId = transaction.value(QStringLiteral("transactionId")).toString(); + const QString productId = transaction.value(QStringLiteral("productId")).toString(); + + if (originalTransactionId.isEmpty()) { + qWarning().noquote() << "[IAP] Skipping restored transaction without originalTransactionId" << transactionId; + continue; + } + + if (processedOriginalTransactionIds.contains(originalTransactionId)) { + qInfo().noquote() << "[IAP] Skipping duplicate restored transaction" << originalTransactionId; + continue; + } + processedOriginalTransactionIds.insert(originalTransactionId); + + qInfo().noquote() << "[IAP] Restoring subscription. transactionId =" << transactionId + << "originalTransactionId =" << originalTransactionId << "productId =" << productId; + + GatewayRequestData gatewayRequestData { QSysInfo::productType(), + QString(APP_VERSION), + appLanguage, + installationUuid, + countryCode, + "", + serviceType, + serviceProtocol, + QJsonObject() }; + + QJsonObject apiPayload = gatewayRequestData.toJsonObject(); + apiPayload[apiDefs::key::transactionId] = originalTransactionId; + + QByteArray responseBody; + ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase); + if (errorCode != ErrorCode::NoError) { + qWarning().noquote() << "[IAP] Failed to restore transaction" << originalTransactionId + << "errorCode =" << static_cast(errorCode); + continue; + } + + int currentDuplicateServerIndex = -1; + errorCode = importServiceFromBilling(responseBody, isTestPurchase, currentDuplicateServerIndex); + if (errorCode == ErrorCode::ApiConfigAlreadyAdded) { + duplicateConfigAlreadyPresent = true; + if (duplicateServerIndex < 0) { + duplicateServerIndex = currentDuplicateServerIndex; + } + qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists" << originalTransactionId; + continue; + } + + if (errorCode != ErrorCode::NoError) { + qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction" << originalTransactionId + << "errorCode =" << static_cast(errorCode); + continue; + } + + hasInstalledConfig = true; + } + + if (!hasInstalledConfig) { + if (duplicateConfigAlreadyPresent) { + emit installServerFromApiFinished(tr("This subscription is already in the app."), duplicateServerIndex); + return true; + } - if (originalTransactionId.isEmpty()) { - qWarning().noquote() << "[IAP] Active entitlement has no originalTransactionId"; emit errorOccurred(ErrorCode::ApiPurchaseError); return false; } - qInfo().noquote() << "[IAP] Restoring subscription. transactionId =" << transactionId - << "originalTransactionId =" << originalTransactionId << "productId =" << productId; - - GatewayRequestData gatewayRequestData { QSysInfo::productType(), - QString(APP_VERSION), - m_settings->getAppLanguage().name().split("_").first(), - m_settings->getInstallationUuid(true), - m_apiServicesModel->getCountryCode(), - "", - m_apiServicesModel->getSelectedServiceType(), - m_apiServicesModel->getSelectedServiceProtocol(), - QJsonObject() }; - - QJsonObject apiPayload = gatewayRequestData.toJsonObject(); - apiPayload[apiDefs::key::transactionId] = originalTransactionId; - auto isTestPurchase = IosController::Instance()->isTestFlight(); - QByteArray responseBody; - ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase); - if (errorCode != ErrorCode::NoError) { - qWarning().noquote() << "[IAP] Failed to restore transaction" << originalTransactionId - << "errorCode =" << static_cast(errorCode); - emit errorOccurred(errorCode); - return false; - } - - int duplicateServerIndex = -1; - errorCode = importServiceFromBilling(responseBody, isTestPurchase, duplicateServerIndex); - if (errorCode == ErrorCode::ApiConfigAlreadyAdded) { - emit installServerFromApiFinished(tr("This subscription is already in the app."), duplicateServerIndex); - return true; - } - if (errorCode != ErrorCode::NoError) { - emit errorOccurred(errorCode); - return false; - } emit installServerFromApiFinished(tr("Subscription restored successfully.")); #endif return true;