1

I have the usual store kit queue observer code:

func paymentQueue(_ queue: SKPaymentQueue, 
    updatedTransactions transactions: [SKPaymentTransaction]) {
        for t in transactions {
            switch t.transactionState {
            case .purchasing, .deferred: break // do nothing
            case .purchased, .restored:
                let p = t.payment
                if p.productIdentifier == myProductID {
                    // ... set UserDefaults to signify purchase ...
                    // ... put up an alert thanking the user ...
                    queue.finishTransaction(t)
                }
            case .failed:
                queue.finishTransaction(t)
            }
        }
}

The problem is what to do where I have the comment "put up an alert thanking the user". It seems simple enough: I'm creating a UIAlertController and calling present to show it. But it sometimes doesn't appear!

The trouble seems to have something to do with the fact that the runtime puts up its own alert ("You're all set"). I don't get any notice of this so I don't know this is happening. How can I cause my UIAlertController to be presented for certain?

matt
  • 447,615
  • 74
  • 748
  • 977

2 Answers2

4

The Problem

You’ve put your finger on a serious issue of timing and information with regard to in-app purchases and StoreKit.

What’s going wrong here is that you (the store observer) receive paymentQueue(_:updatedTransactions:) and at that moment two things happen simultaneously, resulting in a race condition:

  • The runtime puts up its “You’re all set” alert.

  • You try to put your UIAlertController (and kick off various other activities).

As you rightly say, you don’t get any event to tell you when the user has dismissed the runtime’s “You’re all set” alert. So how can you do something after that alert is over?

Moreover, if you try to put up your alert at the same time that the system is putting up its “You’re all set” alert, you will fail silently — your UIAlertController alert will never appear.

The Solution

The solution is to recognize that while the system’s “you’re all set” alert is up, your app is deactivated. We can detect this fact and register to be notified when your app is activated again. And that is the moment when the user has dismissed the “You’re all set” alert!

Thus it is now safe for you to put up your UIAlertController alert.

Like this (uses my delay utility, see https://stackoverflow.com/a/24318861/341994; vc is the view controller we’re going to present the alert on top of):

let alert = UIAlertController( // ...
// ... configure your alert here ...
delay(0.1) { // important! otherwise there's a race and we can get the wrong answer
    if UIApplication.shared.applicationState == .active {
        vc.present(alert, animated:true)
    } else { // if we were deactivated, present only after we are reactivated
        var ob : NSObjectProtocol? = nil
        ob = NotificationCenter.default.addObserver(
            forName: UIApplication.didBecomeActiveNotification, 
            object: nil, queue: nil) { n in
                NotificationCenter.default.removeObserver(ob as Any)
                delay(0.1) { // can omit this delay, but looks nicer
                    vc.present(alert, animated:true)
                }
            }
    }
}

I’ve tested this approach repeatedly (though with difficulty, because testing the store kit stuff works so badly), and it seems thoroughly reliable.

matt
  • 447,615
  • 74
  • 748
  • 977
  • Thanks for the post. It's interesting and makes sense to me, but I can't believe it has to be so complex. I wonder if putting the alert code in `paymentQueue(_:removedTransactions:)` would remove/reduce the chance of the race condition? (I'm implementing similar behavior but haven't finished it and am not able to test it yet). – rayx Jun 04 '20 at 03:44
  • Well @rayx be sure to let me know if you come up with a better way! You can provide an alternative answer, I'll be quite happy. But if what you're saying is that it sounds like trying to test in-app purchases is damned near impossible, I'd have to agree, and I think you'll see that that is the case. – matt Jun 04 '20 at 04:14
  • Sure. I'm adding the feature to my app, so I'll have to implement/workaround it some way. Will let you know when I get it done. – rayx Jun 04 '20 at 04:25
  • Hi @matt, could you please check this question? https://stackoverflow.com/questions/62336577/ios-a-default-alert-will-be-showed-after-purchasing-a-product-in-app-purchase – Tam-Thanh Le Jun 15 '20 at 02:42
0

Just FYI. I implemented a similar behavior in my app but I didn't observer the issue Matt described in the question. My app showed an alert to describe the failure and suggested action when a transaction failed. Below is my code:

func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
    for transaction in transactions {
        switch transaction.transactionState {
        ...
        case .failed:
            let (reason, suggestion) = parsePaymentError(error: transaction.error)
            SKPaymentQueue.default().finishTransaction(transaction)
            if let purchaseFailureHandler = self.purchaseFailureHandler {
                DispatchQueue.main.async {
                    purchaseFailureHandler(reason, suggestion)
                }
            }
        }
    }
}

I tested the code quite a few times with network connection error and user cancellation error. It worked perfectly for me.

rayx
  • 492
  • 4
  • 13