Fix: [P2-511] UI is blocked when a renewal transaction is received

Refs: https://gitlab.protontech.ch/apple/shared/protoncore/-/merge_requests/2123
This commit is contained in:
Tiziano Bruni 2025-04-04 16:23:48 +00:00
commit 08d5f57341
10 changed files with 63 additions and 21 deletions

View file

@ -115,7 +115,7 @@ class PaymentsManager {
case .purchasedPlan(let plan):
self?.selectedPlan = plan
completionHandler(.success(()))
case .purchaseError(let error):
case .purchaseError(let error), .planAlreadyPurchased(let error):
if !shownHandlerCalled {
planShownHandler?()
}

View file

@ -33,6 +33,7 @@ public enum PurchaseResult {
case purchaseError(error: Error, processingPlan: InAppPurchasePlan? = nil)
case apiMightBeBlocked(message: String, originalError: Error, processingPlan: InAppPurchasePlan? = nil)
case purchaseCancelled
case planAlreadyPurchased(error: Error)
@available(*, deprecated, message: "Renewal transactions are no longer reported on")
case renewalNotification
@ -44,6 +45,7 @@ public enum PurchaseResult {
case .apiMightBeBlocked: .apiBlocked
case .purchaseCancelled: .canceled
case .renewalNotification: .renewalNotification
case .planAlreadyPurchased: .canceled
}
}
@ -55,6 +57,7 @@ public enum PurchaseResult {
case .apiMightBeBlocked: .apiBlocked
case .purchaseCancelled: .canceled
case .renewalNotification: .unknown
case .planAlreadyPurchased: .canceled
}
}
@ -66,6 +69,7 @@ public enum PurchaseResult {
case .apiMightBeBlocked: .apiMightBeBlocked
case .purchaseCancelled: .canceled
case .renewalNotification: .successful
case .planAlreadyPurchased: .canceled
}
}
}
@ -288,6 +292,8 @@ final class PurchaseManager: PurchaseManagerProtocol {
finishCallback(.purchaseCancelled)
} else if let error = error as? StoreKitManagerErrors, case let .apiMightBeBlocked(message, originalError) = error {
finishCallback(.apiMightBeBlocked(message: message, originalError: originalError, processingPlan: self?.unfinishedPurchasePlan))
} else if (error as? StoreKitManagerErrors) == .renewalTransaction {
finishCallback(.planAlreadyPurchased(error: error))
} else {
finishCallback(.purchaseError(error: error, processingPlan: self?.unfinishedPurchasePlan))
}

View file

