2025-08-05 10:59:52 +02:00
|
|
|
//
|
|
|
|
|
// Copyright RevenueCat Inc. All Rights Reserved.
|
|
|
|
|
//
|
|
|
|
|
// Licensed under the MIT License (the "License");
|
|
|
|
|
// you may not use this file except in compliance with the License.
|
|
|
|
|
// You may obtain a copy of the License at
|
|
|
|
|
//
|
|
|
|
|
// https://opensource.org/licenses/MIT
|
|
|
|
|
//
|
2025-09-01 19:04:01 +02:00
|
|
|
// SimulatedStorePurchaseUI.swift
|
2025-08-05 10:59:52 +02:00
|
|
|
//
|
|
|
|
|
// Created by Antonio Pallares on 1/8/25.
|
|
|
|
|
|
|
|
|
|
import Foundation
|
|
|
|
|
#if os(iOS) || os(tvOS) || VISION_OS || targetEnvironment(macCatalyst)
|
|
|
|
|
import UIKit
|
|
|
|
|
#elseif os(watchOS)
|
|
|
|
|
import UIKit
|
|
|
|
|
import WatchKit
|
|
|
|
|
#elseif os(macOS)
|
|
|
|
|
import AppKit
|
|
|
|
|
#endif
|
|
|
|
|
|
2025-09-01 19:04:01 +02:00
|
|
|
enum SimulatedStorePurchaseUIResult: Sendable {
|
2025-08-05 10:59:52 +02:00
|
|
|
case cancel
|
|
|
|
|
case simulateFailure
|
|
|
|
|
case simulateSuccess
|
|
|
|
|
case error(PurchasesError)
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 19:04:01 +02:00
|
|
|
protocol SimulatedStorePurchaseUI: Sendable {
|
2025-08-05 10:59:52 +02:00
|
|
|
|
|
|
|
|
/// Presents the purchase UI for the given product.
|
|
|
|
|
///
|
|
|
|
|
/// - Parameters:
|
|
|
|
|
/// - product: The product to be purchased.
|
|
|
|
|
/// - Returns: A result indicating the selected outcome of the purchase UI interaction.
|
2025-09-01 19:04:01 +02:00
|
|
|
func presentPurchaseUI(for product: SimulatedStoreProduct) async -> SimulatedStorePurchaseUIResult
|
2025-08-05 10:59:52 +02:00
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 19:04:01 +02:00
|
|
|
/// Contains the logic to present a system alert for the confirmation of Simulated Store products purchases.
|
|
|
|
|
struct DefaultSimulatedStorePurchaseUI: SimulatedStorePurchaseUI {
|
2025-08-05 10:59:52 +02:00
|
|
|
|
|
|
|
|
private let systemInfo: SystemInfo
|
|
|
|
|
|
|
|
|
|
init(systemInfo: SystemInfo) {
|
|
|
|
|
self.systemInfo = systemInfo
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 19:04:01 +02:00
|
|
|
func presentPurchaseUI(for product: SimulatedStoreProduct) async -> SimulatedStorePurchaseUIResult {
|
2025-08-05 10:59:52 +02:00
|
|
|
await Task { @MainActor in
|
|
|
|
|
return await withUnsafeContinuation { continuation in
|
|
|
|
|
|
2025-09-01 19:04:01 +02:00
|
|
|
let completion: (SimulatedStorePurchaseUIResult) -> Void = { result in
|
2025-08-05 10:59:52 +02:00
|
|
|
continuation.resume(returning: result)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-06 10:35:48 +01:00
|
|
|
let alert = Alert(
|
|
|
|
|
title: Self.purchaseAlertTitle,
|
|
|
|
|
message: product.purchaseAlertMessage,
|
|
|
|
|
actions: [
|
|
|
|
|
.init(title: Self.purchaseActionTitle,
|
|
|
|
|
callback: { _ in completion(.simulateSuccess) },
|
|
|
|
|
style: .default),
|
|
|
|
|
.init(title: Self.failureActionTitle,
|
|
|
|
|
callback: { _ in completion(.simulateFailure) },
|
|
|
|
|
style: .destructive),
|
|
|
|
|
.init(title: Self.cancelActionTitle,
|
|
|
|
|
callback: { _ in completion(.cancel) },
|
|
|
|
|
style: .cancel)
|
|
|
|
|
], dismissCallback: {
|
|
|
|
|
completion(.cancel)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.showAlert(alert) { (error: PurchasesError) in
|
|
|
|
|
completion(.error(error))
|
|
|
|
|
}
|
2025-08-05 10:59:52 +02:00
|
|
|
}
|
|
|
|
|
}.value
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-06 10:35:48 +01:00
|
|
|
#if !DEBUG
|
2025-08-05 10:59:52 +02:00
|
|
|
|
2025-11-06 10:35:48 +01:00
|
|
|
/// Calling this method will show an alert indicating that a Test Store API key
|
|
|
|
|
/// is being used in a release build, which is not supported.
|
2025-12-02 20:40:34 +01:00
|
|
|
func showTestKeyInReleaseAlert(redactedApiKey: String) async {
|
2025-11-06 10:35:48 +01:00
|
|
|
await Task { @MainActor in
|
|
|
|
|
return await withUnsafeContinuation { continuation in
|
2025-08-05 10:59:52 +02:00
|
|
|
|
2025-11-06 10:35:48 +01:00
|
|
|
let completion: () -> Void = continuation.resume
|
2025-08-05 10:59:52 +02:00
|
|
|
|
2025-11-06 10:35:48 +01:00
|
|
|
let alert = Alert(
|
|
|
|
|
title: TestKeyInReleaseAlert.title,
|
2025-12-02 20:40:34 +01:00
|
|
|
message: TestKeyInReleaseAlert.message(redactedApiKey: redactedApiKey),
|
2025-11-06 10:35:48 +01:00
|
|
|
actions: [
|
|
|
|
|
.init(title: TestKeyInReleaseAlert.actionTitle,
|
|
|
|
|
callback: { _ in completion() },
|
|
|
|
|
style: .cancel)
|
|
|
|
|
], dismissCallback: {
|
|
|
|
|
completion()
|
|
|
|
|
}
|
|
|
|
|
)
|
2025-08-05 10:59:52 +02:00
|
|
|
|
2025-11-06 10:35:48 +01:00
|
|
|
self.showAlert(alert) { _ in
|
|
|
|
|
completion()
|
|
|
|
|
}
|
2025-08-05 10:59:52 +02:00
|
|
|
}
|
2025-11-06 10:35:48 +01:00
|
|
|
}.value
|
2025-08-05 10:59:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#if os(iOS) || os(tvOS) || VISION_OS || targetEnvironment(macCatalyst)
|
|
|
|
|
|
|
|
|
|
// MARK: - UIViewController Extensions
|
|
|
|
|
|
|
|
|
|
private extension UIViewController {
|
|
|
|
|
|
|
|
|
|
func topMostViewController() -> UIViewController {
|
|
|
|
|
if let presentedViewController = self.presentedViewController {
|
|
|
|
|
return presentedViewController.topMostViewController()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let navigationController = self as? UINavigationController {
|
|
|
|
|
return navigationController.visibleViewController?.topMostViewController() ?? navigationController
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let tabBarController = self as? UITabBarController {
|
|
|
|
|
return tabBarController.selectedViewController?.topMostViewController() ?? tabBarController
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return self
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
// MARK: - Purchase Alert Details
|
|
|
|
|
|
2025-11-06 10:35:48 +01:00
|
|
|
private extension DefaultSimulatedStorePurchaseUI {
|
2025-08-05 10:59:52 +02:00
|
|
|
|
|
|
|
|
static let purchaseAlertTitle = "Test Purchase"
|
|
|
|
|
static let purchaseActionTitle = "Test valid purchase"
|
|
|
|
|
static let cancelActionTitle = "Cancel"
|
|
|
|
|
static let failureActionTitle = "Test failed purchase"
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-06 10:35:48 +01:00
|
|
|
private enum TestKeyInReleaseAlert {
|
|
|
|
|
|
|
|
|
|
static let title = "Wrong API Key"
|
2025-12-02 20:40:34 +01:00
|
|
|
static func message(redactedApiKey: String) -> String {
|
|
|
|
|
return "This app is using a test API key: \(redactedApiKey)\n\n" +
|
|
|
|
|
"To prepare for release, update your RevenueCat settings to use a production key.\n\n" +
|
|
|
|
|
"For more info, visit the RevenueCat dashboard.\n\n" +
|
|
|
|
|
"The app will close now to protect the security of test purchases."
|
|
|
|
|
}
|
2025-11-06 10:35:48 +01:00
|
|
|
|
|
|
|
|
static let actionTitle = "OK"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension SimulatedStoreProduct {
|
2025-08-05 10:59:52 +02:00
|
|
|
|
|
|
|
|
var purchaseAlertMessage: String {
|
|
|
|
|
var message = "This is a test purchase and should only be used during development. In production, " +
|
|
|
|
|
"use an Apple API key from RevenueCat.\n\n"
|
|
|
|
|
message += "Product ID: \(self.productIdentifier)\n"
|
|
|
|
|
message += "Title: \(self.localizedTitle)\n"
|
|
|
|
|
message += "Price: \(self.localizedPriceString)\n"
|
|
|
|
|
|
|
|
|
|
if let subscriptionPeriod = self.subscriptionPeriod {
|
|
|
|
|
message += subscriptionPeriod.debugDescription + "\n"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !self.discounts.isEmpty {
|
|
|
|
|
message += "Offers:\n" + self.discounts.map { $0.testPurchaseDescription }.joined(separator: "\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return message
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-06 10:35:48 +01:00
|
|
|
private extension StoreProductDiscount {
|
2025-08-05 10:59:52 +02:00
|
|
|
|
|
|
|
|
var testPurchaseDescription: String {
|
|
|
|
|
return "\(self.type.testPurchaseTitle): \(self.localizedPriceString) for " +
|
|
|
|
|
"\(self.numberOfPeriods * self.subscriptionPeriod.value) \(self.subscriptionPeriod.unit.debugDescription)(s)"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-06 10:35:48 +01:00
|
|
|
private extension StoreProductDiscount.DiscountType {
|
2025-08-05 10:59:52 +02:00
|
|
|
|
|
|
|
|
var testPurchaseTitle: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .introductory:
|
|
|
|
|
return "Intro"
|
|
|
|
|
case .promotional:
|
|
|
|
|
return "Promo"
|
|
|
|
|
case .winBack:
|
|
|
|
|
return "WinBack"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-06 10:35:48 +01:00
|
|
|
|
|
|
|
|
// MARK: - Generic Alert Model
|
|
|
|
|
|
|
|
|
|
private extension DefaultSimulatedStorePurchaseUI {
|
|
|
|
|
|
|
|
|
|
struct Action {
|
|
|
|
|
|
|
|
|
|
// swiftlint:disable:next nesting
|
|
|
|
|
enum Style {
|
|
|
|
|
case destructive
|
|
|
|
|
case cancel
|
|
|
|
|
case `default`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let title: String
|
|
|
|
|
let callback: @MainActor (String) -> Void
|
|
|
|
|
let style: Style
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Alert {
|
|
|
|
|
let title: String
|
|
|
|
|
let message: String
|
|
|
|
|
|
|
|
|
|
/// Only up to 3 actions are supported on macOS.
|
|
|
|
|
let actions: [Action]
|
|
|
|
|
let dismissCallback: @MainActor () -> Void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension DefaultSimulatedStorePurchaseUI {
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
func showAlert(_ alert: Alert, onError: (PurchasesError) -> Void) {
|
|
|
|
|
|
|
|
|
|
#if os(iOS) || os(tvOS) || VISION_OS || targetEnvironment(macCatalyst)
|
|
|
|
|
guard let viewController = self.findTopViewController() else {
|
|
|
|
|
Logger.warn(Strings.purchase.unable_to_find_root_view_controller_for_simulated_purchase)
|
|
|
|
|
onError(ErrorUtils.unknownError(
|
|
|
|
|
message: Strings.purchase.unable_to_find_root_view_controller_for_simulated_purchase.description
|
|
|
|
|
))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let alertController = UIAlertController(title: alert.title,
|
|
|
|
|
message: alert.message,
|
|
|
|
|
preferredStyle: .alert)
|
|
|
|
|
|
|
|
|
|
alert.actions.forEach { action in
|
|
|
|
|
let alertAction = UIAlertAction(title: action.title, style: action.style.alertActionStyle) { _ in
|
|
|
|
|
action.callback(action.title)
|
|
|
|
|
}
|
|
|
|
|
alertController.addAction(alertAction)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
viewController.present(alertController, animated: true)
|
|
|
|
|
|
|
|
|
|
#elseif os(watchOS)
|
|
|
|
|
|
|
|
|
|
let actions: [WKAlertAction] = alert.actions.map { action in
|
|
|
|
|
WKAlertAction(title: action.title, style: action.style.alertActionStyle) {
|
|
|
|
|
action.callback(action.title)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
WKInterfaceDevice.current().play(.click)
|
|
|
|
|
|
|
|
|
|
let controller = WKExtension.shared().rootInterfaceController
|
|
|
|
|
controller?.presentAlert(withTitle: alert.title,
|
|
|
|
|
message: alert.message,
|
|
|
|
|
preferredStyle: .alert,
|
|
|
|
|
actions: actions)
|
|
|
|
|
|
|
|
|
|
#elseif os(macOS)
|
|
|
|
|
|
|
|
|
|
let nsAlert = NSAlert()
|
|
|
|
|
nsAlert.messageText = alert.title
|
|
|
|
|
nsAlert.informativeText = alert.message
|
|
|
|
|
nsAlert.alertStyle = .informational
|
|
|
|
|
|
|
|
|
|
// Only up to 3 actions are supported on macOS. Keep a local array of the ones we actually show.
|
|
|
|
|
let displayedActions = Array(alert.actions.prefix(3))
|
|
|
|
|
displayedActions.forEach { action in
|
|
|
|
|
nsAlert.addButton(withTitle: action.title)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let response = nsAlert.runModal()
|
|
|
|
|
|
|
|
|
|
// Map the modal response to the button index (0-based).
|
|
|
|
|
let indexMap: [NSApplication.ModalResponse: Int] = [
|
|
|
|
|
.alertFirstButtonReturn: 0,
|
|
|
|
|
.alertSecondButtonReturn: 1,
|
|
|
|
|
.alertThirdButtonReturn: 2
|
|
|
|
|
]
|
|
|
|
|
let selectedIndex = indexMap[response] ?? 0
|
|
|
|
|
let selectedAction = displayedActions[safe: selectedIndex]
|
|
|
|
|
selectedAction?.callback(selectedAction?.title ?? "")
|
|
|
|
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension DefaultSimulatedStorePurchaseUI.Action.Style {
|
|
|
|
|
|
|
|
|
|
#if os(iOS) || os(tvOS) || VISION_OS || targetEnvironment(macCatalyst)
|
|
|
|
|
|
|
|
|
|
var alertActionStyle: UIAlertAction.Style {
|
|
|
|
|
switch self {
|
|
|
|
|
case .destructive:
|
|
|
|
|
return .destructive
|
|
|
|
|
case .cancel:
|
|
|
|
|
return .cancel
|
|
|
|
|
case .default:
|
|
|
|
|
return .default
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#elseif os(watchOS)
|
|
|
|
|
|
|
|
|
|
var alertActionStyle: WKAlertActionStyle {
|
|
|
|
|
switch self {
|
|
|
|
|
case .destructive:
|
|
|
|
|
return .destructive
|
|
|
|
|
case .cancel:
|
|
|
|
|
return .cancel
|
|
|
|
|
case .default:
|
|
|
|
|
return .default
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Helper
|
|
|
|
|
|
|
|
|
|
#if os(iOS) || os(tvOS) || VISION_OS || targetEnvironment(macCatalyst)
|
|
|
|
|
|
|
|
|
|
fileprivate extension DefaultSimulatedStorePurchaseUI {
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
func findTopViewController() -> UIViewController? {
|
|
|
|
|
guard let application = self.systemInfo.sharedUIApplication else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let window: UIWindow?
|
|
|
|
|
|
|
|
|
|
// Try to get the window from the scene first
|
|
|
|
|
if #available(macCatalyst 13.1, *),
|
|
|
|
|
let windowScene = application.currentWindowScene {
|
|
|
|
|
if #available(iOS 15.0, macCatalyst 15.0, tvOS 15.0, *) {
|
|
|
|
|
window = windowScene.keyWindow
|
|
|
|
|
} else {
|
|
|
|
|
window = windowScene.windows.first(where: { $0.isKeyWindow })
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if #available(iOS 15.0, macCatalyst 15.0, *) {
|
|
|
|
|
window = nil
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback to legacy approach on OSs where UIApplication's `windows` property is not deprecated
|
|
|
|
|
window = application.windows.first(where: { $0.isKeyWindow })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return window?.rootViewController?.topMostViewController()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endif
|