jamesmontemagno / InAppBillingPlugin

Cross-platform In App Billing Plugin for .NET
MIT License
651 stars 152 forks source link

Restore Purchase #6

Closed klint01 closed 7 years ago

klint01 commented 7 years ago

In my app, I am managing the availability of products outside of the store, which means I display the item to the user if it is not listed as purchased in my app database. Note: the product id in my app database and the store do match. As a result, if the app is deleted or the user installs it on another device, I want to restore purchases when they click 'buy'.

To do so, I have wrapped the PurchaseAsync inside the GetPurchasesAsync if statement as follows.

//validate purchase has not already been made
var purchases = await CrossInAppBilling.Current.GetPurchasesAsync(ItemType.InAppPurchase);
if (purchases?.Any(p => p.ProductId == productId) ?? false)
{
    //Purchase restored
    await Download(file);  //restores purchase outside of the store
}
else
{
    //no purchases found.  try to purchase item
    var purchase = await CrossInAppBilling.Current.PurchaseAsync(productId, ItemType.InAppPurchase, "apppayload");
    if (purchase == null)
    {
        //Not purchased
        await DisplayAlert("Error", "Failed to make the purchase", "OK");
    }
    else
    {
        //Purchased!
        await Download(file);
    }
}

When I run tests with via sandbox test user, it does not recognize the previous purchase, so it tries to purchase it but then prompts say "This In-App Purchase has already been bought" and the product is not restored (or in my case downloaded).

Steps: 1) previously purchased the product 2) deleted the app on my phone and redeployed it 3) click on 'buy' for previously purchased product 4) product is not restored

Any thoughts on how to resolve this issue? Thanks!

Version Number of Plugin: 1.1.03-beta Device Tested On: iPhone 6 (iOS 10.2.1)

jamesmontemagno commented 7 years ago

I believe that Sandbox users do not have previous purchases, as you can purchase them over and over and over again. I would try in TestFlight to validate.

This is the same logic that I use in my app and it worked in TestFlight, but not in sandbox.

Test that out and let me know.

klint01 commented 7 years ago

Moved the app to TestFlight, updated In-App package to latest release and experiencing the following: 1) consumable app purchased fine the first time, but does not allow for second purchase instead it says "This In-App Purchase has already been bought". 2) non-consumable app failed to download properly upon first purchase. Press buy again and get "This In-App Purchase has already been bought".

jamesmontemagno commented 7 years ago

So for iOS the Consumable and Non-Consumable are the exact same API, there is no actual difference in the API that I could tell so should be similar results

My test was to:

klint01 commented 7 years ago

I must have something screwed up in my code, because it is failing to restore (non-consumable) or purchase another (consumable) for me.

When first attempting to make non-consumable purchase, it had me log in 3-4 times before triggering the message about the purchase. In the midst of the log ins, it prompted me with "Sign in to check for pending in-app transactions. [Environment: Sandbox]. Should I still be getting Sandbox environment with Test Flight?

jamesmontemagno commented 7 years ago

Do you know if it is outputting or throwing any errors?

I am simply calling: SKPaymentQueue.DefaultQueue.RestoreCompletedTransactions(); which should return all of them back.

I have had it ask me to log in a few times.

If you see that it was already bought for a non-consumable then it should restore correctly. I assume that trying to buy a consumable should buy one as you should be able to buy as many as you need.

jamesmontemagno commented 7 years ago

A good trial here to debug the code is to download the source code and add the .csproj to your projects instead of installing the NuGet. This helped me a lot when creating the library.

klint01 commented 7 years ago

Great suggestion. It will probably be a few days before I get to it, as I am trying to debug a new issue (shown below) that is causing my app to crash, when I call OnAppearing() to refresh the view model.

Unhandled Exception: System.NullReferenceException: Object reference not set to an instance of an object at Xamarin.Forms.VisualElement.Finalize () [0x00040] in C:\BuildAgent3\work\ca3766cfc22354a1\Xamarin.Forms.Core\VisualElement.cs:794

ConsentDevelopment commented 7 years ago

Doing a clean and rebuild normally fixes the "System.NullReferenceException: Object reference not set to an instance of an object" error.

klint01 commented 7 years ago

So I resolved my prior problem. I didn't realize that Xamarin.Forms package updated to the -pre1 because the 'show pre-release versions' was checked when I installed InApp-Billing.

Anyway, with that out of the way, I've gone back to working on the original problem with no success. I was about to install the .csproj as you suggested, but then I wasn't certain how to debug if my only means to test is via TestFlight. Any feedback on that would be appreciated.

