Search Unity

[Solved] Issue: After purchasing a Google Play Non-Consumable, reboots trigger OnPurchaseFailed

Discussion in 'Unity IAP' started by Keepabee, Feb 22, 2017.

Thread Status:
Not open for further replies.
  1. Keepabee

    Keepabee

    Joined:
    Jul 12, 2012
    Posts:
    58
    CASE DETAILS

    Editor Unity 5.5.1p4
    Editor on OS X 10.11.6
    Unity IAP version 1.10.0 - 2017-01-23
    Android OS version 6.0.1

    Google Play app published
    Google Play alpha tester email-list setup
    Alpha-tester email Google account active on Android phone
    Alpha-tester opt-in link opened on Android device and subscribed as alpha-tester
    Products setup on Google Play app In-App Products page.

    ISSUE DESCRIPTION
    • On Google Play the Unity IAP runs restore purchases automatically each time the game starts.
    • Non-Consumable purchase is handled and ProcessPurchase method returns PurchaseProcessingResult.Complete as universally instructed in documentation for local purchase handling.
    • After Non-Consumable was bought on previous run of the game, Unity IAP attempts to reprocess the purchase of the same Non-Consumable on next boot.
    • "Repurchasing" a Non-Consumable triggers OnPurchaseFailed with error "DUPLICATETRANSACTION".
    • Since there is no way to distinguish this "automatic restore re-purchase" from a player-initiated purchase, an error dialog is displayed by my game for the player to see as it would be when a normal purchase fails. Seeing this on every store initialization/game bootup is a terrible experience for the player.
    Is this a bug? Is it intended to report "failed purchases" on every initialization if anyone ever owns Non-Consumable IAPs?

    Also all tutorials and documentation heavily lean on just running through a quick-and-dirty, hard-coded, simplest case of Consumables and pretty much skip Non-Consumables; this was also reflected in a high number of unanswered forum post questions on non-consumables. Maybe I'm missing some undocumented trick devs are intended to use when dealing with Non-Consumables?
     
    TastyRook likes this.
  2. ap-unity

    ap-unity

    Unity Technologies

    Joined:
    Aug 3, 2016
    Posts:
    1,519
    @Meatgrind,

    Would you be able to provide a copy of your purchase script?
     
  3. Keepabee

    Keepabee

    Joined:
    Jul 12, 2012
    Posts:
    58
    Certainly!

    I won't be able to share all dependencies, so this is more for reading than running, but if you need to run this in tests just replace all dialog code with Debug.Logs and the IAP Inventory code with some PlayerPrefs replacement.

    I've added pieces of code commented with "// HACK" to work around this temporarily, but I still feel strongly that there should be some proper way and the issue itself is a bug and not the way I expect Unity IAP to work. If you'd leave out the HACK parts the error dialog would pop up every time the game starts after a non-consumable has been

    IAP store implementation:
    Code (CSharp):
    1. using UnityEngine;
    2. using System.Collections.Generic;
    3. using UnityEngine.Purchasing;
    4.  
    5. public class TuokioIAP : IStoreListener
    6. {
    7.     private const string GOOGLE_PLAY_PUBLIC_API_KEY = "I also think the whole SetPublicKey is badly documented and I am not sure it's too wise to have the API key in source code";
    8.  
    9.     public const string PARTIAL_ID_COINPACK_SMALL = "coins.small";
    10.     public const string PARTIAL_ID_COINPACK_MEDIUM = "coins.medium";
    11.     public const string PARTIAL_ID_COINPACK_LARGE = "coins.large";
    12.  
    13.     public const int COINS_COINPACK_SMALL = 2150;
    14.     public const int COINS_COINPACK_MEDIUM = 12500;
    15.     public const int COINS_COINPACK_LARGE = 55000;
    16.  
    17.     public static event System.Action initializationCompleteEvent;
    18.     public static event System.Action<string> purchaseCompleteEvent;
    19.  
    20.     public static TuokioIAP Instance { get; private set; }
    21.  
    22.     private IStoreController controller;
    23.     private IExtensionProvider extensions;
    24.  
    25.     public static bool isInitialized { get; private set; }
    26.  
    27.     private ProductCatalog catalog;
    28.  
    29.     public static void Initialize()
    30.     {
    31.         if ( Instance != null )
    32.         {
    33.             return;
    34.         }
    35.  
    36.         Instance = new TuokioIAP();
    37.     }
    38.  
    39.     public static Product getProduct(string _productId)
    40.     {
    41.         return (Instance != null && Instance.controller != null ) ? Instance.controller.products.WithID(_productId) : null;
    42.     }
    43.  
    44.     public static ProductCatalogItem getCatalogProduct(string _productId)
    45.     {
    46.         if ( Instance != null && Instance.catalog != null )
    47.         {
    48.             foreach ( ProductCatalogItem product in Instance.catalog.allProducts )
    49.             {
    50.                 if ( product.id == _productId )
    51.                 {
    52.                     return product;
    53.                 }
    54.             }
    55.  
    56.             return null;
    57.         }
    58.         else
    59.         {
    60.             return null;
    61.         }
    62.     }
    63.  
    64.     private TuokioIAP()
    65.     {
    66.         if ( Instance != null )
    67.         {
    68.             Debug.LogError("Duplicate TuokioIAP created!");
    69.             return;
    70.         }
    71.  
    72.         Instance = this;
    73.  
    74.         catalog = ProductCatalog.LoadDefaultCatalog();
    75.  
    76.         StandardPurchasingModule module = StandardPurchasingModule.Instance();
    77.         module.useFakeStoreUIMode = FakeStoreUIMode.StandardUser;
    78.  
    79.         ConfigurationBuilder builder = ConfigurationBuilder.Instance(module);
    80.         builder.useCloudCatalog = false;
    81.         builder.Configure<IGooglePlayConfiguration>().SetPublicKey(GOOGLE_PLAY_PUBLIC_API_KEY);
    82.  
    83.         foreach ( var product in catalog.allProducts )
    84.         {
    85.             if ( product.allStoreIDs.Count > 0 )
    86.             {
    87.                 var ids = new IDs();
    88.  
    89.                 foreach ( var storeID in product.allStoreIDs )
    90.                 {
    91.                     ids.Add(storeID.id, storeID.store);
    92.                 }
    93.  
    94.                 builder.AddProduct(product.id, product.type, ids);
    95.             }
    96.             else
    97.             {
    98.                 builder.AddProduct(product.id, product.type);
    99.             }
    100.         }
    101.      
    102.         UnityPurchasing.Initialize(this, builder);
    103.     }
    104.  
    105.     // To be called from an UI element.
    106.     public void UI_restoreTransactionsApple()
    107.     {
    108.         if ( extensions == null )
    109.         {
    110.             OnRestoreTransactions(false);
    111.             return;
    112.         }
    113.  
    114.         // The following can be compiled on any Unity IAP platform, and if you were to run it on a non Apple platform such as Android it would have no effect; the supplied callback would never be invoked.
    115.         extensions.GetExtension<IAppleExtensions>().RestoreTransactions(OnRestoreTransactions);
    116.     }
    117.  
    118.     public void requestPurchase(string _productId)
    119.     {
    120.         if ( controller == null )
    121.         {
    122.             TuokioDialogNote note = new TuokioDialogNote();
    123.             note.show("STORE ERROR", "Store is not currently available. Check your Internet connection and try again.");
    124.  
    125.             return;
    126.         }
    127.  
    128.         controller.InitiatePurchase(_productId);
    129.     }
    130.  
    131.     /// <summary>
    132.     /// Called when Unity IAP is ready to make purchases.
    133.     /// </summary>
    134.     public void OnInitialized(IStoreController _controller, IExtensionProvider _extensions)
    135.     {
    136.         this.controller = _controller;
    137.         this.extensions = _extensions;
    138.  
    139.         extensions.GetExtension<IAppleExtensions>().RegisterPurchaseDeferredListener(iOSAskToBuyDeferredPurchaseNotification);
    140.  
    141.         isInitialized = true;
    142.  
    143.         if ( initializationCompleteEvent != null )
    144.         {
    145.             initializationCompleteEvent();
    146.         }
    147.     }
    148.  
    149.     /// <summary>
    150.     /// Called when Unity IAP encounters an unrecoverable initialization error.
    151.     ///
    152.     /// Note that this will not be called if Internet is unavailable; Unity IAP
    153.     /// will attempt initialization until it becomes available.
    154.     /// </summary>
    155.     public void OnInitializeFailed(InitializationFailureReason _error)
    156.     {
    157.         TuokioDialogNote note = new TuokioDialogNote();
    158.         note.show("STORE ERROR", "Failed to load online store. Error: " + _error);
    159.     }
    160.  
    161.     // I assume this will be called when iOS 8+ Ask To Buy family feature is used and user "Kid" attempts purchase, which will then be pending supervisor approval on a remote device.
    162.     private void iOSAskToBuyDeferredPurchaseNotification(Product _product)
    163.     {
    164.         TuokioDialogNote note = new TuokioDialogNote();
    165.         note.show("PURCHASE PENDING", "Purchase will complete when approved by paying user");
    166.     }
    167.  
    168.     public void OnRestoreTransactions(bool _restorationProcessSucceeded)
    169.     {
    170.         if ( _restorationProcessSucceeded )
    171.         {
    172.             // This does not mean anything was restored,
    173.             // merely that the restoration process succeeded.
    174.             // ProcessPurchase will automatically have been called for every item restored, if any.
    175.         }
    176.         else
    177.         {
    178.             // Restoration failed.
    179.             TuokioDialogNote note = new TuokioDialogNote();
    180.             note.show("STORE ERROR", "Could not restore previous transactions.");
    181.         }
    182.     }
    183.  
    184.     /// <summary>
    185.     /// Called when a purchase completes.
    186.     ///
    187.     /// May be called at any time after OnInitialized().
    188.     /// </summary>
    189.     public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs _purchaseArguments)
    190.     {
    191.         // NOTICE returning Complete means the purchase is confirmed and final. The alternate result "Pending" is to be used as a transitive state while still saving the purchased item into Cloud storage elsewhere, and needs a ConfirmPurchase call when the Cloud has acknowledged storing the purchase data.
    192.         // Since IAP content is not saved in any Cloud service, we just return Complete and finalize all purchases immediately when they come through from the store back to our game.
    193.         if ( _purchaseArguments.purchasedProduct.definition.id == CoinDoublerIAP.PRODUCT_ID )
    194.         {
    195.             CoinDoublerIAP.isEnabled = true;
    196.  
    197.             TuokioDialogNote note = new TuokioDialogNote();
    198.             note.show("Coin Doubler", "All picked up coins will be doubled");
    199.         }
    200.         else if ( _purchaseArguments.purchasedProduct.definition.id.Contains(PARTIAL_ID_COINPACK_SMALL) )
    201.         {
    202.             TuokioDialogNote note = new TuokioDialogNote();
    203.             note.show("COINPACK", "Received " + COINS_COINPACK_SMALL + " coins");
    204.             PlayerSave.changeCoinAmount(COINS_COINPACK_SMALL);
    205.         }
    206.         else if ( _purchaseArguments.purchasedProduct.definition.id.Contains(PARTIAL_ID_COINPACK_MEDIUM) )
    207.         {
    208.             TuokioDialogNote note = new TuokioDialogNote();
    209.             note.show("COINPACK", "Received " + COINS_COINPACK_MEDIUM + " coins");
    210.             PlayerSave.changeCoinAmount(COINS_COINPACK_MEDIUM);
    211.         }
    212.         else if ( _purchaseArguments.purchasedProduct.definition.id.Contains(PARTIAL_ID_COINPACK_LARGE) )
    213.         {
    214.             TuokioDialogNote note = new TuokioDialogNote();
    215.             note.show("COINPACK", "Received " + COINS_COINPACK_LARGE + " coins");
    216.             PlayerSave.changeCoinAmount(COINS_COINPACK_LARGE);
    217.         }
    218.  
    219.         if ( purchaseCompleteEvent != null )
    220.         {
    221.             purchaseCompleteEvent(_purchaseArguments.purchasedProduct.definition.id);
    222.         }
    223.  
    224.         return PurchaseProcessingResult.Complete;
    225.     }
    226.  
    227.     /// <summary>
    228.     /// Called when a purchase fails.
    229.     /// </summary>
    230.     public void OnPurchaseFailed(Product _product, PurchaseFailureReason _reason)
    231.     {
    232.         TuokioDialogNote note = new TuokioDialogNote();
    233.  
    234.         if ( _reason == PurchaseFailureReason.PurchasingUnavailable )
    235.         {
    236.             // IAP may be disabled in device settings.
    237.             note.show("STORE ERROR", "Purchase failed. You have not been charged. Your device settings may be blocking in-app purchases.");
    238.         }
    239.         else
    240.         {
    241.             // HACK Workaround for Unity IAP triggering OnPurchaseFailed every time it initializes after a Non-Consumable has been purchased and is being auto-restored-on-initialization on Google Play.
    242.             if ( shouldOmitError(_product.definition.id, _reason) )
    243.             {
    244.                 return;
    245.             }
    246.  
    247.             note.show("STORE ERROR", "Purchase failed. You have not been charged. Try again later.\nError: " + _reason);
    248.         }
    249.     }
    250.  
    251.     // HACK Workaround for Unity IAP triggering OnPurchaseFailed every time it initializes after a Non-Consumable has been purchased and is being auto-restored-on-initialization on Google Play.
    252.     List<string> omittedErrorsPerProductID;
    253.  
    254.     // HACK Workaround for Unity IAP triggering OnPurchaseFailed every time it initializes after a Non-Consumable has been purchased and is being auto-restored-on-initialization on Google Play.
    255.     /// <summary>
    256.     /// Allows omitting one PurchaseFailureReason.DuplicateTransaction error for each already-owned Non-Consumable.
    257.     /// </summary>
    258.     /// <returns><c>true</c>, if error should be omitted, <c>false</c> if the error needs to be displayed.</returns>
    259.     /// <param name="_productId">Product identifier.</param>
    260.     /// <param name="_reason">Reason.</param>
    261.     private bool shouldOmitError(string _productId, PurchaseFailureReason _reason)
    262.     {
    263.         if ( _reason == PurchaseFailureReason.DuplicateTransaction && TuokioIAPInventory.isNonConsumableOwned(_productId) )
    264.         {
    265.             if ( omittedErrorsPerProductID == null )
    266.             {
    267.                 omittedErrorsPerProductID = new List<string>();
    268.             }
    269.  
    270.             if ( omittedErrorsPerProductID.Contains(_productId) == false )
    271.             {
    272.                 omittedErrorsPerProductID.Add(_productId);
    273.                 return true;
    274.             }
    275.             else
    276.             {
    277.                 return false;
    278.             }
    279.         }
    280.  
    281.         return false;
    282.     }
    283. }
    Inventory implementation to keep track of non-consumables already purchased:
    Code (CSharp):
    1. using System.Collections;
    2. using System.Collections.Generic;
    3. using UnityEngine;
    4. using UnityEngine.Purchasing;
    5.  
    6. public static class TuokioIAPInventory
    7. {
    8.     private static ListContainer container;
    9.  
    10.     static TuokioIAPInventory()
    11.     {
    12.         GameInitialization.initializeWhenDone(initialize);
    13.     }
    14.  
    15.     private static void initialize()
    16.     {
    17.         container = DataContainer.create<ListContainer>("tiapi");
    18.  
    19.         if ( container.data == null )
    20.         {
    21.             container.data = new List<string>();
    22.         }
    23.  
    24.         TuokioIAP.purchaseCompleteEvent -= checkPurchase;
    25.         TuokioIAP.purchaseCompleteEvent += checkPurchase;
    26.     }
    27.  
    28.     public static bool isNonConsumableOwned(string _productId)
    29.     {
    30.         return container.data.Contains(_productId);
    31.     }
    32.  
    33.     private static void checkPurchase(string _productId)
    34.     {
    35.         Product product = TuokioIAP.getProduct(_productId);
    36.  
    37.         if ( product == null )
    38.         {
    39.             return;
    40.         }
    41.  
    42.         if ( product.definition.type == ProductType.NonConsumable )
    43.         {
    44.             if ( container.data.Contains(_productId) == false )
    45.             {
    46.                 container.data.Add(_productId);
    47.                 container.save();
    48.             }
    49.         }
    50.     }
    51. }
     
  4. Keepabee

    Keepabee

    Joined:
    Jul 12, 2012
    Posts:
    58
    Oh and one more detail: Unity IAP does not function similarly in Editor regarding the Android platform's automatic restore transactions execution - this error occurs only on a Android device. I'd rather the functionality would be mirrored in Editor for testing purposes, as always.
     
  5. gilberto_lumentech

    gilberto_lumentech

    Joined:
    May 6, 2016
    Posts:
    10
    Same error happening here. It's a sandbox environment.

    Only on Android this happens (we're using this same workaround here :D)

    Unity 5.5.2, last IAP version.
     
  6. ap-unity

    ap-unity

    Unity Technologies

    Joined:
    Aug 3, 2016
    Posts:
    1,519
    @Meatgrind, @gilberto_lumentech

    Thank you for reporting this issue. I spoke with our IAP engineers today and this is a known issue that they are actively working on. This will likely require an update to the engine, but it will be backported to 5.5 and 5.4, so you will only need to update to the latest patch (and not the latest release).
     
  7. hed5meg

    hed5meg

    Joined:
    May 21, 2014
    Posts:
    2
    Has this been fixed yet? If so, in which patch was it fixed? If not do you have an ETA?
     
  8. ap-unity

    ap-unity

    Unity Technologies

    Joined:
    Aug 3, 2016
    Posts:
    1,519
    The fix should be available in the following versions:

    2017.1a5
    5.6.0p1
    5.5.3p1
    5.4.5p2
     
    Last edited: Jun 8, 2017
Thread Status:
Not open for further replies.