Search code examples
iosswiftin-app-purchaseios14

iOS 14 In-App Purchase requests sign-in / validation twice


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

Solution

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