I am adding a single non-consumable feature to an App. Everything works well except that the Purchase action requests sign-in, which when successful is followed almost immediately by another sign-in sheet. If the 2nd sign-in is successful, the order goes to completion, otherwise it fails. Diagnostic print trace (below) shows the UpdateTransactions Observer sees an "In Process" event as a single event in the Queue, followed by the two sign-in sheets, followed by the "Purchased" event. There is only a single item in the observer queue when called, and this all seems to occur inside Apple, end of the process. Restore function works normally. This behavior occurs locally on my device and also to my TestFlight beta folks. Does anyone know what is going on?
Buy button tapped
Entered delegate Updated transactions with n = 1 items
TransactionState = 0
Updated Tranaction: purch in process
At this point, the signin sheet appears, pw entered, then a ping and DONE is checked
a few seconds later, the signin re-appears, pw again re-entered and again success signaled on sheet
Entered delegate Updated transactions with n = 1 items
TransactionState = 1
Update Transaction : Successful Purchase
Below is the code for my fairly vanilla IAP manager class:
class IAPManager: NSObject {
// MARK: - Properties
static let shared = IAPManager()
// MARK: - Init
private override init() {
super.init()
}
//MARK: - Control methods
func startObserving() {
SKPaymentQueue.default().add(self)
}
func stopObserving() {
SKPaymentQueue.default().remove(self)
}
func canMakePayments() -> Bool {
return SKPaymentQueue.canMakePayments()
}
func peelError(err: SKError) -> String {
let userInfo = err.errorUserInfo
let usr0 = userInfo["NSUnderlyingError"] as! NSError
let usr1 = (usr0.userInfo["NSUnderlyingError"] as! NSError).userInfo
let usr2 = usr1["NSLocalizedDescription"] as? String
return usr2 ?? "\nreason not available"
}
//MARK: - Generic Alert for nonVC instances
func simpleAlert(title:String, message:String) -> Void {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
let ok = UIAlertAction(title: "OK", style: .default, handler: nil)
alert.addAction(ok)
if #available(iOS 13.0, *) {
var topWindow = UIApplication.shared.currentWindow
if topWindow == nil {
topWindow = UIApplication.shared.currentWindowInactive
}
let topvc = topWindow?.rootViewController?.presentedViewController
topvc?.present(alert, animated: false, completion: nil)
if var topController = UIApplication.shared.keyWindow?.rootViewController {
while let presentedViewController = topController.presentedViewController {
topController = presentedViewController
}
// topController.present(alert, animated: false, completion: nil)
}
} else {
var alertWindow : UIWindow!
alertWindow = UIWindow.init(frame: UIScreen.main.bounds)
alertWindow.rootViewController = UIViewController.init()
alertWindow.windowLevel = UIWindow.Level.alert + 1
alertWindow.makeKeyAndVisible()
alertWindow.rootViewController?.present(alert, animated: false)
}
}
// MARK: - Purchase Products
func buy(product:String) -> Bool {
if !canMakePayments() {return false}
let paymentRequest = SKMutablePayment()
paymentRequest.productIdentifier = product
SKPaymentQueue.default().add(paymentRequest)
return true
}
func restore() -> Void {
SKPaymentQueue.default().restoreCompletedTransactions()
}
}
// MARK: - Methods Specific to my app
func enableBigD() -> Void {
let glob = Globals.shared
let user = UserDefaults.standard
.........
//Post local notification signal that purch success/restored (to change UI in BuyView)
NotificationCenter.default.post(name: Notification.Name(rawValue: NotificationNames.kNotificationBuyDictEvent), object: nil)
}
// MARK: - SKPaymentTransactionObserver
extension IAPManager: SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
//Debugging Code
print("Entered delegate Updated transactions with n = \(transactions.count) items")
for tx in transactions {
print(" TransactionState = \(tx.transactionState.rawValue)")
}
// END debugging code
transactions.forEach { (transaction) in
switch transaction.transactionState {
case .purchased:
enableBigD()
print("Update Transaction : Successful Purchase")
SKPaymentQueue.default().finishTransaction(transaction)
simpleAlert(title: "Purchase Confirmed", message: "Thank You!")
case .restored:
print("Update Transaction : Restored Purchase")
enableBigDict()
SKPaymentQueue.default().finishTransaction(transaction)
simpleAlert(title: "Success", message: "Your access has been restored!")
case .failed:
print("Updated Tranaction: FAIL")
if let err = transaction.error as? SKError {
let reason = peelError(err: err)
simpleAlert(title: "Purchase Problem", message: "Sorry, the requested purchase did not complete.\nThe reason was: \n\(err.localizedDescription) because:\(reason)")
}
UserDefaults.standard.set(false, forKey: Keys.kHasPaidForDict)
SKPaymentQueue.default().finishTransaction(transaction)
case .deferred:
print("Updated Transaction: purch deferred ")
if let err = transaction.error as? SKError {
print("purch deferred \(err.localizedDescription)")
}
break
case .purchasing:
print("Updated Tranaction: purch in process ")
break
@unknown default:
print("Update Transaction: UNKNOWN STATE!")
break
}
}
}
func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
let err = error.localizedDescription
let reason = peelError(err: error as! SKError)
let fullerr = "The Restore request had a problem. If it persists after retrying, Please send us a note with the Code through the Feedback button in Settings. The Error Code is: \(err) because \(reason)"
simpleAlert(title: "Restore Problem", message: fullerr)
}
} //end Extension of Queue Observer
//MARK: - Extension on UIAppl to get current Window in Scene-based iOS14 environment
// https://stackoverflow.com/questions/57009283/how-get-current-keywindow-equivalent-for-multi-window-scenedelegate-xcode-11
extension UIApplication {
var currentWindow: UIWindow? {
connectedScenes
.filter(({$0.activationState == .foregroundActive}))
.map({$0 as? UIWindowScene})
.compactMap({$0})
.first?.windows
.filter({$0.isKeyWindow}).first
}
var currentWindowInactive: UIWindow? {
connectedScenes
.filter(({$0.activationState == .foregroundInactive}))
.map({$0 as? UIWindowScene})
.compactMap({$0})
.first?.windows
.filter({$0.isKeyWindow}).first
}
}
Yes, I've just been writing an article about this. (Not published yet.) Basically this is a bug in the whole way in-app purchase is presented for testing. You just have to ignore the fact that the cycle of dialogs is presented twice.
If you log the heck out of your paymentQueue(_:updatedTransactions:)
implementation (which you've already done, except you should have used OSLog instead of print
), you will find that everything there is happening correctly. It knows nothing of the double-cycle of dialogs, which all happens totally out of process.
And this issue only arises if you are a TestFlight tester or if you are using a sandbox tester account.
So, since this issue does not affect the workings of the code, and since it won't happen when a real user does it, you just have to close your eyes and carry on working. Don't worry about it, and warn your TestFlight users and tell them not to worry about it either.