Many the tutorials around StoreKit 2 and even Apple's own Sample Code reference "StoreKit testing in Xcode so you can build and run the sample app without completing any setup in App Store Connect. The project defines in-app products for the StoreKit testing server in the Products.storekit file." I have an app with auto renewing subscriptions already set up in App Store Connect (migrating to StoreKit 2 from SwiftyStoreKit)...how can I set up this StoreKitManager class to check for an active subscription without needing to make a separate Products.plist file? This code below, largely based on Apple's sample code, results in Error Domain=ASDErrorDomain Code=509 "No active account" which is obvious as I can't figure out how to connect my products to the StoreKit 2 logic? 🤔
EDIT: here is a gist of my code
import Foundation
import StoreKit
typealias Transaction = StoreKit.Transaction
public enum StoreError: Error {
case failedVerification
}
#available(watchOSApplicationExtension 8.0, *)
class WatchStoreManager: ObservableObject {
var updateListenerTask: Task<Void, Error>? = nil
init() {
print("Init called in WatchStoreManager")
//Start a transaction listener as close to app launch as possible so you don't miss any transactions.
updateListenerTask = listenForTransactions()
}
deinit {
updateListenerTask?.cancel()
}
func listenForTransactions() -> Task<Void, Error> {
return Task.detached {
//Iterate through any transactions that don't come from a direct call to `purchase()`.
for await result in Transaction.updates {
do {
let transaction = try self.checkVerified(result)
print("we have a verified transacction")
//Deliver products to the user.
//TODO:
//await self.updateCustomerProductStatus()
//Always finish a transaction.
await transaction.finish()
} catch {
//StoreKit has a transaction that fails verification. Don't deliver content to the user.
print("Transaction failed verification")
}
}
}
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
//Check whether the JWS passes StoreKit verification.
switch result {
case .unverified:
//StoreKit parses the JWS, but it fails verification.
throw StoreError.failedVerification
case .verified(let safe):
//The result is verified. Return the unwrapped value.
return safe
}
}
}
509: No active account
Means the user isn't signed in to the app store. Go to Settings -> Sign in to Your iPhone and sign in with a valid App Store or sandbox account
Edit: I see you are logged in on your device - that's strange. You mentioned you haven't linked your products. You need to fetch products from the App Store with something similar to the following. But I wouldn't expect you to see that specific error message...
enum AppProduct: String, CaseIterable, Identifiable {
case noAds = "MYUNIQUEIDENTIFIER_ESTABLISHEDIN_APPSTORECONNECT"
var id: String {
UUID().uuidString
} // to make it Identifiable for use in a List
static var allProductIds: [String] {
return Self.allCases.map { $0.rawValue }
} // convenience
}
#MainActor
#discardableResult func fetchProducts() async throws -> [Product] {
let products = try await Product.products(for: AppProduct.allProductIds) // this is the call that fetches products
guard products.first != nil else { throw PurchaseError.unknown } // custom error
self._storeProducts = products
let fetchedProducts: [AppProduct] = products.compactMap {
let appProduct = AppProduct(rawValue: $0.id)
return appProduct
}
self.fetchedProducts = fetchedProducts
try await checkPurchased()
return products
}
private func checkPurchased() async throws {
for product in _storeProducts {
guard let state = await product.currentEntitlement else { continue }
let transaction = try self.checkVerified(state)
//Always finish a transaction.
await transaction.finish()
}
}
I'm setting purchase state in checkVerified when verification passes...
Related
I want to offer non renewable subscriptions in my app. 1 month access and 3 months access. I am implementing in-app purchase functionality for non-renewable subscriptions in my app. I am able to handle new subscriptions, but I am having difficulty handling the scenario where a user has an active subscription but wants to extend it before it expires. Using Transaction.currentEntitlements only returns the latest transaction for a product, so I am unable to determine if a user has multiple transactions for the same subscription duration. I am considering using Transaction.all and filtering out invalid transactions by checking the revocationDate property, but I am unsure if this is the best approach or if there are any potential issues I should be aware of. Is it sufficient to just check transaction.revocationDate to filter out invalid transactions, or are there other considerations I should take into account? Is there anyone else with similar use cases who can provide input on this issue?
Here the filtering logic:
let transaction = try checkVerified(result)
guard transaction.revocationDate == nil else {
continue
}
here is the full function:
#MainActor
private func updateCustomerProductStatus() async {
var purchasedNonConsumables: [Purchase] = []
var purchasedNonRenewableSubscriptions: [Purchase] = []
//Iterate through all of the user's purchased products.
for await result in Transaction.all {
do {
//Check whether the transaction is verified. If it isn’t, catch `failedVerification` error.
let transaction = try checkVerified(result)
guard transaction.revocationDate == nil else {
continue
}
//Check the `productType` of the transaction and get the corresponding product from the store.
switch transaction.productType {
case .nonConsumable:
if let nonConsumable = nonConsumables.first(where: { $0.id == transaction.productID }) {
let purchase = Purchase(product: nonConsumable, transaction: transaction)
purchasedNonConsumables.append(purchase)
}
case .nonRenewable:
if let nonRenewable = nonRenewables.first(where: { $0.id == transaction.productID }) {
//Non-renewing subscriptions have no inherent expiration date, so they're always
//contained in `Transaction.currentEntitlements` after the user purchases them.
//This app defines this non-renewing subscription's expiration date to be one year after purchase.
//If the current date is within one year of the `purchaseDate`, the user is still entitled to this
//product.
let numberOfMonths = transaction.productID == StoreProduct.threeMonthsSubscription.identifier ? 3 : 1
let currentDate = Date()
let expirationDate = Calendar(identifier: .gregorian).date(byAdding: DateComponents(month: numberOfMonths),
to: transaction.purchaseDate)!
let purchase = Purchase(product: nonRenewable, transaction: transaction)
purchasedNonRenewableSubscriptions.append(purchase)
}
default:
break
}
} catch {
print()
}
}
//Update the store information with the purchased products.
self.purchasedNonConsumables = purchasedNonConsumables
self.purchasedNonRenewableSubscriptions = purchasedNonRenewableSubscriptions
}
I offer subscriptions in my iOS app. If the app runs on iOS 15 or later, I use StoreKit 2 to handle subscription starts and renewals. My implementation closely follows Apple's example code.
A very small fraction of my users (<1%) report that their active subscription is not recognized - usually after a renewal (Starting a new subscription seems to always work). It appears as if no StoreKit transactions are showing up for the renewals.
After some troubleshooting I found out:
Force quitting and restarting the app never helps.
A call to AppStore.sync() never helps.
Restarting the device helps for some but not for all users.
Deleting and re-downloading the app from the App Store always works.
I could never reproduce this bug on my devices.
Here's the gist of my implementation: I have a StoreManager class that handles all interactions with StoreKit. After initialization, I immediately iterate over Transaction.all to obtain the user's complete purchase history, and also start a task that listens for Transaction.updates. PurchasedItem is a custom struct that I use to group all relevant information about a transaction. Purchased items are collected in the dictionary purchasedItems, where I use the transactions' identifiers as keys. All writes to that dictionary only happen in the method updatePurchasedItemFor() which is bound to the MainActor.
class StoreManager {
static let shared = StoreManager()
private var updateListenerTask: Task<Void, Error>? = nil
init() {
updateListenerTask = listenForTransactions()
loadAllTransactions()
}
func loadAllTransactions() {
Task { #MainActor in
for await result in Transaction.all {
if let transaction = try? checkVerified(result) {
await updatePurchasedItemFor(transaction)
}
}
}
}
func listenForTransactions() -> Task<Void, Error> {
return Task(priority: .background) {
// Iterate through any transactions which didn't come from a direct call to `purchase()`.
for await result in Transaction.updates {
do {
let transaction = try self.checkVerified(result)
// Deliver content to the user.
await self.updatePurchasedItemFor(transaction)
// Always finish a transaction.
await transaction.finish()
} catch {
//StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user.
Analytics.logError(error, forActivity: "Verification on transaction update")
}
}
}
private(set) var purchasedItems: [UInt64: PurchasedItem] = [:]
#MainActor
func updatePurchasedItemFor(_ transaction: Transaction) async {
let item = PurchasedItem(productId: transaction.productID,
originalPurchaseDate: transaction.originalPurchaseDate,
transactionId: transaction.id,
originalTransactionId: transaction.originalID,
expirationDate: transaction.expirationDate,
isInTrial: transaction.offerType == .introductory)
if transaction.revocationDate == nil {
// If the App Store has not revoked the transaction, add it to the list of `purchasedItems`.
purchasedItems[transaction.id] = item
} else {
// If the App Store has revoked this transaction, remove it from the list of `purchasedItems`.
purchasedItems[transaction.id] = nil
}
NotificationCenter.default.post(name: StoreManager.purchasesDidUpdateNotification, object: self)
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
// Check if the transaction passes StoreKit verification.
switch result {
case .unverified(_, let error):
// StoreKit has parsed the JWS but failed verification. Don't deliver content to the user.
throw error
case .verified(let safe):
// If the transaction is verified, unwrap and return it.
return safe
}
}
}
To find out if a user is subscribed, I use this short method, implemented elsewhere in the app:
var subscriberState: SubscriberState {
for (_, item) in StoreManager.shared.purchasedItems {
if let expirationDate = item.expirationDate,
expirationDate > Date() {
return .subscribed(expirationDate: expirationDate, isInTrial: item.isInTrial)
}
}
return .notSubscribed
}
All this code looks very simple to me, and is very similar to Apple's example code. Still, there's a bug somewhere and I cannot find it.
I can imagine that it's one of the following three issues:
I misunderstand how Swift actors and async/await work, and there is a race condition.
I misunderstand how StoreKit 2 transactions work. For example, I currently assume that a subscription renewal transaction has its own, unique identifier, which I can use as a key to collect it in the dictionary.
There actually is a bug in StoreKit 2, some transactions are in fact missing and the bug is not in my code.
To rule out 3., I have submitted a TSI request at Apple. Their response was, essentially: You are expected to use Transaction.currentEntitlements instead of Transaction.all to determine the user's current subscription state, but actually this implementation should also work. If it doesn't please file a bug.
I am using Transaction.all because I need the complete transaction history of the user to customize messaging and special offers in the app, not only to decide if the user has an active subscription or not. So I filed a bug, but haven't received any response yet.
I'm following the AWS Amplify tutorials on API and Authentication. While I've made progress to get both working (!), it's not clear to me how I might connect the two features of Amplify so as to separate/segregate (?) user data...
For instance, take this GraphQL Todo Model:
type Todo #model #auth(rules: [{allow: public}]) {
id: ID!
name: String!
description: String
completed: Boolean!
}
I can save and fetch these Todos with a ViewModel:
import Amplify
class TodoListViewModel: ObservableObject {
#Published var todos = [Todo]()
#Published var completedTodos = [Todo]()
func loadToDos() {
Amplify.DataStore.query(Todo.self) { result in
switch result {
case .success(let todos):
self.todos = todos.filter { !$0.completed }
self.completedTodos = todos.filter { $0.completed }
case .failure(let error):
print("Could not query DataStore: \(error)")
}
}
}
func createTodo(name: String, description: String?) {
let item = Todo(name: name, description: description, completed: false)
todos.append(item)
Amplify.DataStore.save(item) { result in
switch result {
case .success(let savedItem):
print("Saved item: \(savedItem.name)")
case .failure(let error):
print("Could not save item with error: \(error)")
}
}
}
}
But these methods seemingly allow any user to access any other users Todo data (?)
Reading through the docs, I think I need to setup authorization rules (?)
If I'm reading this correctly, to make sure that an arbitrary user can only see their data, is it really as simple as changing the GraphQL Todo model to:
type Todo #model #auth(rules: [{allow: owner}]) {
id: ID!
name: String!
description: String
completed: Boolean!
}
That can't be it?
...what other modifications will I need to implement in order to ensure that "Alice" can save and fetch her data and be sure that I'm not mixing it with "Bob's" data?
A fully worked example that uses an Authenticated (logged-in) user would be appreciated!
It really is that simple. That's the value of using Amplify. Every record that is saved to the database has a column called owner. The value of owner is the Id of the Cognito user that created the record.
AppSync's auto-generated resolvers are smart enough to verify that the user asking for data is the same user that owns the data.
I have a strange bug that is occurring only on few user iPhones, details below -
The app consumes a universal framework (developed by ourself) to save accessToken and refreshToken after successful login to the Keychain. We are using Locksmith to achieve the functionality - Save, load data and delete when the user is logged out.
Everytime when the app is killed and launched or applicationWillEnterForeground, the tokens are refreshed with the help of a service call and are saved to keychain again. When the refreshToken expires (this token is valid for one month), user is notified that the app has not been used for a long time and he is logged out.
The actual problem is here that for only few users, the refresh mechanism fails even when they are using the app daily (i.e. not before completion of one month of the refreshToken). After verification with backend team, the refresh service is always up so I suspect the Locksmith loadDataForUserAccount but unable to reproduce the issue. Also, may users do NOT face the problem. Everything works normally as expected.
Can someone help me move further how to identify the cause?
Below is the code to refresh the accessToken and refreshToken
** Refresh token call From the App when app enters foreground or killed and launched**
if let mySession = ServiceLayer.sharedInstance.session {
mySession.refresh { result in
switch result {
case .failure(.authenticationFailure):
if isBackgroundFetch {
print("👤⚠️ Session refresh failed, user is now logged out.")
self.myService.logoutCurrentUser()
// Logout Current user
mySession.invalidate()
self.showLoginUI()
}
else {
// user accessToken is invalid but provide access to QR
// on the home screen. disable all other actions except logout button
self.showHomeScreen()
}
default:
mySession.getAccessToken { result in
switch result {
case let .success(value):
print("Access Token from App Delegate \(value)")
myAccessToken = value
case let .failure(error):
print("❌ Failed to fetch AccessToken: \(error)")
}
}
}
}
}
From the framework where the refresh mechanism is implemented
public func refresh(_ completion: #escaping (MyResult<String, MyError>) -> (Void)) {
guard isValid else {
completion(.failure(.invalidSession))
return
}
getRefreshToken { result in
switch result {
case let .success(refreshToken):
// Get new tokens.
ServiceClient.requestJSON(ServiceRequest.refreshToken(refreshToken: refreshToken)) { result in
switch result {
case let .success(dictionary):
var newAccessToken: String?
var newRefreshToken: String?
for (key, value) in dictionary {
if key as! String == "access_token" {
newAccessToken = value as? String
}
if key as! String == "refresh_token" {
newRefreshToken = value as? String
}
}
guard newAccessToken != nil && newRefreshToken != nil else {
completion(.failure(.general))
return
}
print("Renewed session tokens.")
do {
try Locksmith.updateData(data: [MySession.accessTokenKeychainKey: newAccessToken!, MySession.refreshTokenKeychainKey: newRefreshToken!],
forUserAccount: MySession.myKeychainAccount)
}
catch {
completion(.failure(.general))
}
completion(.success(newAccessToken!))
case let .failure(error):
if error == MyError.authenticationFailure {
print(“Session refresh failed due to authentication error; invalidating session.")
self.invalidate()
}
completion(.failure(error))
}
}
case let .failure(error):
completion(.failure(error))
}
}
}
The app is likely being launched in the background while the device is locked (for app refresh or other background mode you've configured). Protected data (including Keychain) is not necessarily available at that time. You can check UIApplication.isProtectedDataAvailable to check if it's available, and you can reduce the protection of the item to kSecAttrAccessibleAfterFirstUnlock in order to have background access more reliably (though not 100% promised, even in that mode). Locksmith calls this AfterFirstUnlock.
I execute an API call in Firebase for retrieving the user profile information and storing it in a ViewController member variable.
The API is declared as a static function inside a class MyApi:
// Get User Profile
static func getUserProfile(byID userId:String,response:#escaping (_ result:[User]?,_ error:Error?)->()) {
// check ID is valid
guard userId.length > 0 else {
print("Error retrieving Creator data: invalid user id provided")
response(nil,ApiErrors.invalidParameters)
return
}
// retrieve profile
let profilesNode = Database.database().reference().child(MyAPI.profilesNodeKey)
profilesNode.child(userId).observe(.value, with: { (snapshot) in
// check if a valid data structure is returned
guard var dictionary = snapshot.value as? [String:AnyObject] else {
print("Get User Profile API: cannot find request")
response([],nil)
return
}
// data mapping
dictionary["key"] = userId as AnyObject
guard let user = User(data:dictionary) else {
print("Get User Profile API: error mapping User profile data")
response(nil,ApiErrors.mappingError)
return
}
response([user], nil)
}) { (error) in
response(nil,ApiErrors.FirebaseError(description: error.localizedDescription))
}
}
and I call it like that:
MyAPI.getUserProfile(byID: creatorId) { (profiles, error) in
guard let profiles = profiles, profiles.count > 0 else {
Utility.showErrorBanner(message: "Error retrieving Creator profile")
print("Error retrieving creator profile ID:[\(creatorId)] \(String(describing: error?.localizedDescription))")
return
}
self.currentProfile = profiles.first!
}
The ViewController is called in Modal mode so it should be deallocated every time I exit the screen.
Problem: a huge chunk of memory get allocated when I enter the screen, but it doesn't get freed up when I leave it. I'm sure about this because the problem doesn't appear if I remove the line self.currentProfile = profiles.first! (obviously)
How can I avoid this from happening?
NOTE: currentProfile is of type User, which was used to be a struct. I made it a class so I could use a weak reference for storing the information:
weak var currentCreator: User? {
didSet {
updateView()
}
}
but the problem still persists.
You are adding an observer:
profilesNode.child(userId).observe(...)
But you never remove it. As long as that observe is still added, it will hold on to memory from the entire set of results, and continually retrieve new updates. It's a really bad practice not to remove your observers.
If you want to read data just a single time, there is a different API for that using observeSingleEvent.