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.
Related
This is how I define fetching changes:
func fetchAllChanges(isFetchedFirstTime: Bool) {
let zone = CKRecordZone(zoneName: "fieldservice")
let options = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
options.previousServerChangeToken = Token.privateZoneServerChangeToken //initially it is nil
let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: [zone.zoneID], configurationsByRecordZoneID: [zone.zoneID: options])
operation.fetchAllChanges = isFetchedFirstTime
operation.database = CloudAssistant.shared.privateDatabase
// another stuff
}
When I fetch all of them first time, then fetchAllChanges is false. So I only get server change token and save it for another use. No changes for records is returned. And it is ok;)
The problem is when I try to fetch it SECOND TIME. Since then nothing changed, server change token is not nil now, but fetchAllChanges is true because I need all the changes since first fetch (last server change token). It should work like this in my opinion.
But the SECOND TIME I got ALL THE CHANGES from my cloudkit (a few thousands of records and alll the changes). Why? I thought I told cloudkit that I do not want it like this. What am I doing wrong?
I have implemented #vadian answer, but my allChanges is always empty. Why?
func fetchPrivateLatestChanges(handler: ProgressHandler?) async throws -> ([CKRecord], [CKRecord.ID]) {
/// `recordZoneChanges` can return multiple consecutive changesets before completing, so
/// we use a loop to process multiple results if needed, indicated by the `moreComing` flag.
var awaitingChanges = true
var changedRecords = [CKRecord]()
var deletedRecordIDs = [CKRecord.ID]()
let zone = CKRecordZone(zoneName: "fieldservice")
while awaitingChanges {
/// Fetch changeset for the last known change token.
print("🏆TOKEN: - \(lastChangeToken)")
let allChanges = try await privateDatabase.recordZoneChanges(inZoneWith: zone.zoneID, since: lastChangeToken)
/// Convert changes to `CKRecord` objects and deleted IDs.
let changes = allChanges.modificationResultsByID.compactMapValues { try? $0.get().record }
print(changes.count)
changes.forEach { _, record in
print(record.recordType)
changedRecords.append(record)
handler?("Fetching \(changedRecords.count) private records.")
}
let deletetions = allChanges.deletions.map { $0.recordID }
deletedRecordIDs.append(contentsOf: deletetions)
/// Save our new change token representing this point in time.
lastChangeToken = allChanges.changeToken
/// If there are more changes coming, we need to repeat this process with the new token.
/// This is indicated by the returned changeset `moreComing` flag.
awaitingChanges = allChanges.moreComing
}
return (changedRecords, deletedRecordIDs)
}
And here is what is repeated on console:
🏆TOKEN: - nil
0
🏆TOKEN: - Optional(<CKServerChangeToken: 0x1752a630; data=AQAAAAAAAACXf/////////+L6xlFzHtNX6UXeP5kslOE>)
0
🏆TOKEN: - Optional(<CKServerChangeToken: 0x176432f0; data=AQAAAAAAAAEtf/////////+L6xlFzHtNX6UXeP5kslOE>)
0
🏆TOKEN: - Optional(<CKServerChangeToken: 0x176dccc0; data=AQAAAAAAAAHDf/////////+L6xlFzHtNX6UXeP5kslOE>)
0
... ...
This is how I use it:
TabView {
//my tabs
}
.tabViewStyle(PageTabViewStyle())
.task {
await loadData()
}
private func loadData() async {
await fetchAllInitialDataIfNeeded { error in
print("FINITO>>🏆")
print(error)
}
}
private func fetchAllInitialDataIfNeeded(completion: #escaping ErrorHandler) async {
isLoading = true
do {
let sthToDo = try await assistant.fetchPrivateLatestChanges { info in
self.loadingText = info
}
print(sthToDo)
} catch let error as NSError {
print(error.localizedDescription)
}
Assuming you have implemented also the callbacks of CKFetchRecordZoneChangesOperation you must save the token received by the callbacks permanently for example in UserDefaults.
A smart way to do that is a computed property
var lastChangeToken: CKServerChangeToken? {
get {
guard let tokenData = UserDefaults.standard.data(forKey: Key.zoneChangeToken) else { return nil }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData)
}
set {
if let token = newValue {
let tokenData = try! NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
UserDefaults.standard.set(tokenData, forKey: Key.zoneChangeToken)
} else {
UserDefaults.standard.removeObject(forKey: Key.zoneChangeToken)
}
}
}
The struct Key is for constants, you can add more keys like the private subscription ID etc.
struct Key {
let zoneChangeToken = "zoneChangeToken"
}
Secondly I highly recommend to use the async/await API to fetch the latest changes because it get's rid of the complicated and tedious callbacks.
As you have a singleton CloudAssistant implement the method there and use a property constant for the zone. In init initialize the privateDatabase and also the zone properties.
This is the async/await version of fetchLatestChanges, it returns the new records and also the deleted record IDs
/// Using the last known change token, retrieve changes on the zone since the last time we pulled from iCloud.
func fetchLatestChanges() async throws -> ([CKRecord], [CKRecord.ID]) {
/// `recordZoneChanges` can return multiple consecutive changesets before completing, so
/// we use a loop to process multiple results if needed, indicated by the `moreComing` flag.
var awaitingChanges = true
var changedRecords = [CKRecord]()
var deletedRecordIDs = [CKRecord.ID]()
while awaitingChanges {
/// Fetch changeset for the last known change token.
let allChanges = try await privateDatabase.recordZoneChanges(inZoneWith: zone, since: lastChangeToken)
/// Convert changes to `CKRecord` objects and deleted IDs.
let changes = allChanges.modificationResultsByID.compactMapValues { try? $0.get().record }
changes.forEach { _, record in
changedRecords.append(record)
}
let deletetions = allChanges.deletions.map { $0.recordID }
deletedRecordIDs.append(contentsOf: deletetions)
/// Save our new change token representing this point in time.
lastChangeToken = allChanges.changeToken
/// If there are more changes coming, we need to repeat this process with the new token.
/// This is indicated by the returned changeset `moreComing` flag.
awaitingChanges = allChanges.moreComing
}
return (changedRecords, deletedRecordIDs)
}
I believe you misunderstand how this works. The whole point of passing a token to the CKFetchRecordZoneChangesOperation is so that you only get the changes that have occurred since that token was set. If you pass nil then you get changes starting from the beginning of the lifetime of the record zone.
The fetchAllChanges property is very different from the token. This property specifies whether you need to keep calling a new CKFetchRecordZoneChangesOperation to get all of the changes since the given token or whether the framework does it for you.
On a fresh install of the app you would want to pass nil for the token. Leave the fetchAllChanges set to its default of true. When the operation runs you will get every change ever made to the record zone. Use the various completion blocks to handle those changes. In the end you will get an updated token that you need to save.
The second time you run the operation you use the last token you obtained from the previous run of the operation. You still leave fetchAllChanges set to true. You will now get only the changes that may have occurred since the last time you ran the operation.
The documentation for CKFetchRecordZoneChangesOperation shows example code covering all of this.
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
}
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...
Objective:
I would like to redeem a subscription offer while an autorenewable subscription is currently active and not expired.
What I've tried:
I set up a Store Kit demo project on Xcode 10.2 in order to test out the new subscription offers. After following the WWDC 2019 keynotes and much troubleshooting I was able to achieve the following:
Successfully set up a 1-month autorenewable subscription using StoreKit.
Hit the /verifyReceipt endpoint and successfully decrypted the receipt data.
Set up local server and successfully generated signature.
Set up subscription offer (1 month free) on Appstore Connect.
Successfully redeemed subscription offer after renewable subscription has expired.
My current issue:
Whenever I try to claim the subscription offer and my autorenewable subscription is still active, I get the following error from StoreKit:
Error Domain=SKErrorDomain Code=0 "Cannot connect to iTunes Store" UserInfo={NSLocalizedDescription=Cannot connect to iTunes Store}
Surprisingly, however, an alert box pops up upon trying to claim the offer saying "You're all set. Your purchase was successful. [Environment: Sandbox]" despite getting the above error message in the logs.
According to the Apple Documentation on SKErrorDomain (link: https://developer.apple.com/documentation/storekit/skerror/code), Code 0 is an "Unknown Error". This is the brick wall I've hit.
During the development process of this demo project, I received other error codes, such as ErrorCode=12, which means "invalid signature", which I resolved, so I'm sure there is nothing wrong with the signature. Check out my setup below for more context.
I urge you to disregard my non-optimal coding practices here. I hacked this together as a proof of concept, not for production.
This is the action for tapping the 'Claim Reward' button, which tries to redeem the subscription offer:
// PrimaryVC.swift
#IBAction func aClaimReward(_ sender: UIButton) {
// Hard code offer information
let username = "mail#mysandbox.com" // sandbox username
guard let usernameData = username.data(using: .utf8) else { return }
let usernameHash = usernameData.md5().toHexString()
// Enums for simplifying the product identifiers.
let productIdentifier = IAPProduct.autoRenewable.rawValue
let offerIdentifier = IAPProduct.reward.rawValue
// Call prepare offer method and get discount in completion block
IAPService.shared.prepareOffer(usernameHash: usernameHash, productIdentifier: productIdentifier, offerIdentifier: offerIdentifier) { (discount) in
// Find the autorenewable subscription in products set
guard let product = IAPService.shared.products.filter({ $0.productIdentifier == IAPProduct.autoRenewable.rawValue }).first else {
return
}
// Complete transaction
self.buyProduct(product: product, forApplicationUsername: usernameHash, withOffer: discount)
}
}
This is the code for the buyProduct() method, used at the end of the action above:
// PrimaryVC.swift
func buyProduct(product: SKProduct, forApplicationUsername usernameHash: String, withOffer offer: SKPaymentDiscount) {
// Create payment object
let payment = SKMutablePayment(product: product)
// Apply username and offer to the payment
payment.applicationUsername = usernameHash
payment.paymentDiscount = offer
// Add payment to paymentQueue
IAPService.shared.paymentQueue.add(payment)
}
I created an In-app purchases service class where much of the in-app purchase logic lives. This singleton is used in the button action detailed above, and the method used there follows:
// IAPService.swift
import SwiftyJSON
import Alamofire
// ...
func prepareOffer(usernameHash: String, productIdentifier: String, offerIdentifier: String, completion: #escaping (SKPaymentDiscount) -> Void) {
// Create parameters dictionary
let parameters: Parameters = [
"appBundleID": "my.bundle.id",
"productIdentifier": productIdentifier, // "my.product.id",
"offerID": offerIdentifier, // "REFERRALBONUSMONTH"
"applicationUsername": usernameHash
]
// Generate new signature by making get request to local server.
// I used the starter code from the wwdc2019 lecture on subscription offers
AF.request("https://mylocalserver/offer", parameters: parameters).responseJSON { response in
var signature: String?
var keyID: String?
var timestamp: NSNumber?
var nonce: UUID?
switch response.result {
case let .success(value):
let json = JSON(value)
// Get required parameters for creating offer
signature = json["signature"].stringValue
keyID = json["keyID"].stringValue
timestamp = json["timestamp"].numberValue
nonce = UUID(uuidString: json["nonce"].stringValue)
case let .failure(error):
print(error)
return
}
// Create offer
let discountOffer = SKPaymentDiscount(identifier: offerIdentifier, keyIdentifier: keyID!, nonce: nonce!, signature: signature!, timestamp: timestamp!)
// Pass offer in completion block
completion(discountOffer)
}
}
Conclusion:
According to the WWDC2019 subscription offers lecture, users should be able to redeem a subscription offer even during an active subscription, but I continue to get SKErrorCode=0 when I try to redeem a subscription offer during an active subscription. I am able to redeem the subscription after the autorenewable subscription has expired, and I have verified the receipt and have seen the data for the subscription offer on the receipt.
Any ideas on where I might be going wrong? Or is this an issue on Apple's side?
I have an app that uses CloudKit and everything worked perfectly until iOS 11.
In previous iOS versions, I used a CKQuerySubscription with NSPredicate to receive notifications whenever a user changes a specific table, matching the NSPredicate properties.
After doing so, whenever the server sent notifications, I would iterate through them, fetching its changes and afterwards marking them as READ so I would parse through them again (saving a serverToken).
Now in iOS 11 , Xcode informs me that those delegates are deprecated, and I should change them, but this is where I'm having trouble with - I cannot figure out how to do it in the non-deprecated way, for iOS 11.
Here's my code:
Saving a subscription
fileprivate func setupCloudkitSubscription() {
let userDefaults = UserDefaults.standard
let predicate = NSPredicate(format: /*...*/) // predicate here
let subscription = CKQuerySubscription(recordType: "recordType", predicate: predicate, subscriptionID: "tablename-changes", options: [.firesOnRecordUpdate, .firesOnRecordCreation])
let notificationInfo = CKNotificationInfo()
notificationInfo.shouldSendContentAvailable = true // if true, then it will push as a silent notification
subscription.notificationInfo = notificationInfo
let publicDB = CKContainer.default().publicCloudDatabase
publicDB.save(subscription) { (subscription, err) in
if err != nil {
print("Failed to save subscription:", err ?? "")
return
}
}
}
Check for pending notifications
fileprivate func checkForPendingNotifications() {
let serverToken = UserDefaults.standard.pushNotificationsChangeToken
let operation = CKFetchNotificationChangesOperation(previousServerChangeToken: serverToken)
var notificationIDsToMarkRead = [CKNotificationID]()
operation.notificationChangedBlock = { (notification) -> Void in
if let notificationID = notification.notificationID {
notificationIDsToMarkRead.append(notificationID)
}
}
operation.fetchNotificationChangesCompletionBlock = {(token, err) -> Void in
if err != nil {
print("Error occured fetchNotificationChangesCompletionBlock:", err ?? "")
print("deleting existing token and refetch pending notifications")
UserDefaults.standard.pushNotificationsChangeToken = nil
return
}
let markOperation = CKMarkNotificationsReadOperation(notificationIDsToMarkRead: notificationIDsToMarkRead)
markOperation.markNotificationsReadCompletionBlock = { (notificationIDsMarkedRead: [CKNotificationID]?, operationError: Error?) -> Void in
if operationError != nil {
print("ERROR MARKING NOTIFICATIONS:", operationError ?? "")
return
}
}
let operationQueue = OperationQueue()
operationQueue.addOperation(markOperation)
if token != nil {
UserDefaults.standard.pushNotificationsChangeToken = token
}
}
let operationQueue = OperationQueue()
operationQueue.addOperation(operation)
}
As you can see, the code above works perfectly on iOS until 11;
Now Xcode prompts warning on the following lines:
let operation = CKFetchNotificationChangesOperation(previousServerChangeToken: serverToken)
Warning:
'CKFetchNotificationChangesOperation' was deprecated in iOS 11.0: Instead of iterating notifications to enumerate changed record zones, use CKDatabaseSubscription, CKFetchDatabaseChangesOperation, and CKFetchRecordZoneChangesOperation
AND
let markOperation = CKMarkNotificationsReadOperation(notificationIDsToMarkRead: notificationIDsToMarkRead)
Warning:
'CKMarkNotificationsReadOperation' was deprecated in iOS 11.0: Instead of iterating notifications, consider using CKDatabaseSubscription, CKFetchDatabaseChangesOperation, and CKFetchRecordZoneChangesOperation as appropriate
I tried applying CKDatabaseSubscription but by doing so I cannot apply a NSPredicate to filter the subscription as I can do with CKQuerySubscription, and if I try to fetch and mark pending notifications
as Read it shows those warnings.
What's the best approach for iOS 11 in this case? Any hint?
Thank you.
So as I've mentioned in the comment above in the openradar bug report , which can be found here this is a known issue in iOS 11 when fetching changes for public records and then mark those changes as read, saving the given token.
As there's not a true solution for this issue, because Apple hasn't gave a workaround for this, or maybe not marking those delegate-functions as deprecated until a solution is given I had to go through a different path, which was the follwowing:
I had to create a custom CKRecordZone in the Private Database, then I had to subscribe database changes in that zoneID, by doing so, whenever the user changes something in that database the desired push-notifications and/or silent-push-notifications fire as expected and then I can parse the new data.
My issue here is that I had the User Profile in a public database, so whenever the user changed something related to him (Name, Bio, etc) it saved in CloudKit and silent notifications would fire to the user's other devices to update this data - this I can do perfectly with the private database as well - but my problem was that other users could search for app-users to follow-unfollow them and if that data is stored in the private-database it will be out of general users search scope.
In order to overcome this I had to semi-duplicate the User Profile data.
The user fetches and edits its data through the private-database, and on save it also update a semi-related table in the public-database so it is available to search for general-users.
Until Apple allows us to fetch changes from public-database as we used to do in iOS 10 this solution will work for me temporarily.