Add StoreKit utils

This commit is contained in:
Daniel Saidi 2021-11-09 11:31:25 +01:00
parent 05d92c0a85
commit 9505a4ff73
11 changed files with 469 additions and 3 deletions

View File

@ -34,6 +34,7 @@ SwiftKit is divided into the following sections:
* Messaging
* Network
* Services
* StoreKit
* Validation
You can explore the various sections in the documentation or in the demo app.

View File

@ -3,11 +3,11 @@
## 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
@ -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.
* `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

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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)" }
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -130,6 +130,13 @@ This namespace contains composer extensions.
- ``MultiProxy``
- ``Proxy``
### StoreKit
- ``StoreService``
- ``StoreContext``
- ``StoreServiceError``
- ``StandardStoreService``
### Validation
- ``EmailValidator``

View File

@ -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())
}
}
}
}
}