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 * 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.

View File

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

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`` - ``MultiProxy``
- ``Proxy`` - ``Proxy``
### StoreKit
- ``StoreService``
- ``StoreContext``
- ``StoreServiceError``
- ``StandardStoreService``
### Validation ### Validation
- ``EmailValidator`` - ``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())
}
}
}
}
}