For reference, here is my code:

    // Purchase expansion deck
        async void buyClicked(object sender, EventArgs e)
        {
            // determine which deck was selected
            Button btn = (Button)sender;
            string file = (string)btn.CommandParameter;
            var productId = "whatchasayin.deck." + file.ToLower();

            bool productCheck = await CheckPurchase(productId, file); //first check if already purchased

            if (!productCheck)
            {
                try
                {
                    var connected = await CrossInAppBilling.Current.ConnectAsync();

                    if (!connected)
                    {
                        //Couldn't connect
                        await DisplayAlert("Connectivity Error", "Unable to connect to the internet.", "OK");
                        return;
                    }

                    //determine if consumable or non-consumable product
                    if (file == "Custom") // custom is a consumable product
                    {
                        var purchase = await CrossInAppBilling.Current.PurchaseAsync(productId, ItemType.InAppPurchase, "whatchasayin2017");

                        if (purchase == null)
                        {
                            //Not purchased
                            await DisplayAlert("Error", "Failed to make the purchase", "OK");
                        }
                        else
                        {
                            //Purchased!
                            for (int c = 0; c < 10; c++)
                            {
                                Phrases addPhrase = new Phrases();
                                addPhrase.ID = 0;
                                addPhrase.PhraseGroup = file + " Deck";
                                addPhrase.PhraseStatement = "";
                                addPhrase.Active = false;
                                addPhrase.Used = false;
                                App.Database.SavePhrase(addPhrase);
                            }
                            // after updating database, refresh screen and show success message
                            OnAppearing();
                            await DisplayAlert("New Custom Cards Added", "Successfully added 10 new cards to the '" + file + " Deck'.\n\n" +
                                               "Click 'Edit' for Custom Deck to add/edit your phrases and activate/deactivate cards.", "OK");
                        }

                        //If iOS we are done, else try to consume
                        if (Device.OS == TargetPlatform.iOS)
                            return;

                        var consumedItem = await CrossInAppBilling.Current.ConsumePurchaseAsync(purchase.ProductId, purchase.PurchaseToken);

                        if (consumedItem != null)
                        {
                            //Consumed!!
                            for (int c = 0; c < 10; c++)
                            {
                                Phrases addPhrase = new Phrases();
                                addPhrase.ID = 0;
                                addPhrase.PhraseGroup = file + " Deck";
                                addPhrase.PhraseStatement = "";
                                addPhrase.Active = false;
                                addPhrase.Used = false;
                                App.Database.SavePhrase(addPhrase);
                            }
                            // after updating database, refresh screen and show success message
                            OnAppearing();
                            await DisplayAlert("New Custom Cards Added", "Successfully added 10 new cards to the '" + file + " Deck'.\n\n" +
                                               "Click 'Edit' for Custom Deck to add/edit your phrases and activate/deactivate cards.", "OK");
                        }
                    }
                    else {

                        var purchase = await CrossInAppBilling.Current.PurchaseAsync(productId, ItemType.InAppPurchase, "whatchasayin2017");

                        if (purchase == null)
                        {
                            //Not purchased
                            await DisplayAlert("Error", "Failed to make the purchase", "OK");
                        }
                        else
                        {
                            //Purchased!
                            await Download(file); // download non-consumable product
                        }
                    }
                }

                catch (Exception ex)
                {
                    await DisplayAlert("Error", ex.Message, "OK");
                }

                finally
                {
                    //busy = false;
                    await CrossInAppBilling.Current.DisconnectAsync();
                }
            }
        }

        async Task<bool> CheckPurchase(string productID, string file)
        {
            try
            {
                var productId = productID;
                var connected = await CrossInAppBilling.Current.ConnectAsync();

                if (!connected)
                {
                    //Couldn't connect
                    await DisplayAlert("Connectivity Error", "Unable to connect to the internet.", "OK");
                    return true;
                }

                //check purchases
                var purchases = await CrossInAppBilling.Current.GetPurchasesAsync(ItemType.InAppPurchase);

                if (purchases?.Any(p => p.ProductId == productId) ?? false)
                {
                    //Purchase restored
                    await Download(file);
                    return true;
                }
                else
                {
                    //no purchases found
                    return false;
                }
            }
            catch (Exception ex)
            {
                await DisplayAlert("Error", ex.Message, "OK");
                return true;
            }
            finally
            {
                await CrossInAppBilling.Current.DisconnectAsync();
            }
        }

Step-by-step test: 1) click on product to purchase and/or validate if already purchased so it is restored 2) Apple Store prompt for password 3) Apple Store "Confirm Your In-App Purchase" (provides details of the product and price) - I click BUY 4) Apple Store "This In-App Purchase has already been bought. It will be restored for free." - I click OK. 5) Nothing happens on the app. This is the same for consumable and non-consumable. The non-consumable product is not re-downloaded The consumable product is not loaded or noted as purchasable again.

