|
1 | 1 | // |
2 | | -// ProductType.swift |
3 | | -// Superwall |
| 2 | +// File.swift |
4 | 3 | // |
5 | | -// Created by Yusuf Tör on 01/03/2022. |
| 4 | +// |
| 5 | +// Created by Yusuf Tör on 29/03/2024. |
6 | 6 | // |
7 | 7 |
|
8 | 8 | import Foundation |
9 | 9 |
|
10 | | -/// The type of product. |
11 | | -@objc(SWKProductType) |
12 | | -public enum ProductType: Int, Codable, Sendable { |
13 | | - /// The primary product of the paywall. |
14 | | - case primary |
15 | | - |
16 | | - /// The secondary product of the paywall. |
17 | | - case secondary |
18 | | - |
19 | | - /// The tertiary product of the paywall. |
20 | | - case tertiary |
| 10 | +/// An enum whose types specify the store which the product belongs to. |
| 11 | +@objc(SWKProductStore) |
| 12 | +public enum ProductStore: Int, Codable, Sendable { |
| 13 | + /// An Apple App Store product. |
| 14 | + case appStore |
21 | 15 |
|
22 | | - enum InternalProductType: String, Codable { |
23 | | - case primary |
24 | | - case secondary |
25 | | - case tertiary |
| 16 | + enum CodingKeys: String, CodingKey { |
| 17 | + case appStore = "APP_STORE" |
26 | 18 | } |
27 | 19 |
|
28 | 20 | public func encode(to encoder: Encoder) throws { |
29 | 21 | var container = encoder.singleValueContainer() |
30 | | - |
31 | 22 | switch self { |
32 | | - case .primary: |
33 | | - try container.encode(InternalProductType.primary.rawValue) |
34 | | - case .secondary: |
35 | | - try container.encode(InternalProductType.secondary.rawValue) |
36 | | - case .tertiary: |
37 | | - try container.encode(InternalProductType.tertiary.rawValue) |
| 23 | + case .appStore: |
| 24 | + try container.encode(CodingKeys.appStore.rawValue) |
38 | 25 | } |
39 | 26 | } |
40 | 27 |
|
41 | 28 | public init(from decoder: Decoder) throws { |
42 | 29 | let container = try decoder.singleValueContainer() |
43 | 30 | let rawValue = try container.decode(String.self) |
44 | | - guard let internalProductType = InternalProductType(rawValue: rawValue) else { |
| 31 | + let type = CodingKeys(rawValue: rawValue) |
| 32 | + switch type { |
| 33 | + case .appStore: |
| 34 | + self = .appStore |
| 35 | + case .none: |
| 36 | + throw DecodingError.valueNotFound( |
| 37 | + String.self, |
| 38 | + .init( |
| 39 | + codingPath: [], |
| 40 | + debugDescription: "Unsupported product store type." |
| 41 | + ) |
| 42 | + ) |
| 43 | + } |
| 44 | + } |
| 45 | +} |
| 46 | + |
| 47 | +/// An Apple App Store product. |
| 48 | +@objc(SWKAppStoreProduct) |
| 49 | +@objcMembers |
| 50 | +public final class AppStoreProduct: NSObject, Codable, Sendable { |
| 51 | + /// The bundleId that the product is associated with |
| 52 | + let bundleId: String? |
| 53 | + |
| 54 | + /// The store the product belongs to. |
| 55 | + let store: ProductStore |
| 56 | + |
| 57 | + /// The product identifier. |
| 58 | + public let id: String |
| 59 | + |
| 60 | + enum CodingKeys: String, CodingKey { |
| 61 | + case bundleId |
| 62 | + case id = "productIdentifier" |
| 63 | + case store |
| 64 | + } |
| 65 | + |
| 66 | + init( |
| 67 | + store: ProductStore = .appStore, |
| 68 | + id: String |
| 69 | + ) { |
| 70 | + self.bundleId = Bundle.main.bundleIdentifier |
| 71 | + self.store = store |
| 72 | + self.id = id |
| 73 | + } |
| 74 | + |
| 75 | + public func encode(to encoder: any Encoder) throws { |
| 76 | + var container = encoder.container(keyedBy: CodingKeys.self) |
| 77 | + try container.encode(id, forKey: .id) |
| 78 | + try container.encode(store, forKey: .store) |
| 79 | + try container.encodeIfPresent(bundleId, forKey: .bundleId) |
| 80 | + } |
| 81 | + |
| 82 | + public init(from decoder: any Decoder) throws { |
| 83 | + let container = try decoder.container(keyedBy: CodingKeys.self) |
| 84 | + self.id = try container.decode(String.self, forKey: .id) |
| 85 | + self.store = try container.decode(ProductStore.self, forKey: .store) |
| 86 | + |
| 87 | + // If the bundle ID is present, and it's not equal to the bundle |
| 88 | + // ID of the app, it gets ignored. |
| 89 | + let bundleId = try container.decodeIfPresent(String.self, forKey: .bundleId) |
| 90 | + if let bundleId = bundleId, |
| 91 | + bundleId != Bundle.main.bundleIdentifier { |
45 | 92 | throw DecodingError.typeMismatch( |
46 | | - InternalProductType.self, |
47 | | - .init( |
48 | | - codingPath: [], |
49 | | - debugDescription: "Didn't find a primary, secondary, or tertiary product type." |
50 | | - ) |
| 93 | + String.self, |
| 94 | + .init( |
| 95 | + codingPath: [], |
| 96 | + debugDescription: "The bundle id of the product didn't match the bundle id of the app." |
| 97 | + ) |
51 | 98 | ) |
52 | 99 | } |
53 | | - switch internalProductType { |
54 | | - case .primary: |
55 | | - self = .primary |
56 | | - case .secondary: |
57 | | - self = .secondary |
58 | | - case .tertiary: |
59 | | - self = .tertiary |
| 100 | + self.bundleId = bundleId |
| 101 | + super.init() |
| 102 | + } |
| 103 | + |
| 104 | + public override func isEqual(_ object: Any?) -> Bool { |
| 105 | + guard let other = object as? AppStoreProduct else { |
| 106 | + return false |
60 | 107 | } |
| 108 | + return bundleId == other.bundleId |
| 109 | + && store == other.store |
| 110 | + && id == other.id |
| 111 | + } |
| 112 | + |
| 113 | + public override var hash: Int { |
| 114 | + var hasher = Hasher() |
| 115 | + hasher.combine(bundleId) |
| 116 | + hasher.combine(store) |
| 117 | + hasher.combine(id) |
| 118 | + return hasher.finalize() |
61 | 119 | } |
62 | 120 | } |
63 | 121 |
|
64 | | -// MARK: - CustomStringConvertible |
65 | | -extension ProductType: CustomStringConvertible { |
66 | | - public var description: String { |
67 | | - switch self { |
68 | | - case .primary: |
69 | | - return InternalProductType.primary.rawValue |
70 | | - case .secondary: |
71 | | - return InternalProductType.secondary.rawValue |
72 | | - case .tertiary: |
73 | | - return InternalProductType.tertiary.rawValue |
| 122 | +/// An objc-only type that specifies a store and a product. |
| 123 | +@objc(SWKStoreProductAdapter) |
| 124 | +@objcMembers |
| 125 | +public final class StoreProductAdapterObjc: NSObject, Codable, Sendable { |
| 126 | + /// The store associated with the product. |
| 127 | + public let store: ProductStore |
| 128 | + |
| 129 | + /// The App Store product. This is non-nil if `store` is |
| 130 | + /// `appStore`. |
| 131 | + public let appStoreProduct: AppStoreProduct? |
| 132 | + |
| 133 | + init( |
| 134 | + store: ProductStore, |
| 135 | + appStoreProduct: AppStoreProduct? |
| 136 | + ) { |
| 137 | + self.store = store |
| 138 | + self.appStoreProduct = appStoreProduct |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +/// The product in the paywall. |
| 143 | +@objc(SWKProduct) |
| 144 | +@objcMembers |
| 145 | +public final class Product: NSObject, Codable, Sendable { |
| 146 | + /// The type of store and its associated product. |
| 147 | + public enum StoreProductType: Codable, Sendable, Hashable { |
| 148 | + case appStore(AppStoreProduct) |
| 149 | + } |
| 150 | + |
| 151 | + private enum CodingKeys: String, CodingKey { |
| 152 | + case name = "referenceName" |
| 153 | + case storeProduct |
| 154 | + case entitlements |
| 155 | + } |
| 156 | + |
| 157 | + /// The name of the product in the editor. |
| 158 | + /// |
| 159 | + /// This is optional because products can also be decoded from outside |
| 160 | + /// of a paywall. |
| 161 | + public let name: String? |
| 162 | + |
| 163 | + /// The type of product |
| 164 | + public let type: StoreProductType |
| 165 | + |
| 166 | + /// Convenience variable that accesses the product's identifier. |
| 167 | + public var id: String { |
| 168 | + switch type { |
| 169 | + case .appStore(let product): |
| 170 | + return product.id |
74 | 171 | } |
75 | 172 | } |
| 173 | + |
| 174 | + /// The entitlement associated with the product. |
| 175 | + public let entitlements: Set<Entitlement> |
| 176 | + |
| 177 | + /// The objc-only type of product. |
| 178 | + @objc(adapter) |
| 179 | + public let objcAdapter: StoreProductAdapterObjc |
| 180 | + |
| 181 | + init( |
| 182 | + name: String?, |
| 183 | + type: StoreProductType, |
| 184 | + entitlements: Set<Entitlement> |
| 185 | + ) { |
| 186 | + self.name = name |
| 187 | + self.type = type |
| 188 | + self.entitlements = entitlements |
| 189 | + |
| 190 | + switch type { |
| 191 | + case .appStore(let product): |
| 192 | + objcAdapter = .init( |
| 193 | + store: .appStore, |
| 194 | + appStoreProduct: product |
| 195 | + ) |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + public func encode(to encoder: Encoder) throws { |
| 200 | + var container = encoder.container(keyedBy: CodingKeys.self) |
| 201 | + |
| 202 | + try container.encodeIfPresent(name, forKey: .name) |
| 203 | + |
| 204 | + try container.encode(entitlements, forKey: .entitlements) |
| 205 | + |
| 206 | + switch type { |
| 207 | + case .appStore(let product): |
| 208 | + try container.encode(product, forKey: .storeProduct) |
| 209 | + } |
| 210 | + } |
| 211 | + |
| 212 | + public required init(from decoder: Decoder) throws { |
| 213 | + let container = try decoder.container(keyedBy: CodingKeys.self) |
| 214 | + name = try container.decodeIfPresent(String.self, forKey: .name) |
| 215 | + |
| 216 | + // These will throw an error if the StoreProduct is not an AppStoreProduct or if the |
| 217 | + // entitlement type is not `SERVICE_LEVEL`, which must be caught in a `Throwable` and |
| 218 | + // ignored in the paywall object. |
| 219 | + entitlements = try container.decode(Set<Entitlement>.self, forKey: .entitlements) |
| 220 | + let storeProduct = try container.decode(AppStoreProduct.self, forKey: .storeProduct) |
| 221 | + type = .appStore(storeProduct) |
| 222 | + objcAdapter = .init(store: .appStore, appStoreProduct: storeProduct) |
| 223 | + } |
| 224 | + |
| 225 | + public override func isEqual(_ object: Any?) -> Bool { |
| 226 | + guard let other = object as? Product else { |
| 227 | + return false |
| 228 | + } |
| 229 | + return name == other.name |
| 230 | + && type == other.type |
| 231 | + && entitlements == other.entitlements |
| 232 | + } |
| 233 | + |
| 234 | + public override var hash: Int { |
| 235 | + var hasher = Hasher() |
| 236 | + hasher.combine(name) |
| 237 | + hasher.combine(type) |
| 238 | + hasher.combine(entitlements) |
| 239 | + return hasher.finalize() |
| 240 | + } |
| 241 | +} |
| 242 | + |
| 243 | +struct TemplatingProductItem: Encodable { |
| 244 | + let name: String |
| 245 | + let productId: String |
| 246 | + |
| 247 | + private enum CodingKeys: String, CodingKey { |
| 248 | + case product |
| 249 | + case productId |
| 250 | + } |
| 251 | + |
| 252 | + static func create(from productItems: [Product]) -> [TemplatingProductItem] { |
| 253 | + return productItems.compactMap { |
| 254 | + guard let name = $0.name else { |
| 255 | + return nil |
| 256 | + } |
| 257 | + return TemplatingProductItem( |
| 258 | + name: name, |
| 259 | + productId: $0.id |
| 260 | + ) |
| 261 | + } |
| 262 | + } |
| 263 | + |
| 264 | + func encode(to encoder: Encoder) throws { |
| 265 | + var container = encoder.container(keyedBy: CodingKeys.self) |
| 266 | + |
| 267 | + // Encode name as "product" for templating |
| 268 | + try container.encode(name, forKey: .product) |
| 269 | + |
| 270 | + // Encode product ID as "productId" for templating |
| 271 | + try container.encode(productId, forKey: .productId) |
| 272 | + } |
76 | 273 | } |
0 commit comments