Add StoreKit utils
This commit is contained in:
parent
05d92c0a85
commit
9505a4ff73
|
@ -34,6 +34,7 @@ SwiftKit is divided into the following sections:
|
||||||
* Messaging
|
* Messaging
|
||||||
* Network
|
* Network
|
||||||
* Services
|
* Services
|
||||||
|
* StoreKit
|
||||||
* Validation
|
* Validation
|
||||||
|
|
||||||
You can explore the various sections in the documentation or in the demo app.
|
You can explore the various sections in the documentation or in the demo app.
|
||||||
|
|
|
@ -3,11 +3,11 @@
|
||||||
|
|
||||||
## 1.0
|
## 1.0
|
||||||
|
|
||||||
After a lot of waiting and stabilizing, I think it's time to push the major release button.
|
After a lot of waiting and stabilizing, I think it's time to push the major release button.
|
||||||
|
|
||||||
There are no drastic changes, but I use this in most of my projects and find it very helpful and stable.
|
This version drastically improves documentation and ships with a DocC documentation archive.
|
||||||
|
|
||||||
This version drastically improves documentation and ships with a DocC documentation archive.
|
This version also introduces a new `StoreKit` namespace with handy utils for managing StoreKit products and purchases.
|
||||||
|
|
||||||
### ✨ New features
|
### ✨ New features
|
||||||
|
|
||||||
|
@ -15,6 +15,10 @@ This version drastically improves documentation and ships with a DocC documentat
|
||||||
* `Date` has a new `components` extension for retrieving year, month, hour etc.
|
* `Date` has a new `components` extension for retrieving year, month, hour etc.
|
||||||
* `String` has new `boolValue` extension.
|
* `String` has new `boolValue` extension.
|
||||||
|
|
||||||
|
* `StoreService` is a new protocol for managing StoreKit products and purchases.
|
||||||
|
* `StoreContext` is a new class for managing StoreKit products and purchases.
|
||||||
|
* `StandardStoreService` is a new class that implements the `StoreService` protocol.
|
||||||
|
|
||||||
|
|
||||||
## 0.7.0
|
## 0.7.0
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
//
|
||||||
|
// Persisted.swift
|
||||||
|
// SwiftKit
|
||||||
|
//
|
||||||
|
// Created by Daniel Saidi on 2020-04-05.
|
||||||
|
// Copyright © 2020 Daniel Saidi. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/**
|
||||||
|
This property wrapper automatically persists any new values
|
||||||
|
to user defaults and sets the initial property value to the
|
||||||
|
last persisted value or a fallback value.
|
||||||
|
|
||||||
|
This type is internal, since the `SwiftUI` instance is used
|
||||||
|
to greated extent.
|
||||||
|
*/
|
||||||
|
@propertyWrapper
|
||||||
|
struct Persisted<T: Codable> {
|
||||||
|
|
||||||
|
init(
|
||||||
|
key: String,
|
||||||
|
store: UserDefaults = .standard,
|
||||||
|
defaultValue: T) {
|
||||||
|
self.key = key
|
||||||
|
self.store = store
|
||||||
|
self.defaultValue = defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private let key: String
|
||||||
|
private let store: UserDefaults
|
||||||
|
private let defaultValue: T
|
||||||
|
|
||||||
|
var wrappedValue: T {
|
||||||
|
get {
|
||||||
|
guard let data = store.object(forKey: key) as? Data else { return defaultValue }
|
||||||
|
let value = try? JSONDecoder().decode(T.self, from: data)
|
||||||
|
return value ?? defaultValue
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let data = try? JSONEncoder().encode(newValue)
|
||||||
|
store.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,168 @@
|
||||||
|
//
|
||||||
|
// StandardStoreService.swift
|
||||||
|
// SwiftKit
|
||||||
|
//
|
||||||
|
// Created by Daniel Saidi on 2021-11-08.
|
||||||
|
// Copyright © 2021 Daniel Saidi. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
/**
|
||||||
|
This service class implements `StoreService` by integrating
|
||||||
|
with StoreKit.
|
||||||
|
|
||||||
|
The service keeps products and purchases in sync, using the
|
||||||
|
provided ``StoreContext``. An app can listen to any changes
|
||||||
|
in the context to drive UI changes.
|
||||||
|
|
||||||
|
You can configure a test app to use this service with local
|
||||||
|
products by adding a StoreKit configuration file to the app.
|
||||||
|
*/
|
||||||
|
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
|
||||||
|
public class StandardStoreService: StoreService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
Create a service instance for the provided `productIds`,
|
||||||
|
that syncs any changes to the provided `context`.
|
||||||
|
|
||||||
|
- Parameters:
|
||||||
|
- productIds: The IDs of the products to handle.
|
||||||
|
- context: The store context to sync with.
|
||||||
|
*/
|
||||||
|
public init(
|
||||||
|
productIds: [String],
|
||||||
|
context: StoreContext = StoreContext()) {
|
||||||
|
self.productIds = productIds
|
||||||
|
self.storeContext = context
|
||||||
|
self.transactionTask = nil
|
||||||
|
self.transactionTask = getTransactionListenerTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
self.transactionTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private let productIds: [String]
|
||||||
|
private let storeContext: StoreContext
|
||||||
|
private var transactionTask: Task<Void, Error>?
|
||||||
|
|
||||||
|
/**
|
||||||
|
Get all available products.
|
||||||
|
*/
|
||||||
|
public func getProducts() async throws -> [Product] {
|
||||||
|
try await Product.products(for: productIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Purchase a certain product.
|
||||||
|
*/
|
||||||
|
public func purchase(_ product: Product) async throws -> Product.PurchaseResult {
|
||||||
|
let result = try await product.purchase()
|
||||||
|
switch result {
|
||||||
|
case .success(let result): try await handleTransaction(result)
|
||||||
|
case .pending: break
|
||||||
|
case .userCancelled: break
|
||||||
|
@unknown default: break
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Restore purchases that are not on this device.
|
||||||
|
*/
|
||||||
|
public func restorePurchases() async throws {
|
||||||
|
try await syncTransactions()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Sync product and purchase information from the store to
|
||||||
|
the provided store context.
|
||||||
|
*/
|
||||||
|
public func syncStoreData() async throws {
|
||||||
|
let products = try await getProducts()
|
||||||
|
await updateContext(with: products)
|
||||||
|
try await restorePurchases()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
|
||||||
|
private extension StandardStoreService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
Create a task that can be used to listen for and acting
|
||||||
|
on transaction changes.
|
||||||
|
*/
|
||||||
|
func getTransactionListenerTask() -> Task<Void, Error> {
|
||||||
|
Task.detached {
|
||||||
|
for await result in Transaction.updates {
|
||||||
|
do {
|
||||||
|
try await self.handleTransaction(result)
|
||||||
|
} catch {
|
||||||
|
print("Transaction listener error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Try resolving a valid transaction for a certain product.
|
||||||
|
*/
|
||||||
|
func getValidTransaction(for productId: String) async throws -> Transaction? {
|
||||||
|
guard let latest = await Transaction.latest(for: productId) else { return nil }
|
||||||
|
let result = try verifyTransaction(latest)
|
||||||
|
return result.isValid ? result : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Handle the transaction in the provided `result`.
|
||||||
|
*/
|
||||||
|
func handleTransaction(_ result: VerificationResult<Transaction>) async throws {
|
||||||
|
let transaction = try verifyTransaction(result)
|
||||||
|
await updateContext(with: transaction)
|
||||||
|
await transaction.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Sync the transactions of all available products.
|
||||||
|
*/
|
||||||
|
func syncTransactions() async throws {
|
||||||
|
var transactions: [Transaction] = []
|
||||||
|
for id in productIds {
|
||||||
|
if let transaction = try await getValidTransaction(for: id) {
|
||||||
|
transactions.append(transaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await updateContext(with: transactions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Verify the transaction in the provided `result`
|
||||||
|
*/
|
||||||
|
func verifyTransaction(_ result: VerificationResult<Transaction>) throws -> Transaction {
|
||||||
|
switch result {
|
||||||
|
case .unverified(let transaction, let error): throw StoreServiceError.invalidTransaction(transaction, error)
|
||||||
|
case .verified(let transaction): return transaction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
|
||||||
|
private extension StandardStoreService {
|
||||||
|
|
||||||
|
func updateContext(with products: [Product]) {
|
||||||
|
storeContext.products = products
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateContext(with transaction: Transaction) {
|
||||||
|
var transactions = storeContext.transactions
|
||||||
|
.filter { $0.productID != transaction.productID }
|
||||||
|
transactions.append(transaction)
|
||||||
|
storeContext.transactions = transactions
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateContext(with transactions: [Transaction]) {
|
||||||
|
storeContext.transactions = transactions
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
//
|
||||||
|
// StoreContext+Products.swift
|
||||||
|
// SwiftKit
|
||||||
|
//
|
||||||
|
// Created by Daniel Saidi on 2021-11-09.
|
||||||
|
// Copyright © 2021 Daniel Saidi. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
|
||||||
|
public extension StoreContext {
|
||||||
|
|
||||||
|
/**
|
||||||
|
Whether or not a certain product has an active purchase.
|
||||||
|
*/
|
||||||
|
func isProductPurchased(id: String) -> Bool {
|
||||||
|
purchasedProductIds.contains(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Whether or not a certain product has an active purchase.
|
||||||
|
*/
|
||||||
|
func isProductPurchased(_ product: Product) -> Bool {
|
||||||
|
isProductPurchased(id: product.id)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
//
|
||||||
|
// StoreContext.swift
|
||||||
|
// SwiftKit
|
||||||
|
//
|
||||||
|
// Created by Daniel Saidi on 2021-11-08.
|
||||||
|
// Copyright © 2021 Daniel Saidi. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import StoreKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/**
|
||||||
|
This class can be used to manage store information in a way
|
||||||
|
that makes it observable.
|
||||||
|
|
||||||
|
Since the `Product` type isn't `Codable`, `products` is not
|
||||||
|
permanently persisted, which means that this information is
|
||||||
|
reset if the app restarts. Due to this, any changes to this
|
||||||
|
property will also update `productIds`, which gives you the
|
||||||
|
option to map the IDs to a local product representation and
|
||||||
|
present previously fetched products.
|
||||||
|
|
||||||
|
Note however that a `Product` instance is needed to perform
|
||||||
|
a purchase.
|
||||||
|
*/
|
||||||
|
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
|
||||||
|
public class StoreContext: ObservableObject {
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
productIds = persistedProductIds
|
||||||
|
purchasedProductIds = persistedPurchasedProductIds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
The available products.
|
||||||
|
|
||||||
|
Since `Product` isn't `Codable`, this property can't be
|
||||||
|
persisted. It must be re-fetched when the app starts.
|
||||||
|
*/
|
||||||
|
public var products: [Product] = [] {
|
||||||
|
didSet { productIds = products.map { $0.id} }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
The available products IDs.
|
||||||
|
|
||||||
|
This list is permanently persisted and can be mapped to
|
||||||
|
a local product representation to present temp. product
|
||||||
|
info before `products` have been re-fetched.
|
||||||
|
*/
|
||||||
|
@Published
|
||||||
|
public private(set) var productIds: [String] = [] {
|
||||||
|
willSet { persistedProductIds = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
The purchased products IDs.
|
||||||
|
|
||||||
|
This list is permanently persisted and can be mapped to
|
||||||
|
a local product representation to present temp. product
|
||||||
|
info before `transactions` have been re-fetched.
|
||||||
|
*/
|
||||||
|
@Published
|
||||||
|
public private(set) var purchasedProductIds: [String] = [] {
|
||||||
|
willSet { persistedPurchasedProductIds = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
The active transactions.
|
||||||
|
|
||||||
|
Since `Transaction` isn't `Codable` this property can't
|
||||||
|
be persisted. It must be re-fetched when the app starts.
|
||||||
|
*/
|
||||||
|
public var transactions: [StoreKit.Transaction] = [] {
|
||||||
|
didSet { purchasedProductIds = transactions.map { $0.productID } }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Persisted Properties
|
||||||
|
|
||||||
|
@Persisted(key: key("productIds"), defaultValue: [])
|
||||||
|
private var persistedProductIds: [String]
|
||||||
|
|
||||||
|
@Persisted(key: key("purchasedProductIds"), defaultValue: [])
|
||||||
|
private var persistedPurchasedProductIds: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
|
||||||
|
private extension StoreContext {
|
||||||
|
|
||||||
|
static func key(_ name: String) -> String { "store.\(name)" }
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// StoreService.swift
|
||||||
|
// SwiftKit
|
||||||
|
//
|
||||||
|
// Created by Daniel Saidi on 2021-11-08.
|
||||||
|
// Copyright © 2021 Daniel Saidi. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
/**
|
||||||
|
This protocol can be implemented by any classes that can be
|
||||||
|
used to manage store products.
|
||||||
|
*/
|
||||||
|
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
|
||||||
|
public protocol StoreService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
Get all available products.
|
||||||
|
*/
|
||||||
|
func getProducts() async throws -> [Product]
|
||||||
|
|
||||||
|
/**
|
||||||
|
Purchase a certain product.
|
||||||
|
*/
|
||||||
|
func purchase(_ product: Product) async throws -> Product.PurchaseResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
Restore purchases that are not on this device.
|
||||||
|
*/
|
||||||
|
func restorePurchases() async throws
|
||||||
|
|
||||||
|
/**
|
||||||
|
Sync product and purchase information from the store to
|
||||||
|
any implementation defined sync destination.
|
||||||
|
*/
|
||||||
|
func syncStoreData() async throws
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
//
|
||||||
|
// StoreServiceError.swift
|
||||||
|
// SwiftKit
|
||||||
|
//
|
||||||
|
// Created by Daniel Saidi on 2021-11-08.
|
||||||
|
// Copyright © 2021 Daniel Saidi. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
/**
|
||||||
|
This enum lists errors that can be thrown by store services.
|
||||||
|
*/
|
||||||
|
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
|
||||||
|
public enum StoreServiceError: Error {
|
||||||
|
|
||||||
|
/// This is thrown if a transaction can't be verified.
|
||||||
|
case invalidTransaction(Transaction, VerificationResult<Transaction>.VerificationError)
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
//
|
||||||
|
// Transaction+Valid.swift
|
||||||
|
// SwiftKit
|
||||||
|
//
|
||||||
|
// Created by Daniel Saidi on 2021-11-08.
|
||||||
|
// Copyright © 2021 Daniel Saidi. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
|
||||||
|
extension Transaction {
|
||||||
|
|
||||||
|
/**
|
||||||
|
Whether or not the transaction is valid.
|
||||||
|
|
||||||
|
The logic may have to be adjusted as more product types
|
||||||
|
are handled with this service.
|
||||||
|
*/
|
||||||
|
var isValid: Bool {
|
||||||
|
if revocationDate != nil { return false }
|
||||||
|
guard let date = expirationDate else { return false }
|
||||||
|
return date > Date()
|
||||||
|
}
|
||||||
|
}
|
|
@ -130,6 +130,13 @@ This namespace contains composer extensions.
|
||||||
- ``MultiProxy``
|
- ``MultiProxy``
|
||||||
- ``Proxy``
|
- ``Proxy``
|
||||||
|
|
||||||
|
### StoreKit
|
||||||
|
|
||||||
|
- ``StoreService``
|
||||||
|
- ``StoreContext``
|
||||||
|
- ``StoreServiceError``
|
||||||
|
- ``StandardStoreService``
|
||||||
|
|
||||||
### Validation
|
### Validation
|
||||||
|
|
||||||
- ``EmailValidator``
|
- ``EmailValidator``
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// String+BoolTests.swift
|
||||||
|
// SwiftKit
|
||||||
|
//
|
||||||
|
// Created by Daniel Saidi on 2021-11-02.
|
||||||
|
// Copyright © 2021 Daniel Saidi. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Quick
|
||||||
|
import Nimble
|
||||||
|
import SwiftKit
|
||||||
|
|
||||||
|
class String_BoolTests: QuickSpec {
|
||||||
|
|
||||||
|
override func spec() {
|
||||||
|
|
||||||
|
describe("bool value") {
|
||||||
|
|
||||||
|
func result(for string: String) -> Bool {
|
||||||
|
string.boolValue
|
||||||
|
}
|
||||||
|
|
||||||
|
it("is valid for many different true expressions") {
|
||||||
|
let expected = ["YES", "yes", "1"]
|
||||||
|
expected.forEach {
|
||||||
|
expect(result(for: $0)).to(beTrue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("is valid for many different false expressions") {
|
||||||
|
let expected = ["NO", "no", "0"]
|
||||||
|
expected.forEach {
|
||||||
|
expect(result(for: $0)).to(beFalse())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue