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:
using UnityEngine;
using System.Collections.Generic;
using UnityEngine.Purchasing;
public class TuokioIAP : IStoreListener
{
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";
public const string PARTIAL_ID_COINPACK_SMALL = "coins.small";
public const string PARTIAL_ID_COINPACK_MEDIUM = "coins.medium";
public const string PARTIAL_ID_COINPACK_LARGE = "coins.large";
public const int COINS_COINPACK_SMALL = 2150;
public const int COINS_COINPACK_MEDIUM = 12500;
public const int COINS_COINPACK_LARGE = 55000;
public static event System.Action initializationCompleteEvent;
public static event System.Action<string> purchaseCompleteEvent;
public static TuokioIAP Instance { get; private set; }
private IStoreController controller;
private IExtensionProvider extensions;
public static bool isInitialized { get; private set; }
private ProductCatalog catalog;
public static void Initialize()
{
if ( Instance != null )
{
return;
}
Instance = new TuokioIAP();
}
public static Product getProduct(string _productId)
{
return (Instance != null && Instance.controller != null ) ? Instance.controller.products.WithID(_productId) : null;
}
public static ProductCatalogItem getCatalogProduct(string _productId)
{
if ( Instance != null && Instance.catalog != null )
{
foreach ( ProductCatalogItem product in Instance.catalog.allProducts )
{
if ( product.id == _productId )
{
return product;
}
}
return null;
}
else
{
return null;
}
}
private TuokioIAP()
{
if ( Instance != null )
{
Debug.LogError("Duplicate TuokioIAP created!");
return;
}
Instance = this;
catalog = ProductCatalog.LoadDefaultCatalog();
StandardPurchasingModule module = StandardPurchasingModule.Instance();
module.useFakeStoreUIMode = FakeStoreUIMode.StandardUser;
ConfigurationBuilder builder = ConfigurationBuilder.Instance(module);
builder.useCloudCatalog = false;
builder.Configure<IGooglePlayConfiguration>().SetPublicKey(GOOGLE_PLAY_PUBLIC_API_KEY);
foreach ( var product in catalog.allProducts )
{
if ( product.allStoreIDs.Count > 0 )
{
var ids = new IDs();
foreach ( var storeID in product.allStoreIDs )
{
ids.Add(storeID.id, storeID.store);
}
builder.AddProduct(product.id, product.type, ids);
}
else
{
builder.AddProduct(product.id, product.type);
}
}
UnityPurchasing.Initialize(this, builder);
}
// To be called from an UI element.
public void UI_restoreTransactionsApple()
{
if ( extensions == null )
{
OnRestoreTransactions(false);
return;
}
// 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.
extensions.GetExtension<IAppleExtensions>().RestoreTransactions(OnRestoreTransactions);
}
public void requestPurchase(string _productId)
{
if ( controller == null )
{
TuokioDialogNote note = new TuokioDialogNote();
note.show("STORE ERROR", "Store is not currently available. Check your Internet connection and try again.");
return;
}
controller.InitiatePurchase(_productId);
}
/// <summary>
/// Called when Unity IAP is ready to make purchases.
/// </summary>
public void OnInitialized(IStoreController _controller, IExtensionProvider _extensions)
{
this.controller = _controller;
this.extensions = _extensions;
extensions.GetExtension<IAppleExtensions>().RegisterPurchaseDeferredListener(iOSAskToBuyDeferredPurchaseNotification);
isInitialized = true;
if ( initializationCompleteEvent != null )
{
initializationCompleteEvent();
}
}
/// <summary>
/// Called when Unity IAP encounters an unrecoverable initialization error.
///
/// Note that this will not be called if Internet is unavailable; Unity IAP
/// will attempt initialization until it becomes available.
/// </summary>
public void OnInitializeFailed(InitializationFailureReason _error)
{
TuokioDialogNote note = new TuokioDialogNote();
note.show("STORE ERROR", "Failed to load online store. Error: " + _error);
}
// 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.
private void iOSAskToBuyDeferredPurchaseNotification(Product _product)
{
TuokioDialogNote note = new TuokioDialogNote();
note.show("PURCHASE PENDING", "Purchase will complete when approved by paying user");
}
public void OnRestoreTransactions(bool _restorationProcessSucceeded)
{
if ( _restorationProcessSucceeded )
{
// This does not mean anything was restored,
// merely that the restoration process succeeded.
// ProcessPurchase will automatically have been called for every item restored, if any.
}
else
{
// Restoration failed.
TuokioDialogNote note = new TuokioDialogNote();
note.show("STORE ERROR", "Could not restore previous transactions.");
}
}
/// <summary>
/// Called when a purchase completes.
///
/// May be called at any time after OnInitialized().
/// </summary>
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs _purchaseArguments)
{
// 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.
// 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.
if ( _purchaseArguments.purchasedProduct.definition.id == CoinDoublerIAP.PRODUCT_ID )
{
CoinDoublerIAP.isEnabled = true;
TuokioDialogNote note = new TuokioDialogNote();
note.show("Coin Doubler", "All picked up coins will be doubled");
}
else if ( _purchaseArguments.purchasedProduct.definition.id.Contains(PARTIAL_ID_COINPACK_SMALL) )
{
TuokioDialogNote note = new TuokioDialogNote();
note.show("COINPACK", "Received " + COINS_COINPACK_SMALL + " coins");
PlayerSave.changeCoinAmount(COINS_COINPACK_SMALL);
}
else if ( _purchaseArguments.purchasedProduct.definition.id.Contains(PARTIAL_ID_COINPACK_MEDIUM) )
{
TuokioDialogNote note = new TuokioDialogNote();
note.show("COINPACK", "Received " + COINS_COINPACK_MEDIUM + " coins");
PlayerSave.changeCoinAmount(COINS_COINPACK_MEDIUM);
}
else if ( _purchaseArguments.purchasedProduct.definition.id.Contains(PARTIAL_ID_COINPACK_LARGE) )
{
TuokioDialogNote note = new TuokioDialogNote();
note.show("COINPACK", "Received " + COINS_COINPACK_LARGE + " coins");
PlayerSave.changeCoinAmount(COINS_COINPACK_LARGE);
}
if ( purchaseCompleteEvent != null )
{
purchaseCompleteEvent(_purchaseArguments.purchasedProduct.definition.id);
}
return PurchaseProcessingResult.Complete;
}
/// <summary>
/// Called when a purchase fails.
/// </summary>
public void OnPurchaseFailed(Product _product, PurchaseFailureReason _reason)
{
TuokioDialogNote note = new TuokioDialogNote();
if ( _reason == PurchaseFailureReason.PurchasingUnavailable )
{
// IAP may be disabled in device settings.
note.show("STORE ERROR", "Purchase failed. You have not been charged. Your device settings may be blocking in-app purchases.");
}
else
{
// 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.
if ( shouldOmitError(_product.definition.id, _reason) )
{
return;
}
note.show("STORE ERROR", "Purchase failed. You have not been charged. Try again later.\nError: " + _reason);
}
}
// 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.
List<string> omittedErrorsPerProductID;
// 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.
/// <summary>
/// Allows omitting one PurchaseFailureReason.DuplicateTransaction error for each already-owned Non-Consumable.
/// </summary>
/// <returns><c>true</c>, if error should be omitted, <c>false</c> if the error needs to be displayed.</returns>
/// <param name="_productId">Product identifier.</param>
/// <param name="_reason">Reason.</param>
private bool shouldOmitError(string _productId, PurchaseFailureReason _reason)
{
if ( _reason == PurchaseFailureReason.DuplicateTransaction && TuokioIAPInventory.isNonConsumableOwned(_productId) )
{
if ( omittedErrorsPerProductID == null )
{
omittedErrorsPerProductID = new List<string>();
}
if ( omittedErrorsPerProductID.Contains(_productId) == false )
{
omittedErrorsPerProductID.Add(_productId);
return true;
}
else
{
return false;
}
}
return false;
}
}
Inventory implementation to keep track of non-consumables already purchased:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Purchasing;
public static class TuokioIAPInventory
{
private static ListContainer container;
static TuokioIAPInventory()
{
GameInitialization.initializeWhenDone(initialize);
}
private static void initialize()
{
container = DataContainer.create<ListContainer>("tiapi");
if ( container.data == null )
{
container.data = new List<string>();
}
TuokioIAP.purchaseCompleteEvent -= checkPurchase;
TuokioIAP.purchaseCompleteEvent += checkPurchase;
}
public static bool isNonConsumableOwned(string _productId)
{
return container.data.Contains(_productId);
}
private static void checkPurchase(string _productId)
{
Product product = TuokioIAP.getProduct(_productId);
if ( product == null )
{
return;
}
if ( product.definition.type == ProductType.NonConsumable )
{
if ( container.data.Contains(_productId) == false )
{
container.data.Add(_productId);
container.save();
}
}
}
}