@ -183,6 +183,10 @@ final class StoreKitManager: NSObject, StoreKitManagerProtocol {
threadSafeCache.removeValue(for: cache, in: \.errorCompletion, defaultValue: defaultErrorCallback) { $0?(error) }
}
private func callSilentErrorCompletion(for cache: UserInitiatedPurchaseCache, with error: Error) {
threadSafeCache.removeValue(for: cache, in: \.errorCompletion, defaultValue: {_ in }) { $0?(error) }
}
private func getSuccessCompletion(for cache: UserInitiatedPurchaseCache, completion: @escaping (SuccessCallback?) -> Void) {
threadSafeCache.removeValue(for: cache, in: \.successCompletion, completion: completion)
}
@ -661,8 +665,11 @@ extension StoreKitManager: SKPaymentTransactionObserver {
guard !transaction.isRenewal else {
// skip processing for transactions corresponding to a monthly renewal
if let self {
paymentQueue.finishTransaction(transaction)
finishTransaction(transaction, completion: nil)
ObservabilityEnv.report(.paymentLaunchBillingTotal(status: .renewalNotification, isDynamic: self.featureFlagsRepository.isEnabled(CoreFeatureFlagType.dynamicPlan)))
let cacheKey = UserInitiatedPurchaseCache(storeKitProductId: transaction.payment.productIdentifier,
hashedUserId: applicationUserId().map(hash(userId:)))
self.callSilentErrorCompletion(for: cacheKey, with: StoreKitManagerErrors.renewalTransaction)
}
return
}

View file

@ -180,9 +180,7 @@ protocol ProcessDependencies: AnyObject {
var refreshCompletionHandler: (ProcessCompletionResult) -> Void { get }
}
extension SKPaymentQueue: PaymentQueueProtocol {
}
extension SKPaymentQueue: PaymentQueueProtocol {}
extension InAppPurchasePlan {
// only needed for backwards compatibility to purchase 0 cost plans

View file

@ -36,6 +36,7 @@ public enum StoreKitManagerErrors: LocalizedError {
case appIsLocked
case pleaseSignIn
case apiMightBeBlocked(message: String, originalError: Error)
case renewalTransaction
@available(*, deprecated, message: "This is never returned anymore — the success callback with `.resolvingIAPToCreditsCausedByError` is returned instead")
static var creditsApplied: StoreKitManagerErrors { .transactionFailedByUnknownReason }
@ -52,19 +53,34 @@ public enum StoreKitManagerErrors: LocalizedError {
public var errorDescription: String? {
switch self {
case .unavailableProduct: return PSTranslation._error_unavailable_product.l10n
case .invalidPurchase: return PSTranslation._error_invalid_purchase.l10n
case .receiptLost: return PSTranslation._error_receipt_lost.l10n
case .haveTransactionOfAnotherUser: return PSTranslation._error_another_user_transaction.l10n
case .alreadyPurchasedPlanDoesNotMatchBackend: return PSTranslation._error_backend_mismatch.l10n
case .noActiveUsername: return PSTranslation._error_no_active_username_in_user_data_service.l10n
case .transactionFailedByUnknownReason: return PSTranslation._error_transaction_failed_by_unknown_reason.l10n
case .noNewSubscriptionInSuccessfulResponse: return PSTranslation._error_no_new_subscription_in_response.l10n
case .appIsLocked: return PSTranslation._error_unlock_to_proceed_with_iap.l10n
case .pleaseSignIn: return PSTranslation._error_please_sign_in_iap.l10n
case .wrongTokenStatus: return PSTranslation._error_wrong_token_status.l10n
case .notAllowed, .unknown: return nil
case .apiMightBeBlocked(let message, _): return message
case .unavailableProduct:
return PSTranslation._error_unavailable_product.l10n
case .invalidPurchase:
return PSTranslation._error_invalid_purchase.l10n
case .receiptLost:
return PSTranslation._error_receipt_lost.l10n
case .haveTransactionOfAnotherUser:
return PSTranslation._error_another_user_transaction.l10n
case .alreadyPurchasedPlanDoesNotMatchBackend:
return PSTranslation._error_backend_mismatch.l10n
case .noActiveUsername:
return PSTranslation._error_no_active_username_in_user_data_service.l10n
case .transactionFailedByUnknownReason:
return PSTranslation._error_transaction_failed_by_unknown_reason.l10n
case .noNewSubscriptionInSuccessfulResponse:
return PSTranslation._error_no_new_subscription_in_response.l10n
case .appIsLocked:
return PSTranslation._error_unlock_to_proceed_with_iap.l10n
case .pleaseSignIn:
return PSTranslation._error_please_sign_in_iap.l10n
case .wrongTokenStatus:
return PSTranslation._error_wrong_token_status.l10n
case .notAllowed, .unknown:
return nil
case .apiMightBeBlocked(let message, _):
return message
case .renewalTransaction:
return PSTranslation.error_plan_already_purchased.l10n
}
}
}
@ -82,7 +98,8 @@ extension StoreKitManagerErrors: Equatable {
(.noNewSubscriptionInSuccessfulResponse, .noNewSubscriptionInSuccessfulResponse),
(.notAllowed, .notAllowed),
(.appIsLocked, .appIsLocked),
(.pleaseSignIn, .pleaseSignIn):
(.pleaseSignIn, .pleaseSignIn),
(.renewalTransaction, .renewalTransaction):
return true
case let (.wrongTokenStatus(ltoken), .wrongTokenStatus(rtoken)):
return ltoken == rtoken

View file

@ -55,6 +55,7 @@ public enum PSTranslation: TranslationsExposing {
case popup_credits_applied_cancellation
case error_apply_payment_on_registration_title
case error_apply_payment_on_registration_message
case error_plan_already_purchased
public var l10n: String {
switch self {
@ -104,6 +105,8 @@ public enum PSTranslation: TranslationsExposing {
return localized(key: "Payment failed", comment: "Error applying credit after registration alert")
case .error_apply_payment_on_registration_message:
return localized(key: "You have successfully registered but your payment was not processed. To resend your payment information, click Retry. You will only be charged once. If the problem persists, please contact customer support.", comment: "Error applying credit after registration alert")
case .error_plan_already_purchased:
return localized(key: "The signed-in Apple account already purchased this item.", comment: "Error trying to purchase a plan twice using the same Apple account")
}
}
}

View file

@ -43,3 +43,5 @@
"OK" = "OK";
"The Proton servers are unreachable. It might be caused by wrong network configuration, Proton servers not working or Proton servers being blocked" = "The Proton servers are unreachable. It might be caused by wrong network configuration, Proton servers not working or Proton servers being blocked";
"The signed-in Apple account already purchased this item." = "The signed-in Apple account already purchased this item.";

View file

@ -397,6 +397,10 @@ extension PaymentsUICoordinator: PaymentsUIViewControllerDelegate {
break
case .renewalNotification:
break // precondition prevents it
case .planAlreadyPurchased(let error):
self.unfinishedPurchasePlan = nil
self.finishCallback(reason: .planAlreadyPurchased(error: error))
self.showError(error: error)
}
completionHandler()

View file

@ -40,6 +40,7 @@ public enum PaymentsUIResultReason {
case planPurchaseProcessingInProgress(accountPlan: InAppPurchasePlan)
case purchaseError(error: Error)
case apiMightBeBlocked(message: String, originalError: Error)
case planAlreadyPurchased(error: Error)
}
enum PaymentsUIMode {

View file

@ -244,7 +244,9 @@ public class ProtonButton: UIButton, AccessibleView {
fileprivate func showLoading() {
contentEdgeInsets = UIEdgeInsets(top: contentEdgeInsets.top, left: 40, bottom: contentEdgeInsets.bottom, right: 40)
if let activityIndicator = activityIndicator {
activityIndicator.startAnimating()
DispatchQueue.main.async {
activityIndicator.startAnimating()
}
} else {
createActivityIndicator()
}
@ -253,7 +255,9 @@ public class ProtonButton: UIButton, AccessibleView {
fileprivate func stopLoading() {
modeConfiguration()
activityIndicator?.stopAnimating()
DispatchQueue.main.async { [weak self] in
self?.activityIndicator?.stopAnimating()
}
isUserInteractionEnabled = true
}