jamesmontemagno commented 7 years ago

I would try to debug on your device in debug mode. Just sign with signing certs for development and make sure version numbers match up.

Essentially you want to test out if the check purchase is working or now as that will restore any purchases.

What you need to try to debug through is this here: https://github.com/jamesmontemagno/InAppBillingPlugin/blob/master/src/Plugin.InAppBilling.iOS/InAppBillingImplementation.cs#L104

You need to see if transactions are restored here or what is returned.

Then check the parsing: https://github.com/jamesmontemagno/InAppBillingPlugin/blob/master/src/Plugin.InAppBilling.iOS/InAppBillingImplementation.cs#L302

jamesmontemagno commented 7 years ago

From reading: http://stackoverflow.com/questions/5628710/restorecompletedtransactions-broken

Perhaps something with the test user is bad.

Also reading:

Which makes sense as you are supposed to track consumables somewhere else like iCloud.

klint01 commented 7 years ago

Okay, realized my problem with the non-consumable. I wasn't persisting the receipt. I found the code in the programming guide. What isn't clear is where the code should be added. Does this get added to FinishedLaunching() in AppDelegate.cs or within the PCL code after purchase is confirmed?

Just to level-set, this is my first Xamarin solution, so I am learning as I go.

I am making the conclusion about the persistent receipt, because when I step through the code. tcsTransaction.TrySetResult(transactions); => zero records, but not null, therefore var purchases = await CrossInAppBilling.Current.GetPurchasesAsync(ItemType.InAppPurchase); contains no records and skips directly to no purchases found.

klint01 commented 7 years ago

@jamesmontemagno did some digging around and I am not finding some of the lines of code in the iOS version of the plugin as recommended on the Xamarin site. So wondering if that is why previous purchases are not recognized.

This line is supposed to register the purchase PhotoFilterManager.Purchase(productId);

This line is required to close out the purchase with Apple SKPaymentQueue.DefaultQueue.FinishTransaction(transaction);

I will do some adding to the code and testing to see if it makes a difference tomorrow.

public void CompleteTransaction (SKPaymentTransaction transaction) { var productId = transaction.Payment.ProductIdentifier; // Register the purchase, so it is remembered for next time PhotoFilterManager.Purchase(productId); FinishTransaction(transaction, true); }

public void FinishTransaction(SKPaymentTransaction transaction, bool wasSuccessful) { // remove the transaction from the payment queue. SKPaymentQueue.DefaultQueue.FinishTransaction(transaction); // THIS IS IMPORTANT - LET'S APPLE KNOW WE'RE DONE !!!! using (var pool = new NSAutoreleasePool()) { NSDictionary userInfo = NSDictionary.FromObjectsAndKeys(new NSObject[] {transaction},new NSObject[] {new NSString("transaction")}); if (wasSuccessful) { // send out a notification that we've finished the transaction NSNotificationCenter.DefaultCenter.PostNotificationName (InAppPurchaseManagerTransactionSucceededNotification, this, userInfo); } else { // send out a notification for the failed transaction NSNotificationCenter.DefaultCenter.PostNotificationName (InAppPurchaseManagerTransactionFailedNotification, this, userInfo); } } }

klint01 commented 7 years ago

I've had not luck in identifying the issue. After reviewing the code and documentation on Xamarin and Apple, it appears that things should be working. I created a new tester in TestFlight and still same issue. Initial InApp non-consumable purchase works fine. Delete the app, reinstall, click the item to validate if purchased and the result is nothing is restored (or I am not alerted via code to trigger the restore in my code) but yet Apple prompts with messages that purchase will be restored. I fear I am missing something very simple.

jamesmontemagno commented 7 years ago

It could be that Finish call... Odd. I will add it in and test though and confirm on Monday.

klint01 commented 7 years ago

Thank you for looking at it.

Some additional information: I added Console.WriteLine() within the foreach loop for UpdatedTransactions() to show (1) TransactionState and (2) Payment.ProductIdentifier. I found UpdatedTransactions() is called during GetProductInfoAsync, which I am using to get LocalizedPrice. My Console shows transaction state = Purchased for my InApp product (that is the product I want to restore) when it is getting the ProductInfo.

I also added Console.WriteLine() to GetPurchaesAsync to show (A) total purchases and to RestoreAsync() to show (B) the list of transactions if transactions != null.

When I run GetPurchasesAsync() to check for prior purchases, I get (A) purchases = 0 and (B) restore transactions = StoreKit.SKPaymentTransaction[].

So for some reason the resulting GetPurchases validation is empty.

klint01 commented 7 years ago

Found some more interesting details. I added the following to RestoreCompletedTransactionsFinished() just before TransactionsRestored?.Invoke(rt);

`Console.WriteLine(" ** RestoreCompletedTransactionsFinished: run = " + queue.Transactions.Count());

for (int i = 0; i < queue.Transactions.Count(); i++) Console.WriteLine(" ** RestoreCompletedTransactionsFinished: queue transaction = " + queue.Transactions[i].TransactionState + " => " + queue.Transactions[i].Payment.ProductIdentifier);`

The output showed my product in the payment queue. Matter of fact, just like the GetProductInfoAsync() my InApp products were listed 33 times (I only have two InApp products) in both Failed and Purchased states. I am assuming it is because I tried repurchasing when the restore failed. Do you think the payment queue needs to be flushed out.

klint01 commented 7 years ago

@jamesmontemagno figured it out for non-consumables. Restores are working correctly now for non-cosumable products.

1) I had to clear out the payment queue, since it was built up from past attempts. So added the following code to RestoreCompletedTransactionsFinished():

foreach (SKPaymentTransaction transaction in queue.Transactions)
       SKPaymentQueue.DefaultQueue.FinishTransaction(transaction);  // THIS IS IMPORTANT - LET'S APPLE KNOW WE'RE DONE !!!!

I removed this code once the payment queue was cleaned up.

2) Added new method FinishTransaction() within public class InAppBillingImplementation : IInAppBilling.

public void FinishTransaction(SKPaymentTransaction transaction, bool wasSuccessful)
{
    // remove the transaction from the payment queue.
    SKPaymentQueue.DefaultQueue.FinishTransaction(transaction);  // THIS IS IMPORTANT - LET'S APPLE KNOW WE'RE DONE !!!!

        using (var pool = new NSAutoreleasePool())
    {
        NSDictionary userInfo = NSDictionary.FromObjectsAndKeys(new NSObject[] { transaction }, new NSObject[] { new NSString("transaction") });
        if (wasSuccessful)
        {
            // send out a notification that we've finished the transaction
            NSNotificationCenter.DefaultCenter.PostNotificationName(InAppPurchaseManagerTransactionSucceededNotification, succeededObserver, userInfo);
        }
        else {
            // send out a notification for the failed transaction
            NSNotificationCenter.DefaultCenter.PostNotificationName(InAppPurchaseManagerTransactionFailedNotification, failedObserver, userInfo);
        }
    }
}

3) Added a call to FinishTransaction() within RestoreAsync() when transactions != null.

foreach (var t in transactions) FinishTransaction(t, true);

4) Added a call to FinishTransaction() within PurchaseAsync() for success test:

if (!success)
{
    FinishTransaction(tran, false);
    tcsTransaction.TrySetException(new Exception(tran?.Error.LocalizedDescription));
}
else {
    FinishTransaction(tran, true);
    tcsTransaction.TrySetResult(tran);
}

5) Added the following within public class InAppBillingImplementation : IInAppBilling:

public static string InAppPurchaseManagerTransactionFailedNotification = "InAppPurchaseManagerTransactionFailedNotification";
public static string InAppPurchaseManagerTransactionSucceededNotification = "InAppPurchaseManagerTransactionSucceededNotification";
public static string InAppPurchaseManagerProductsFetchedNotification = "InAppPurchaseManagerProductsFetchedNotification";
NSObject succeededObserver, failedObserver;

Next step is to test consumable products.

klint01 commented 7 years ago

Consumable products is now working correctly on iOS as well.

jamesmontemagno commented 7 years ago

Are you able to send a PR down? I can take a look and integrate. Nice work on this. Odd that it worked for me most the time. I only had 1 IAP though

jamesmontemagno commented 7 years ago

checkout: 1.1.0.15-beta

klint01 commented 7 years ago

Saw your note, sorry for the indentation variances. I will try the revised update and let you know.

jamesmontemagno commented 7 years ago

I implemented all the changes. A lot of the code that was in there for notifications isn't necessary

jamesmontemagno commented 7 years ago

I can validate things are working perfect on my test app here.

klint01 commented 7 years ago

Uninstalled .csproj and reinstalled the latest beta and everything is working perfectly on iOS.

jamesmontemagno commented 7 years ago

Thanks for your help on this one.

klint01 commented 7 years ago

Absolutely! I learned a lot. Especially with this being my first time using Xamarin, developing for iOS, and developing in C#.