fix macOS build (following Projucer changes made in Windows, which removed /Applications/JUCE/modules from its headers). move JUCE headers under source control, so that Windows and macOS can both build against same version of JUCE. remove AUv3 target (I think it's an iOS thing, so it will never work with this macOS fluidsynth dylib).
This commit is contained in:
@ -0,0 +1,138 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
namespace juce
|
||||
{
|
||||
|
||||
//==============================================================================
|
||||
JUCE_IMPLEMENT_SINGLETON (InAppPurchases)
|
||||
|
||||
InAppPurchases::InAppPurchases()
|
||||
#if JUCE_ANDROID || JUCE_IOS || JUCE_MAC
|
||||
: pimpl (new Pimpl (*this))
|
||||
#endif
|
||||
{}
|
||||
|
||||
InAppPurchases::~InAppPurchases() { clearSingletonInstance(); }
|
||||
|
||||
bool InAppPurchases::isInAppPurchasesSupported() const
|
||||
{
|
||||
#if JUCE_ANDROID || JUCE_IOS || JUCE_MAC
|
||||
return pimpl->isInAppPurchasesSupported();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void InAppPurchases::getProductsInformation (const StringArray& productIdentifiers)
|
||||
{
|
||||
#if JUCE_ANDROID || JUCE_IOS || JUCE_MAC
|
||||
pimpl->getProductsInformation (productIdentifiers);
|
||||
#else
|
||||
Array<Product> products;
|
||||
for (auto productId : productIdentifiers)
|
||||
products.add (Product { productId, {}, {}, {}, {} });
|
||||
|
||||
listeners.call ([&] (Listener& l) { l.productsInfoReturned (products); });
|
||||
#endif
|
||||
}
|
||||
|
||||
void InAppPurchases::purchaseProduct (const String& productIdentifier,
|
||||
bool isSubscription,
|
||||
const StringArray& upgradeProductIdentifiers,
|
||||
bool creditForUnusedSubscription)
|
||||
{
|
||||
#if JUCE_ANDROID || JUCE_IOS || JUCE_MAC
|
||||
pimpl->purchaseProduct (productIdentifier, isSubscription,
|
||||
upgradeProductIdentifiers, creditForUnusedSubscription);
|
||||
#else
|
||||
Listener::PurchaseInfo purchaseInfo { Purchase { "", productIdentifier, {}, {}, {} }, {} };
|
||||
|
||||
listeners.call ([&] (Listener& l) { l.productPurchaseFinished (purchaseInfo, false, "In-app purchases unavailable"); });
|
||||
ignoreUnused (isSubscription, upgradeProductIdentifiers, creditForUnusedSubscription);
|
||||
#endif
|
||||
}
|
||||
|
||||
void InAppPurchases::restoreProductsBoughtList (bool includeDownloadInfo, const String& subscriptionsSharedSecret)
|
||||
{
|
||||
#if JUCE_ANDROID || JUCE_IOS || JUCE_MAC
|
||||
pimpl->restoreProductsBoughtList (includeDownloadInfo, subscriptionsSharedSecret);
|
||||
#else
|
||||
listeners.call ([] (Listener& l) { l.purchasesListRestored ({}, false, "In-app purchases unavailable"); });
|
||||
ignoreUnused (includeDownloadInfo, subscriptionsSharedSecret);
|
||||
#endif
|
||||
}
|
||||
|
||||
void InAppPurchases::consumePurchase (const String& productIdentifier, const String& purchaseToken)
|
||||
{
|
||||
#if JUCE_ANDROID || JUCE_IOS || JUCE_MAC
|
||||
pimpl->consumePurchase (productIdentifier, purchaseToken);
|
||||
#else
|
||||
listeners.call ([&] (Listener& l) { l.productConsumed (productIdentifier, false, "In-app purchases unavailable"); });
|
||||
ignoreUnused (purchaseToken);
|
||||
#endif
|
||||
}
|
||||
|
||||
void InAppPurchases::addListener (Listener* l) { listeners.add (l); }
|
||||
void InAppPurchases::removeListener (Listener* l) { listeners.remove (l); }
|
||||
|
||||
void InAppPurchases::startDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
#if JUCE_ANDROID || JUCE_IOS || JUCE_MAC
|
||||
pimpl->startDownloads (downloads);
|
||||
#else
|
||||
ignoreUnused (downloads);
|
||||
#endif
|
||||
}
|
||||
|
||||
void InAppPurchases::pauseDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
#if JUCE_ANDROID || JUCE_IOS || JUCE_MAC
|
||||
pimpl->pauseDownloads (downloads);
|
||||
#else
|
||||
ignoreUnused (downloads);
|
||||
#endif
|
||||
}
|
||||
|
||||
void InAppPurchases::resumeDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
#if JUCE_ANDROID || JUCE_IOS || JUCE_MAC
|
||||
pimpl->resumeDownloads (downloads);
|
||||
#else
|
||||
ignoreUnused (downloads);
|
||||
#endif
|
||||
}
|
||||
|
||||
void InAppPurchases::cancelDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
#if JUCE_ANDROID || JUCE_IOS || JUCE_MAC
|
||||
pimpl->cancelDownloads (downloads);
|
||||
#else
|
||||
ignoreUnused (downloads);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace juce
|
@ -0,0 +1,285 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
namespace juce
|
||||
{
|
||||
|
||||
/**
|
||||
Provides in-app purchase functionality.
|
||||
|
||||
Your app should create a single instance of this class, and on iOS it should
|
||||
be created as soon as your app starts. This is because on application startup
|
||||
any previously pending transactions will be resumed.
|
||||
|
||||
Once an InAppPurchases object is created, call addListener() to attach listeners.
|
||||
|
||||
@tags{ProductUnlocking}
|
||||
*/
|
||||
class JUCE_API InAppPurchases : private DeletedAtShutdown
|
||||
{
|
||||
public:
|
||||
#ifndef DOXYGEN
|
||||
JUCE_DECLARE_SINGLETON (InAppPurchases, false)
|
||||
#endif
|
||||
|
||||
//==============================================================================
|
||||
/** Represents a product available in the store. */
|
||||
struct Product
|
||||
{
|
||||
/** Product ID (also known as SKU) that uniquely identifies a product in the store. */
|
||||
String identifier;
|
||||
|
||||
/** Title of the product. */
|
||||
String title;
|
||||
|
||||
/** Description of the product. */
|
||||
String description;
|
||||
|
||||
/** Price of the product in local currency. */
|
||||
String price;
|
||||
|
||||
/** Price locale. */
|
||||
String priceLocale;
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
/** Represents a purchase of a product in the store. */
|
||||
struct Purchase
|
||||
{
|
||||
/** A unique order identifier for the transaction (generated by the store). */
|
||||
String orderId;
|
||||
|
||||
/** A unique identifier of in-app product that was purchased. */
|
||||
String productId;
|
||||
|
||||
/** This will be bundle ID on iOS and package name on Android, of the application for which this
|
||||
in-app product was purchased. */
|
||||
String applicationBundleName;
|
||||
|
||||
/** Date of the purchase (in ISO8601 format). */
|
||||
String purchaseTime;
|
||||
|
||||
/** Android only: purchase token that should be used to consume purchase, provided that In-App product
|
||||
is consumable. */
|
||||
String purchaseToken;
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
/** iOS only: represents in-app purchase download. Download will be available only
|
||||
for purchases that are hosted on the AppStore. */
|
||||
struct Download
|
||||
{
|
||||
enum class Status
|
||||
{
|
||||
waiting = 0, /**< The download is waiting to start. Called at the beginning of a download operation. */
|
||||
active, /**< The download is in progress. */
|
||||
paused, /**< The download was paused and is awaiting resuming or cancelling. */
|
||||
finished, /**< The download was finished successfully. */
|
||||
failed, /**< The download failed (e.g. because of no internet connection). */
|
||||
cancelled, /**< The download was cancelled. */
|
||||
};
|
||||
|
||||
virtual ~Download() {}
|
||||
|
||||
/** A unique identifier for the in-app product to be downloaded. */
|
||||
virtual String getProductId() const = 0;
|
||||
|
||||
/** Content length in bytes. */
|
||||
virtual int64 getContentLength() const = 0;
|
||||
|
||||
/** Content version. */
|
||||
virtual String getContentVersion() const = 0;
|
||||
|
||||
/** Returns current status of the download. */
|
||||
virtual Status getStatus() const = 0;
|
||||
};
|
||||
|
||||
|
||||
//==============================================================================
|
||||
/** Represents an object that gets notified about events such as product info returned or product purchase
|
||||
finished. */
|
||||
struct Listener
|
||||
{
|
||||
virtual ~Listener() {}
|
||||
|
||||
/** Called whenever a product info is returned after a call to InAppPurchases::getProductsInformation(). */
|
||||
virtual void productsInfoReturned (const Array<Product>& /*products*/) {}
|
||||
|
||||
/** Structure holding purchase information */
|
||||
struct PurchaseInfo
|
||||
{
|
||||
Purchase purchase;
|
||||
Array<Download*> downloads;
|
||||
};
|
||||
|
||||
/** Called whenever a purchase is complete, with additional state whether the purchase completed successfully.
|
||||
|
||||
For hosted content (iOS only), the downloads array within PurchaseInfo will contain all download objects corresponding
|
||||
with the purchase. For non-hosted content, the downloads array will be empty.
|
||||
|
||||
InAppPurchases class will own downloads and will delete them as soon as they are finished.
|
||||
|
||||
NOTE: it is possible to receive this callback for the same purchase multiple times. If that happens,
|
||||
only the newest set of downloads and the newest orderId will be valid, the old ones should be not used anymore!
|
||||
*/
|
||||
virtual void productPurchaseFinished (const PurchaseInfo&, bool /*success*/, const String& /*statusDescription*/) {}
|
||||
|
||||
/** Called when a list of all purchases is restored. This can be used to figure out to
|
||||
which products a user is entitled to.
|
||||
|
||||
NOTE: it is possible to receive this callback for the same purchase multiple times. If that happens,
|
||||
only the newest set of downloads and the newest orderId will be valid, the old ones should be not used anymore!
|
||||
*/
|
||||
virtual void purchasesListRestored (const Array<PurchaseInfo>&, bool /*success*/, const String& /*statusDescription*/) {}
|
||||
|
||||
/** Called whenever a product consumption finishes. */
|
||||
virtual void productConsumed (const String& /*productId*/, bool /*success*/, const String& /*statusDescription*/) {}
|
||||
|
||||
/** iOS only: Called when a product download progress gets updated. If the download was interrupted in the last
|
||||
application session, this callback may be called after the application starts.
|
||||
|
||||
If the download was in progress and the application was closed, the download may happily continue in the
|
||||
background by OS. If you open the app and the download is still in progress, you will receive this callback.
|
||||
If the download finishes in the background before you start the app again, you will receive productDownloadFinished
|
||||
callback instead. The download will only stop when it is explicitly cancelled or when it is finished.
|
||||
*/
|
||||
virtual void productDownloadProgressUpdate (Download&, float /*progress*/, RelativeTime /*timeRemaining*/) {}
|
||||
|
||||
/** iOS only: Called when a product download is paused. This may also be called after the application starts, if
|
||||
the download was in a paused state and the application was closed before finishing the download.
|
||||
|
||||
Only after the download is finished successfully or cancelled you will stop receiving this callback on startup.
|
||||
*/
|
||||
virtual void productDownloadPaused (Download&) {}
|
||||
|
||||
/** iOS only: Called when a product download finishes (successfully or not). Call Download::getStatus()
|
||||
to check if the downloaded finished successfully.
|
||||
|
||||
It is your responsibility to move the download content into your app directory and to clean up
|
||||
any files that are no longer needed.
|
||||
|
||||
After the download is finished, the download object is destroyed and should not be accessed anymore.
|
||||
*/
|
||||
virtual void productDownloadFinished (Download&, const URL& /*downloadedContentPath*/) {}
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
/** Checks whether in-app purchases is supported on current platform. On iOS this always returns true. */
|
||||
bool isInAppPurchasesSupported() const;
|
||||
|
||||
/** Asynchronously requests information for products with given ids. Upon completion, for each enquired product
|
||||
there is going to be a corresponding Product object.
|
||||
If there is no information available for the given product identifier, it will be ignored.
|
||||
*/
|
||||
void getProductsInformation (const StringArray& productIdentifiers);
|
||||
|
||||
/** Asynchronously requests to buy a product with given id.
|
||||
|
||||
@param productIdentifier The product identifier.
|
||||
|
||||
@param isSubscription (Android only) defines if a product a user wants to buy is a subscription or a one-time purchase.
|
||||
On iOS, type of the product is derived implicitly.
|
||||
|
||||
@param upgradeOrDowngradeFromSubscriptionsWithProductIdentifiers (Android only) specifies subscriptions that will be replaced by the
|
||||
one being purchased now. Used only when buying a subscription
|
||||
that is an upgrade or downgrade from other ones.
|
||||
|
||||
@param creditForUnusedSubscription (Android only) controls whether a user should be credited for any unused subscription time on
|
||||
the products that are being upgraded or downgraded.
|
||||
*/
|
||||
void purchaseProduct (const String& productIdentifier,
|
||||
bool isSubscription,
|
||||
const StringArray& upgradeOrDowngradeFromSubscriptionsWithProductIdentifiers = {},
|
||||
bool creditForUnusedSubscription = true);
|
||||
|
||||
/** Asynchronously asks about a list of products that a user has already bought. Upon completion, Listener::purchasesListReceived()
|
||||
callback will be invoked. The user may be prompted to login first.
|
||||
|
||||
@param includeDownloadInfo (iOS only) if true, then after restoration is successfull, the downloads array passed to
|
||||
Listener::purchasesListReceived() callback will contain all the download objects corresponding with
|
||||
the purchase. In the opposite case, the downloads array will be empty.
|
||||
|
||||
@param subscriptionsSharedSecret (iOS only) required when not including download information and when there are
|
||||
auto-renewable subscription set up with this app. Refer to In-App-Purchase settings in the store.
|
||||
*/
|
||||
void restoreProductsBoughtList (bool includeDownloadInfo, const juce::String& subscriptionsSharedSecret = {});
|
||||
|
||||
/** Android only: asynchronously sends a request to mark a purchase with given identifier as consumed.
|
||||
To consume a product, provide product identifier as well as a purchase token that was generated when
|
||||
the product was purchased. The purchase token can also be retrieved by using getProductsInformation().
|
||||
In general if it is available on hand, it is better to use it, because otherwise another async
|
||||
request will be sent to the store, to first retrieve the token.
|
||||
|
||||
After successful consumption, a product will no longer be returned in getProductsBought() and
|
||||
it will be available for purchase.
|
||||
|
||||
On iOS consumption happens automatically. If the product was set as consumable, this function is a no-op.
|
||||
*/
|
||||
void consumePurchase (const String& productIdentifier, const String& purchaseToken = {});
|
||||
|
||||
//==============================================================================
|
||||
/** Adds a listener. */
|
||||
void addListener (Listener*);
|
||||
|
||||
/** Removes a listener. */
|
||||
void removeListener (Listener*);
|
||||
|
||||
//==============================================================================
|
||||
/** iOS only: Starts downloads of hosted content from the store. */
|
||||
void startDownloads (const Array<Download*>& downloads);
|
||||
|
||||
/** iOS only: Pauses downloads of hosted content from the store. */
|
||||
void pauseDownloads (const Array<Download*>& downloads);
|
||||
|
||||
/** iOS only: Resumes downloads of hosted content from the store. */
|
||||
void resumeDownloads (const Array<Download*>& downloads);
|
||||
|
||||
/** iOS only: Cancels downloads of hosted content from the store. */
|
||||
void cancelDownloads (const Array<Download*>& downloads);
|
||||
|
||||
private:
|
||||
//==============================================================================
|
||||
#ifndef DOXYGEN
|
||||
InAppPurchases();
|
||||
~InAppPurchases();
|
||||
#endif
|
||||
|
||||
//==============================================================================
|
||||
ListenerList<Listener> listeners;
|
||||
|
||||
#if JUCE_ANDROID
|
||||
friend void juce_inAppPurchaseCompleted (void*);
|
||||
#endif
|
||||
|
||||
#if JUCE_ANDROID || JUCE_IOS || JUCE_MAC
|
||||
struct Pimpl;
|
||||
friend struct Pimpl;
|
||||
|
||||
std::unique_ptr<Pimpl> pimpl;
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace juce
|
69
modules/juce_product_unlocking/juce_product_unlocking.cpp
Normal file
69
modules/juce_product_unlocking/juce_product_unlocking.cpp
Normal file
@ -0,0 +1,69 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
#ifdef JUCE_PRODUCT_UNLOCKING_H_INCLUDED
|
||||
/* When you add this cpp file to your project, you mustn't include it in a file where you've
|
||||
already included any other headers - just put it inside a file on its own, possibly with your config
|
||||
flags preceding it, but don't include anything else. That also includes avoiding any automatic prefix
|
||||
header files that the compiler may be using.
|
||||
*/
|
||||
#error "Incorrect use of JUCE cpp file"
|
||||
#endif
|
||||
|
||||
#define JUCE_CORE_INCLUDE_JNI_HELPERS 1
|
||||
#define JUCE_CORE_INCLUDE_OBJC_HELPERS 1
|
||||
#define JUCE_CORE_INCLUDE_NATIVE_HEADERS 1
|
||||
|
||||
// Set this flag to 1 to use test servers on iOS
|
||||
#ifndef JUCE_IN_APP_PURCHASES_USE_SANDBOX_ENVIRONMENT
|
||||
#define JUCE_IN_APP_PURCHASES_USE_SANDBOX_ENVIRONMENT 0
|
||||
#endif
|
||||
|
||||
#include "juce_product_unlocking.h"
|
||||
|
||||
#if JUCE_IOS || JUCE_MAC
|
||||
#import <StoreKit/StoreKit.h>
|
||||
#endif
|
||||
|
||||
#if JUCE_IN_APP_PURCHASES
|
||||
#if JUCE_ANDROID
|
||||
#include "native/juce_android_InAppPurchases.cpp"
|
||||
#elif JUCE_IOS || JUCE_MAC
|
||||
#include "native/juce_ios_InAppPurchases.cpp"
|
||||
#endif
|
||||
|
||||
#include "in_app_purchases/juce_InAppPurchases.cpp"
|
||||
#endif
|
||||
|
||||
#include "marketplace/juce_OnlineUnlockStatus.cpp"
|
||||
|
||||
#if JUCE_MODULE_AVAILABLE_juce_data_structures
|
||||
#include "marketplace/juce_TracktionMarketplaceStatus.cpp"
|
||||
#endif
|
||||
|
||||
#if JUCE_MODULE_AVAILABLE_juce_gui_extra
|
||||
#include "marketplace/juce_OnlineUnlockForm.cpp"
|
||||
#endif
|
93
modules/juce_product_unlocking/juce_product_unlocking.h
Normal file
93
modules/juce_product_unlocking/juce_product_unlocking.h
Normal file
@ -0,0 +1,93 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
/*******************************************************************************
|
||||
The block below describes the properties of this module, and is read by
|
||||
the Projucer to automatically generate project code that uses it.
|
||||
For details about the syntax and how to create or use a module, see the
|
||||
JUCE Module Format.txt file.
|
||||
|
||||
|
||||
BEGIN_JUCE_MODULE_DECLARATION
|
||||
|
||||
ID: juce_product_unlocking
|
||||
vendor: juce
|
||||
version: 5.3.2
|
||||
name: JUCE Online marketplace support
|
||||
description: Classes for online product authentication
|
||||
website: http://www.juce.com/juce
|
||||
license: GPL/Commercial
|
||||
|
||||
dependencies: juce_cryptography juce_core
|
||||
|
||||
END_JUCE_MODULE_DECLARATION
|
||||
|
||||
*******************************************************************************/
|
||||
|
||||
|
||||
#pragma once
|
||||
#define JUCE_PRODUCT_UNLOCKING_H_INCLUDED
|
||||
|
||||
/**
|
||||
The juce_product_unlocking module provides simple user-registration classes
|
||||
for allowing you to build apps/plugins with features that are unlocked by a
|
||||
user having a suitable account on a webserver.
|
||||
|
||||
Although originally designed for use with products that are sold on the
|
||||
Tracktion Marketplace web-store, the module itself is fully open, and can
|
||||
be used to connect to your own web-store instead, if you implement your
|
||||
own compatible web-server back-end.
|
||||
|
||||
In additional, the module supports in-app purchases both on iOS and Android
|
||||
platforms.
|
||||
*/
|
||||
|
||||
//==============================================================================
|
||||
#include <juce_core/juce_core.h>
|
||||
#include <juce_cryptography/juce_cryptography.h>
|
||||
|
||||
#if JUCE_MODULE_AVAILABLE_juce_data_structures
|
||||
#include <juce_data_structures/juce_data_structures.h>
|
||||
#endif
|
||||
|
||||
#if JUCE_MODULE_AVAILABLE_juce_gui_extra
|
||||
#include <juce_gui_extra/juce_gui_extra.h>
|
||||
#endif
|
||||
|
||||
#if JUCE_IN_APP_PURCHASES
|
||||
#include "in_app_purchases/juce_InAppPurchases.h"
|
||||
#endif
|
||||
|
||||
#if JUCE_MODULE_AVAILABLE_juce_data_structures
|
||||
#include "marketplace/juce_OnlineUnlockStatus.h"
|
||||
#include "marketplace/juce_TracktionMarketplaceStatus.h"
|
||||
#endif
|
||||
|
||||
#include "marketplace/juce_KeyFileGeneration.h"
|
||||
|
||||
#if JUCE_MODULE_AVAILABLE_juce_gui_extra
|
||||
#include "marketplace/juce_OnlineUnlockForm.h"
|
||||
#endif
|
23
modules/juce_product_unlocking/juce_product_unlocking.mm
Normal file
23
modules/juce_product_unlocking/juce_product_unlocking.mm
Normal file
@ -0,0 +1,23 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
The code included in this file is provided under the terms of the ISC license
|
||||
http://www.isc.org/downloads/software-support-policy/isc-license. Permission
|
||||
To use, copy, modify, and/or distribute this software for any purpose with or
|
||||
without fee is hereby granted provided that the above copyright notice and
|
||||
this permission notice appear in all copies.
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
#include "juce_product_unlocking.cpp"
|
@ -0,0 +1,116 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
namespace juce
|
||||
{
|
||||
|
||||
/**
|
||||
Contains static utilities for generating key-files that can be unlocked by
|
||||
the OnlineUnlockStatus class.
|
||||
|
||||
@tags{ProductUnlocking}
|
||||
*/
|
||||
class JUCE_API KeyGeneration
|
||||
{
|
||||
public:
|
||||
/**
|
||||
Generates the content of a key-file which can be sent to a user's machine to
|
||||
unlock a product.
|
||||
|
||||
The returned value is a block of text containing an RSA-encoded block, followed
|
||||
by some human-readable details. If you pass this block of text to
|
||||
OnlineUnlockStatus::applyKeyFile(), it will decrypt it, and if the
|
||||
key matches and the machine numbers match, it will unlock that machine.
|
||||
|
||||
Typically the way you'd use this on a server would be to build a small executable
|
||||
that simply calls this method and prints the result, so that the webserver can
|
||||
use this as a reply to the product's auto-registration mechanism. The
|
||||
keyGenerationAppMain() function is an example of how to build such a function.
|
||||
|
||||
@see OnlineUnlockStatus
|
||||
*/
|
||||
static String JUCE_CALLTYPE generateKeyFile (const String& appName,
|
||||
const String& userEmail,
|
||||
const String& userName,
|
||||
const String& machineNumbers,
|
||||
const RSAKey& privateKey);
|
||||
|
||||
/** Similar to the above key file generation method but with an expiry time.
|
||||
You must supply a Time after which this key file should no longer be considered as active.
|
||||
|
||||
N.B. when an app is unlocked with an expiring key file, OnlineUnlockStatus::isUnlocked will
|
||||
still return false. You must then check OnlineUnlockStatus::getExpiryTime to see if this
|
||||
expiring key file is still in date and act accordingly.
|
||||
|
||||
@see OnlineUnlockStatus
|
||||
*/
|
||||
static String JUCE_CALLTYPE generateExpiringKeyFile (const String& appName,
|
||||
const String& userEmail,
|
||||
const String& userName,
|
||||
const String& machineNumbers,
|
||||
const Time expiryTime,
|
||||
const RSAKey& privateKey);
|
||||
|
||||
//==============================================================================
|
||||
/** This is a simple implementation of a key-generator that you could easily wrap in
|
||||
a command-line main() function for use on your server.
|
||||
|
||||
So for example you might use this in a command line app called "unlocker" and
|
||||
then call it like this:
|
||||
|
||||
unlocker MyGreatApp Joe_Bloggs joebloggs@foobar.com 1234abcd,95432ff 22d9aec92d986dd1,923ad49e9e7ff294c
|
||||
*/
|
||||
static inline int keyGenerationAppMain (int argc, char* argv[])
|
||||
{
|
||||
StringArray args;
|
||||
for (int i = 1; i < argc; ++i)
|
||||
args.add (argv[i]);
|
||||
|
||||
if (args.size() != 5)
|
||||
{
|
||||
std::cout << "Requires 5 arguments: app-name user-email username machine-numbers private-key" << std::endl
|
||||
<< " app-name: name of the product being unlocked" << std::endl
|
||||
<< " user-email: user's email address" << std::endl
|
||||
<< " username: name of the user. Careful not to allow any spaces!" << std::endl
|
||||
<< " machine-numbers: a comma- or semicolon-separated list of all machine ID strings this user can run this product on (no whitespace between items!)" << std::endl
|
||||
<< " private-key: the RSA private key corresponding to the public key you've used in the app" << std::endl
|
||||
<< std::endl;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (! args[4].containsChar (','))
|
||||
{
|
||||
std::cout << "Not a valid RSA key!" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << generateKeyFile (args[0], args[1], args[2], args[3], RSAKey (args[4])) << std::endl;
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace juce
|
@ -0,0 +1,320 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
namespace juce
|
||||
{
|
||||
|
||||
struct Spinner : public Component,
|
||||
private Timer
|
||||
{
|
||||
Spinner() { startTimer (1000 / 50); }
|
||||
void timerCallback() override { repaint(); }
|
||||
|
||||
void paint (Graphics& g) override
|
||||
{
|
||||
getLookAndFeel().drawSpinningWaitAnimation (g, Colours::darkgrey, 0, 0, getWidth(), getHeight());
|
||||
}
|
||||
};
|
||||
|
||||
struct OnlineUnlockForm::OverlayComp : public Component,
|
||||
private Thread,
|
||||
private Timer,
|
||||
private Button::Listener
|
||||
{
|
||||
OverlayComp (OnlineUnlockForm& f, bool hasCancelButton = false)
|
||||
: Thread (String()), form (f)
|
||||
{
|
||||
result.succeeded = false;
|
||||
email = form.emailBox.getText();
|
||||
password = form.passwordBox.getText();
|
||||
addAndMakeVisible (spinner);
|
||||
|
||||
if (hasCancelButton)
|
||||
{
|
||||
cancelButton.reset (new TextButton (TRANS ("Cancel")));
|
||||
addAndMakeVisible (cancelButton.get());
|
||||
cancelButton->addListener (this);
|
||||
}
|
||||
|
||||
startThread (4);
|
||||
}
|
||||
|
||||
~OverlayComp()
|
||||
{
|
||||
stopThread (10000);
|
||||
}
|
||||
|
||||
void paint (Graphics& g) override
|
||||
{
|
||||
g.fillAll (Colours::white.withAlpha (0.97f));
|
||||
|
||||
g.setColour (Colours::black);
|
||||
g.setFont (15.0f);
|
||||
|
||||
g.drawFittedText (TRANS("Contacting XYZ...").replace ("XYZ", form.status.getWebsiteName()),
|
||||
getLocalBounds().reduced (20, 0).removeFromTop (proportionOfHeight (0.6f)),
|
||||
Justification::centred, 5);
|
||||
}
|
||||
|
||||
void resized() override
|
||||
{
|
||||
const int spinnerSize = 40;
|
||||
spinner.setBounds ((getWidth() - spinnerSize) / 2, proportionOfHeight (0.6f), spinnerSize, spinnerSize);
|
||||
|
||||
if (cancelButton != nullptr)
|
||||
cancelButton->setBounds (getLocalBounds().removeFromBottom (50).reduced (getWidth() / 4, 5));
|
||||
}
|
||||
|
||||
void run() override
|
||||
{
|
||||
result = form.status.attemptWebserverUnlock (email, password);
|
||||
startTimer (100);
|
||||
}
|
||||
|
||||
void timerCallback() override
|
||||
{
|
||||
spinner.setVisible (false);
|
||||
stopTimer();
|
||||
|
||||
if (result.errorMessage.isNotEmpty())
|
||||
{
|
||||
AlertWindow::showMessageBoxAsync (AlertWindow::WarningIcon,
|
||||
TRANS("Registration Failed"),
|
||||
result.errorMessage);
|
||||
}
|
||||
else if (result.informativeMessage.isNotEmpty())
|
||||
{
|
||||
AlertWindow::showMessageBoxAsync (AlertWindow::InfoIcon,
|
||||
TRANS("Registration Complete!"),
|
||||
result.informativeMessage);
|
||||
}
|
||||
else if (result.urlToLaunch.isNotEmpty())
|
||||
{
|
||||
URL url (result.urlToLaunch);
|
||||
url.launchInDefaultBrowser();
|
||||
}
|
||||
|
||||
// (local copies because we're about to delete this)
|
||||
const bool worked = result.succeeded;
|
||||
OnlineUnlockForm& f = form;
|
||||
|
||||
delete this;
|
||||
|
||||
if (worked)
|
||||
f.dismiss();
|
||||
}
|
||||
|
||||
void buttonClicked (Button* button) override
|
||||
{
|
||||
if (button == cancelButton.get())
|
||||
{
|
||||
form.status.userCancelled();
|
||||
|
||||
spinner.setVisible (false);
|
||||
stopTimer();
|
||||
|
||||
delete this;
|
||||
}
|
||||
}
|
||||
|
||||
OnlineUnlockForm& form;
|
||||
Spinner spinner;
|
||||
OnlineUnlockStatus::UnlockResult result;
|
||||
String email, password;
|
||||
|
||||
std::unique_ptr<TextButton> cancelButton;
|
||||
|
||||
JUCE_LEAK_DETECTOR (OnlineUnlockForm::OverlayComp)
|
||||
};
|
||||
|
||||
static juce_wchar getDefaultPasswordChar() noexcept
|
||||
{
|
||||
#if JUCE_LINUX
|
||||
return 0x2022;
|
||||
#else
|
||||
return 0x25cf;
|
||||
#endif
|
||||
}
|
||||
|
||||
OnlineUnlockForm::OnlineUnlockForm (OnlineUnlockStatus& s,
|
||||
const String& userInstructions,
|
||||
bool hasCancelButton,
|
||||
bool overlayHasCancelButton)
|
||||
: message (String(), userInstructions),
|
||||
passwordBox (String(), getDefaultPasswordChar()),
|
||||
registerButton (TRANS("Register")),
|
||||
cancelButton (TRANS ("Cancel")),
|
||||
status (s),
|
||||
showOverlayCancelButton (overlayHasCancelButton)
|
||||
{
|
||||
// Please supply a message to tell your users what to do!
|
||||
jassert (userInstructions.isNotEmpty());
|
||||
|
||||
setOpaque (true);
|
||||
|
||||
emailBox.setText (status.getUserEmail());
|
||||
message.setJustificationType (Justification::centred);
|
||||
|
||||
addAndMakeVisible (message);
|
||||
addAndMakeVisible (emailBox);
|
||||
addAndMakeVisible (passwordBox);
|
||||
addAndMakeVisible (registerButton);
|
||||
|
||||
if (hasCancelButton)
|
||||
addAndMakeVisible (cancelButton);
|
||||
|
||||
emailBox.setEscapeAndReturnKeysConsumed (false);
|
||||
passwordBox.setEscapeAndReturnKeysConsumed (false);
|
||||
|
||||
registerButton.addShortcut (KeyPress (KeyPress::returnKey));
|
||||
|
||||
registerButton.addListener (this);
|
||||
cancelButton.addListener (this);
|
||||
|
||||
lookAndFeelChanged();
|
||||
setSize (500, 250);
|
||||
}
|
||||
|
||||
OnlineUnlockForm::~OnlineUnlockForm()
|
||||
{
|
||||
unlockingOverlay.deleteAndZero();
|
||||
}
|
||||
|
||||
void OnlineUnlockForm::paint (Graphics& g)
|
||||
{
|
||||
g.fillAll (Colours::lightgrey);
|
||||
}
|
||||
|
||||
void OnlineUnlockForm::resized()
|
||||
{
|
||||
/* If you're writing a plugin, then DO NOT USE A POP-UP A DIALOG WINDOW!
|
||||
Plugins that create external windows are incredibly annoying for users, and
|
||||
cause all sorts of headaches for hosts. Don't be the person who writes that
|
||||
plugin that irritates everyone with a nagging dialog box every time they scan!
|
||||
*/
|
||||
jassert (JUCEApplicationBase::isStandaloneApp() || findParentComponentOfClass<DialogWindow>() == nullptr);
|
||||
|
||||
const int buttonHeight = 22;
|
||||
|
||||
Rectangle<int> r (getLocalBounds().reduced (10, 20));
|
||||
|
||||
Rectangle<int> buttonArea (r.removeFromBottom (buttonHeight));
|
||||
registerButton.changeWidthToFitText (buttonHeight);
|
||||
cancelButton.changeWidthToFitText (buttonHeight);
|
||||
|
||||
const int gap = 20;
|
||||
buttonArea = buttonArea.withSizeKeepingCentre (registerButton.getWidth()
|
||||
+ (cancelButton.isVisible() ? gap + cancelButton.getWidth() : 0),
|
||||
buttonHeight);
|
||||
registerButton.setBounds (buttonArea.removeFromLeft (registerButton.getWidth()));
|
||||
buttonArea.removeFromLeft (gap);
|
||||
cancelButton.setBounds (buttonArea);
|
||||
|
||||
r.removeFromBottom (20);
|
||||
|
||||
// (force use of a default system font to make sure it has the password blob character)
|
||||
Font font (Font::getDefaultTypefaceForFont (Font (Font::getDefaultSansSerifFontName(),
|
||||
Font::getDefaultStyle(),
|
||||
5.0f)));
|
||||
|
||||
const int boxHeight = 24;
|
||||
passwordBox.setBounds (r.removeFromBottom (boxHeight));
|
||||
passwordBox.setInputRestrictions (64);
|
||||
passwordBox.setFont (font);
|
||||
|
||||
r.removeFromBottom (20);
|
||||
emailBox.setBounds (r.removeFromBottom (boxHeight));
|
||||
emailBox.setInputRestrictions (512);
|
||||
emailBox.setFont (font);
|
||||
|
||||
r.removeFromBottom (20);
|
||||
|
||||
message.setBounds (r);
|
||||
|
||||
if (unlockingOverlay != nullptr)
|
||||
unlockingOverlay->setBounds (getLocalBounds());
|
||||
}
|
||||
|
||||
void OnlineUnlockForm::lookAndFeelChanged()
|
||||
{
|
||||
Colour labelCol (findColour (TextEditor::backgroundColourId).contrasting (0.5f));
|
||||
|
||||
emailBox.setTextToShowWhenEmpty (TRANS("Email Address"), labelCol);
|
||||
passwordBox.setTextToShowWhenEmpty (TRANS("Password"), labelCol);
|
||||
}
|
||||
|
||||
void OnlineUnlockForm::showBubbleMessage (const String& text, Component& target)
|
||||
{
|
||||
bubble.reset (new BubbleMessageComponent (500));
|
||||
addChildComponent (bubble.get());
|
||||
|
||||
AttributedString attString;
|
||||
attString.append (text, Font (16.0f));
|
||||
|
||||
bubble->showAt (getLocalArea (&target, target.getLocalBounds()),
|
||||
attString, 500, // numMillisecondsBeforeRemoving
|
||||
true, // removeWhenMouseClicked
|
||||
false); // deleteSelfAfterUse
|
||||
}
|
||||
|
||||
void OnlineUnlockForm::buttonClicked (Button* b)
|
||||
{
|
||||
if (b == ®isterButton)
|
||||
attemptRegistration();
|
||||
else if (b == &cancelButton)
|
||||
dismiss();
|
||||
}
|
||||
|
||||
void OnlineUnlockForm::attemptRegistration()
|
||||
{
|
||||
if (unlockingOverlay == nullptr)
|
||||
{
|
||||
if (emailBox.getText().trim().length() < 3)
|
||||
{
|
||||
showBubbleMessage (TRANS ("Please enter a valid email address!"), emailBox);
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordBox.getText().trim().length() < 3)
|
||||
{
|
||||
showBubbleMessage (TRANS ("Please enter a valid password!"), passwordBox);
|
||||
return;
|
||||
}
|
||||
|
||||
status.setUserEmail (emailBox.getText());
|
||||
|
||||
addAndMakeVisible (unlockingOverlay = new OverlayComp (*this, showOverlayCancelButton));
|
||||
resized();
|
||||
unlockingOverlay->enterModalState();
|
||||
}
|
||||
}
|
||||
|
||||
void OnlineUnlockForm::dismiss()
|
||||
{
|
||||
delete this;
|
||||
}
|
||||
|
||||
} // namespace juce
|
@ -0,0 +1,100 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
namespace juce
|
||||
{
|
||||
|
||||
/** Acts as a GUI which asks the user for their details, and calls the approriate
|
||||
methods on your OnlineUnlockStatus object to attempt to register the app.
|
||||
|
||||
You should create one of these components and add it to your parent window,
|
||||
or use a DialogWindow to display it as a pop-up. But if you're writing a plugin,
|
||||
then DO NOT USE A DIALOG WINDOW! Add it as a child component of your plugin's editor
|
||||
component instead. Plugins that pop up external registration windows are incredibly
|
||||
annoying, and cause all sorts of headaches for hosts. Don't be the person who
|
||||
writes that plugin that irritates everyone with a dialog box every time they
|
||||
try to scan for new plugins!
|
||||
|
||||
Note that after adding it, you should put the component into a modal state,
|
||||
and it will automatically delete itself when it has completed.
|
||||
|
||||
Although it deletes itself, it's also OK to delete it manually yourself
|
||||
if you need to get rid of it sooner.
|
||||
|
||||
@see OnlineUnlockStatus
|
||||
|
||||
@tags{ProductUnlocking}
|
||||
*/
|
||||
class JUCE_API OnlineUnlockForm : public Component,
|
||||
private Button::Listener
|
||||
{
|
||||
public:
|
||||
/** Creates an unlock form that will work with the given status object.
|
||||
The userInstructions will be displayed above the email and password boxes.
|
||||
*/
|
||||
OnlineUnlockForm (OnlineUnlockStatus&,
|
||||
const String& userInstructions,
|
||||
bool hasCancelButton = true,
|
||||
bool overlayHasCancelButton = false);
|
||||
|
||||
/** Destructor. */
|
||||
~OnlineUnlockForm();
|
||||
|
||||
/** This is called when the form is dismissed (either cancelled or when registration
|
||||
succeeds).
|
||||
By default it will delete this, but you can override it to do other things.
|
||||
*/
|
||||
virtual void dismiss();
|
||||
|
||||
/** @internal */
|
||||
void paint (Graphics&) override;
|
||||
/** @internal */
|
||||
void resized() override;
|
||||
/** @internal */
|
||||
void lookAndFeelChanged() override;
|
||||
|
||||
Label message;
|
||||
TextEditor emailBox, passwordBox;
|
||||
TextButton registerButton, cancelButton;
|
||||
|
||||
private:
|
||||
OnlineUnlockStatus& status;
|
||||
std::unique_ptr<BubbleMessageComponent> bubble;
|
||||
|
||||
bool showOverlayCancelButton;
|
||||
|
||||
struct OverlayComp;
|
||||
friend struct OverlayComp;
|
||||
Component::SafePointer<Component> unlockingOverlay;
|
||||
|
||||
void buttonClicked (Button*) override;
|
||||
void attemptRegistration();
|
||||
void showBubbleMessage (const String&, Component&);
|
||||
|
||||
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (OnlineUnlockForm)
|
||||
};
|
||||
|
||||
} // namespace juce
|
@ -0,0 +1,496 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
namespace juce
|
||||
{
|
||||
|
||||
/* Note: there's a bit of light obfuscation in this code, just to make things
|
||||
a bit more annoying for crackers who try to reverse-engineer your binaries, but
|
||||
nothing particularly foolproof.
|
||||
*/
|
||||
|
||||
struct KeyFileUtils
|
||||
{
|
||||
static XmlElement createKeyFileContent (const String& appName,
|
||||
const String& userEmail,
|
||||
const String& userName,
|
||||
const String& machineNumbers,
|
||||
const String& machineNumbersAttributeName)
|
||||
{
|
||||
XmlElement xml ("key");
|
||||
|
||||
xml.setAttribute ("user", userName);
|
||||
xml.setAttribute ("email", userEmail);
|
||||
xml.setAttribute (machineNumbersAttributeName, machineNumbers);
|
||||
xml.setAttribute ("app", appName);
|
||||
xml.setAttribute ("date", String::toHexString (Time::getCurrentTime().toMilliseconds()));
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
static String createKeyFileComment (const String& appName,
|
||||
const String& userEmail,
|
||||
const String& userName,
|
||||
const String& machineNumbers)
|
||||
{
|
||||
String comment;
|
||||
comment << "Keyfile for " << appName << newLine;
|
||||
|
||||
if (userName.isNotEmpty())
|
||||
comment << "User: " << userName << newLine;
|
||||
|
||||
comment << "Email: " << userEmail << newLine
|
||||
<< "Machine numbers: " << machineNumbers << newLine
|
||||
<< "Created: " << Time::getCurrentTime().toString (true, true);
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
static String encryptXML (const XmlElement& xml, RSAKey privateKey)
|
||||
{
|
||||
MemoryOutputStream text;
|
||||
text << xml.createDocument (StringRef(), true);
|
||||
|
||||
BigInteger val;
|
||||
val.loadFromMemoryBlock (text.getMemoryBlock());
|
||||
|
||||
privateKey.applyToValue (val);
|
||||
|
||||
return val.toString (16);
|
||||
}
|
||||
|
||||
static String createKeyFile (String comment,
|
||||
const XmlElement& xml,
|
||||
RSAKey rsaPrivateKey)
|
||||
{
|
||||
String asHex ("#" + encryptXML (xml, rsaPrivateKey));
|
||||
|
||||
StringArray lines;
|
||||
lines.add (comment);
|
||||
lines.add (String());
|
||||
|
||||
const int charsPerLine = 70;
|
||||
while (asHex.length() > 0)
|
||||
{
|
||||
lines.add (asHex.substring (0, charsPerLine));
|
||||
asHex = asHex.substring (charsPerLine);
|
||||
}
|
||||
|
||||
lines.add (String());
|
||||
|
||||
return lines.joinIntoString ("\r\n");
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
static XmlElement decryptXML (String hexData, RSAKey rsaPublicKey)
|
||||
{
|
||||
BigInteger val;
|
||||
val.parseString (hexData, 16);
|
||||
|
||||
RSAKey key (rsaPublicKey);
|
||||
jassert (key.isValid());
|
||||
|
||||
std::unique_ptr<XmlElement> xml;
|
||||
|
||||
if (! val.isZero())
|
||||
{
|
||||
key.applyToValue (val);
|
||||
|
||||
const MemoryBlock mb (val.toMemoryBlock());
|
||||
|
||||
if (CharPointer_UTF8::isValidString (static_cast<const char*> (mb.getData()), (int) mb.getSize()))
|
||||
xml.reset (XmlDocument::parse (mb.toString()));
|
||||
}
|
||||
|
||||
return xml != nullptr ? *xml : XmlElement("key");
|
||||
}
|
||||
|
||||
static XmlElement getXmlFromKeyFile (String keyFileText, RSAKey rsaPublicKey)
|
||||
{
|
||||
return decryptXML (keyFileText.fromLastOccurrenceOf ("#", false, false).trim(), rsaPublicKey);
|
||||
}
|
||||
|
||||
static StringArray getMachineNumbers (XmlElement xml, StringRef attributeName)
|
||||
{
|
||||
StringArray numbers;
|
||||
numbers.addTokens (xml.getStringAttribute (attributeName), ",; ", StringRef());
|
||||
numbers.trim();
|
||||
numbers.removeEmptyStrings();
|
||||
return numbers;
|
||||
}
|
||||
|
||||
static String getLicensee (const XmlElement& xml) { return xml.getStringAttribute ("user"); }
|
||||
static String getEmail (const XmlElement& xml) { return xml.getStringAttribute ("email"); }
|
||||
static String getAppID (const XmlElement& xml) { return xml.getStringAttribute ("app"); }
|
||||
|
||||
struct KeyFileData
|
||||
{
|
||||
String licensee, email, appID;
|
||||
StringArray machineNumbers;
|
||||
|
||||
bool keyFileExpires;
|
||||
Time expiryTime;
|
||||
};
|
||||
|
||||
static KeyFileData getDataFromKeyFile (XmlElement xml)
|
||||
{
|
||||
KeyFileData data;
|
||||
|
||||
data.licensee = getLicensee (xml);
|
||||
data.email = getEmail (xml);
|
||||
data.appID = getAppID (xml);
|
||||
|
||||
if (xml.hasAttribute ("expiryTime") && xml.hasAttribute ("expiring_mach"))
|
||||
{
|
||||
data.keyFileExpires = true;
|
||||
data.machineNumbers.addArray (getMachineNumbers (xml, "expiring_mach"));
|
||||
data.expiryTime = Time (xml.getStringAttribute ("expiryTime").getHexValue64());
|
||||
}
|
||||
else
|
||||
{
|
||||
data.keyFileExpires = false;
|
||||
data.machineNumbers.addArray (getMachineNumbers (xml, "mach"));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
#if JUCE_MODULE_AVAILABLE_juce_data_structures
|
||||
const char* OnlineUnlockStatus::unlockedProp = "u";
|
||||
const char* OnlineUnlockStatus::expiryTimeProp = "t";
|
||||
static const char* stateTagName = "REG";
|
||||
static const char* userNameProp = "user";
|
||||
static const char* keyfileDataProp = "key";
|
||||
|
||||
static var machineNumberAllowed (StringArray numbersFromKeyFile,
|
||||
StringArray localMachineNumbers)
|
||||
{
|
||||
var result;
|
||||
|
||||
for (int i = 0; i < localMachineNumbers.size(); ++i)
|
||||
{
|
||||
String localNumber (localMachineNumbers[i].trim());
|
||||
|
||||
if (localNumber.isNotEmpty())
|
||||
{
|
||||
for (int j = numbersFromKeyFile.size(); --j >= 0;)
|
||||
{
|
||||
var ok (localNumber.trim().equalsIgnoreCase (numbersFromKeyFile[j].trim()));
|
||||
result.swapWith (ok);
|
||||
|
||||
if (result)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
OnlineUnlockStatus::OnlineUnlockStatus() : status (stateTagName)
|
||||
{
|
||||
}
|
||||
|
||||
OnlineUnlockStatus::~OnlineUnlockStatus()
|
||||
{
|
||||
}
|
||||
|
||||
void OnlineUnlockStatus::load()
|
||||
{
|
||||
MemoryBlock mb;
|
||||
mb.fromBase64Encoding (getState());
|
||||
|
||||
if (mb.getSize() > 0)
|
||||
status = ValueTree::readFromGZIPData (mb.getData(), mb.getSize());
|
||||
else
|
||||
status = ValueTree (stateTagName);
|
||||
|
||||
StringArray localMachineNums (getLocalMachineIDs());
|
||||
|
||||
if (machineNumberAllowed (StringArray ("1234"), localMachineNums))
|
||||
status.removeProperty (unlockedProp, nullptr);
|
||||
|
||||
KeyFileUtils::KeyFileData data;
|
||||
data = KeyFileUtils::getDataFromKeyFile (KeyFileUtils::getXmlFromKeyFile (status[keyfileDataProp], getPublicKey()));
|
||||
|
||||
if (data.keyFileExpires)
|
||||
{
|
||||
if (! doesProductIDMatch (data.appID))
|
||||
status.removeProperty (expiryTimeProp, nullptr);
|
||||
|
||||
if (! machineNumberAllowed (data.machineNumbers, localMachineNums))
|
||||
status.removeProperty (expiryTimeProp, nullptr);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (! doesProductIDMatch (data.appID))
|
||||
status.removeProperty (unlockedProp, nullptr);
|
||||
|
||||
if (! machineNumberAllowed (data.machineNumbers, localMachineNums))
|
||||
status.removeProperty (unlockedProp, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
void OnlineUnlockStatus::save()
|
||||
{
|
||||
MemoryOutputStream mo;
|
||||
|
||||
{
|
||||
GZIPCompressorOutputStream gzipStream (mo, 9);
|
||||
status.writeToStream (gzipStream);
|
||||
}
|
||||
|
||||
saveState (mo.getMemoryBlock().toBase64Encoding());
|
||||
}
|
||||
|
||||
char OnlineUnlockStatus::MachineIDUtilities::getPlatformPrefix()
|
||||
{
|
||||
#if JUCE_MAC
|
||||
return 'M';
|
||||
#elif JUCE_WINDOWS
|
||||
return 'W';
|
||||
#elif JUCE_LINUX
|
||||
return 'L';
|
||||
#elif JUCE_IOS
|
||||
return 'I';
|
||||
#elif JUCE_ANDROID
|
||||
return 'A';
|
||||
#endif
|
||||
}
|
||||
|
||||
String OnlineUnlockStatus::MachineIDUtilities::getEncodedIDString (const String& input)
|
||||
{
|
||||
const String platform (String::charToString (static_cast<juce_wchar> (getPlatformPrefix())));
|
||||
|
||||
return platform + MD5 ((input + "salt_1" + platform).toUTF8())
|
||||
.toHexString().substring (0, 9).toUpperCase();
|
||||
}
|
||||
|
||||
bool OnlineUnlockStatus::MachineIDUtilities::addFileIDToList (StringArray& ids, const File& f)
|
||||
{
|
||||
if (uint64 num = f.getFileIdentifier())
|
||||
{
|
||||
ids.add (getEncodedIDString (String::toHexString ((int64) num)));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void OnlineUnlockStatus::MachineIDUtilities::addMACAddressesToList (StringArray& ids)
|
||||
{
|
||||
Array<MACAddress> addresses;
|
||||
MACAddress::findAllAddresses (addresses);
|
||||
|
||||
for (int i = 0; i < addresses.size(); ++i)
|
||||
ids.add (getEncodedIDString (addresses.getReference(i).toString()));
|
||||
}
|
||||
|
||||
StringArray OnlineUnlockStatus::MachineIDUtilities::getLocalMachineIDs()
|
||||
{
|
||||
auto identifiers = SystemStats::getDeviceIdentifiers();
|
||||
for (auto& identifier : identifiers)
|
||||
identifier = getEncodedIDString (identifier);
|
||||
|
||||
return identifiers;
|
||||
}
|
||||
|
||||
StringArray OnlineUnlockStatus::getLocalMachineIDs()
|
||||
{
|
||||
return MachineIDUtilities::getLocalMachineIDs();
|
||||
}
|
||||
|
||||
void OnlineUnlockStatus::userCancelled()
|
||||
{
|
||||
}
|
||||
|
||||
void OnlineUnlockStatus::setUserEmail (const String& usernameOrEmail)
|
||||
{
|
||||
status.setProperty (userNameProp, usernameOrEmail, nullptr);
|
||||
}
|
||||
|
||||
String OnlineUnlockStatus::getUserEmail() const
|
||||
{
|
||||
return status[userNameProp].toString();
|
||||
}
|
||||
|
||||
bool OnlineUnlockStatus::applyKeyFile (String keyFileContent)
|
||||
{
|
||||
KeyFileUtils::KeyFileData data;
|
||||
data = KeyFileUtils::getDataFromKeyFile (KeyFileUtils::getXmlFromKeyFile (keyFileContent, getPublicKey()));
|
||||
|
||||
if (data.licensee.isNotEmpty() && data.email.isNotEmpty() && doesProductIDMatch (data.appID))
|
||||
{
|
||||
setUserEmail (data.email);
|
||||
status.setProperty (keyfileDataProp, keyFileContent, nullptr);
|
||||
status.removeProperty (data.keyFileExpires ? expiryTimeProp : unlockedProp, nullptr);
|
||||
|
||||
var actualResult (0), dummyResult (1.0);
|
||||
var v (machineNumberAllowed (data.machineNumbers, getLocalMachineIDs()));
|
||||
actualResult.swapWith (v);
|
||||
v = machineNumberAllowed (StringArray ("01"), getLocalMachineIDs());
|
||||
dummyResult.swapWith (v);
|
||||
jassert (! dummyResult);
|
||||
|
||||
if (data.keyFileExpires)
|
||||
{
|
||||
if ((! dummyResult) && actualResult)
|
||||
status.setProperty (expiryTimeProp, data.expiryTime.toMilliseconds(), nullptr);
|
||||
|
||||
return getExpiryTime().toMilliseconds() > 0;
|
||||
}
|
||||
|
||||
if ((! dummyResult) && actualResult)
|
||||
status.setProperty (unlockedProp, actualResult, nullptr);
|
||||
|
||||
return isUnlocked();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool canConnectToWebsite (const URL& url)
|
||||
{
|
||||
std::unique_ptr<InputStream> in (url.createInputStream (false, nullptr, nullptr, String(), 2000, nullptr));
|
||||
return in != nullptr;
|
||||
}
|
||||
|
||||
static bool areMajorWebsitesAvailable()
|
||||
{
|
||||
const char* urlsToTry[] = { "http://google.com", "http://bing.com", "http://amazon.com",
|
||||
"https://google.com", "https://bing.com", "https://amazon.com", nullptr};
|
||||
|
||||
for (const char** url = urlsToTry; *url != nullptr; ++url)
|
||||
if (canConnectToWebsite (URL (*url)))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
OnlineUnlockStatus::UnlockResult OnlineUnlockStatus::handleXmlReply (XmlElement xml)
|
||||
{
|
||||
UnlockResult r;
|
||||
|
||||
if (const XmlElement* keyNode = xml.getChildByName ("KEY"))
|
||||
{
|
||||
const String keyText (keyNode->getAllSubText().trim());
|
||||
r.succeeded = keyText.length() > 10 && applyKeyFile (keyText);
|
||||
}
|
||||
else
|
||||
{
|
||||
r.succeeded = false;
|
||||
}
|
||||
|
||||
if (xml.hasTagName ("MESSAGE"))
|
||||
r.informativeMessage = xml.getStringAttribute ("message").trim();
|
||||
|
||||
if (xml.hasTagName ("ERROR"))
|
||||
r.errorMessage = xml.getStringAttribute ("error").trim();
|
||||
|
||||
if (xml.getStringAttribute ("url").isNotEmpty())
|
||||
r.urlToLaunch = xml.getStringAttribute ("url").trim();
|
||||
|
||||
if (r.errorMessage.isEmpty() && r.informativeMessage.isEmpty() && r.urlToLaunch.isEmpty() && ! r.succeeded)
|
||||
r.errorMessage = TRANS ("Unexpected or corrupted reply from XYZ").replace ("XYZ", getWebsiteName()) + "...\n\n"
|
||||
+ TRANS("Please try again in a few minutes, and contact us for support if this message appears again.");
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
OnlineUnlockStatus::UnlockResult OnlineUnlockStatus::handleFailedConnection()
|
||||
{
|
||||
UnlockResult r;
|
||||
r.succeeded = false;
|
||||
|
||||
r.errorMessage = TRANS("Couldn't connect to XYZ").replace ("XYZ", getWebsiteName()) + "...\n\n";
|
||||
|
||||
if (areMajorWebsitesAvailable())
|
||||
r.errorMessage << TRANS("Your internet connection seems to be OK, but our webserver "
|
||||
"didn't respond... This is most likely a temporary problem, so try "
|
||||
"again in a few minutes, but if it persists, please contact us for support!");
|
||||
else
|
||||
r.errorMessage << TRANS("No internet sites seem to be accessible from your computer.. Before trying again, "
|
||||
"please check that your network is working correctly, and make sure "
|
||||
"that any firewall/security software installed on your machine isn't "
|
||||
"blocking your web connection.");
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
OnlineUnlockStatus::UnlockResult OnlineUnlockStatus::attemptWebserverUnlock (const String& email,
|
||||
const String& password)
|
||||
{
|
||||
// This method will block while it contacts the server, so you must run it on a background thread!
|
||||
jassert (! MessageManager::getInstance()->isThisTheMessageThread());
|
||||
|
||||
String reply (readReplyFromWebserver (email, password));
|
||||
|
||||
DBG ("Reply from server: " << reply);
|
||||
|
||||
std::unique_ptr<XmlElement> xml (XmlDocument::parse (reply));
|
||||
|
||||
if (xml != nullptr)
|
||||
return handleXmlReply (*xml);
|
||||
|
||||
return handleFailedConnection();
|
||||
}
|
||||
|
||||
#endif // JUCE_MODULE_AVAILABLE_juce_data_structures
|
||||
|
||||
//==============================================================================
|
||||
String KeyGeneration::generateKeyFile (const String& appName,
|
||||
const String& userEmail,
|
||||
const String& userName,
|
||||
const String& machineNumbers,
|
||||
const RSAKey& privateKey)
|
||||
{
|
||||
XmlElement xml (KeyFileUtils::createKeyFileContent (appName, userEmail, userName, machineNumbers, "mach"));
|
||||
const String comment (KeyFileUtils::createKeyFileComment (appName, userEmail, userName, machineNumbers));
|
||||
|
||||
return KeyFileUtils::createKeyFile (comment, xml, privateKey);
|
||||
}
|
||||
|
||||
String KeyGeneration::generateExpiringKeyFile (const String& appName,
|
||||
const String& userEmail,
|
||||
const String& userName,
|
||||
const String& machineNumbers,
|
||||
const Time expiryTime,
|
||||
const RSAKey& privateKey)
|
||||
{
|
||||
XmlElement xml (KeyFileUtils::createKeyFileContent (appName, userEmail, userName, machineNumbers, "expiring_mach"));
|
||||
xml.setAttribute ("expiryTime", String::toHexString (expiryTime.toMilliseconds()));
|
||||
|
||||
String comment (KeyFileUtils::createKeyFileComment (appName, userEmail, userName, machineNumbers));
|
||||
comment << newLine << "Expires: " << expiryTime.toString (true, true);
|
||||
|
||||
return KeyFileUtils::createKeyFile (comment, xml, privateKey);
|
||||
}
|
||||
|
||||
} // namespace juce
|
@ -0,0 +1,275 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
namespace juce
|
||||
{
|
||||
|
||||
/**
|
||||
A base class for online unlocking systems.
|
||||
|
||||
This class stores information about whether your app has been unlocked for the
|
||||
current machine, and handles communication with a web-store to perform the
|
||||
unlock procedure.
|
||||
|
||||
You probably won't ever use this base class directly, but rather a store-specific
|
||||
subclass such as TracktionMarketplaceStatus, which knows how to talk to the particular
|
||||
online store that you're using.
|
||||
|
||||
To use it, you create a subclass which implements all the pure virtual methods
|
||||
(see their comments to find out what you'll need to make them do).
|
||||
|
||||
Then you can create an instance of your subclass which will hold the registration
|
||||
state. Typically, you'll want to just keep a single instance of the class around for
|
||||
the duration of your app. You can then call its methods to handle the various
|
||||
registration tasks.
|
||||
|
||||
Areas of your code that need to know whether the user is registered (e.g. to decide
|
||||
whether a particular feature is available) should call isUnlocked() to find out.
|
||||
|
||||
If you want to create a GUI that allows your users to enter their details and
|
||||
register, see the OnlineUnlockForm class.
|
||||
|
||||
@see OnlineUnlockForm, KeyGeneration
|
||||
|
||||
@tags{ProductUnlocking}
|
||||
*/
|
||||
class JUCE_API OnlineUnlockStatus
|
||||
{
|
||||
public:
|
||||
OnlineUnlockStatus();
|
||||
|
||||
/** Destructor. */
|
||||
virtual ~OnlineUnlockStatus();
|
||||
|
||||
//==============================================================================
|
||||
/** This must return your product's ID, as allocated by the store. */
|
||||
virtual String getProductID() = 0;
|
||||
|
||||
/** This must check whether a product ID string that the server returned is OK for
|
||||
unlocking the current app.
|
||||
*/
|
||||
virtual bool doesProductIDMatch (const String& returnedIDFromServer) = 0;
|
||||
|
||||
/** This must return the RSA public key for authenticating responses from
|
||||
the server for this app. You can get this key from your marketplace
|
||||
account page.
|
||||
*/
|
||||
virtual RSAKey getPublicKey() = 0;
|
||||
|
||||
/** This method must store the given string somewhere in your app's
|
||||
persistent properties, so it can be retrieved later by getState().
|
||||
*/
|
||||
virtual void saveState (const String&) = 0;
|
||||
|
||||
/** This method must retrieve the last state that was provided by the
|
||||
saveState method.
|
||||
|
||||
On first-run, it should just return an empty string.
|
||||
*/
|
||||
virtual String getState() = 0;
|
||||
|
||||
/** Returns the name of the web-store website, not for communication, but for
|
||||
presenting to the user.
|
||||
*/
|
||||
virtual String getWebsiteName() = 0;
|
||||
|
||||
/** Returns the URL of the authentication API. */
|
||||
virtual URL getServerAuthenticationURL() = 0;
|
||||
|
||||
/** Subclasses that talk to a particular web-store will implement this method
|
||||
to contact their webserver and attempt to unlock the current machine for
|
||||
the given username and password. The return value is the XML text from the
|
||||
server which contains error information and/or the encrypted keyfile.
|
||||
*/
|
||||
virtual String readReplyFromWebserver (const String& email, const String& password) = 0;
|
||||
|
||||
/** Returns a list of strings, any of which should be unique to this
|
||||
physical computer.
|
||||
|
||||
When testing whether the user is allowed to use the product on this
|
||||
machine, this list of tokens is compared to the ones that were stored
|
||||
on the webserver.
|
||||
|
||||
The default implementation of this method will simply call
|
||||
MachineIDUtilities::getLocalMachineIDs(), which provides a default
|
||||
version of this functionality.
|
||||
*/
|
||||
virtual StringArray getLocalMachineIDs();
|
||||
|
||||
/** This method will be called if the user cancels the connection to the webserver
|
||||
by clicking the cancel button in OnlineUnlockForm::OverlayComp.
|
||||
|
||||
The default implementation of this method does nothing but you should use it to
|
||||
cancel any WebInputStreams that may be connecting.
|
||||
*/
|
||||
virtual void userCancelled();
|
||||
|
||||
//==============================================================================
|
||||
// The following methods can be called by your app:
|
||||
|
||||
/** Returns true if the product has been successfully authorised for this machine.
|
||||
|
||||
The reason it returns a variant rather than a bool is just to make it marginally
|
||||
more tedious for crackers to work around. Hopefully if this method gets inlined
|
||||
they'll need to hack all the places where you call it, rather than just the
|
||||
function itself.
|
||||
|
||||
Bear in mind that each place where you check this return value will need to be
|
||||
changed by a cracker in order to unlock your app, so the more places you call this
|
||||
method, the more hassle it will be for them to find and crack them all.
|
||||
*/
|
||||
inline var isUnlocked() const { return status[unlockedProp]; }
|
||||
|
||||
/** Returns the Time when the keyfile expires.
|
||||
|
||||
If a the key file obtained has an expiry time, isUnlocked will return false and this
|
||||
will return a non-zero time. The interpretation of this is up to your app but could
|
||||
be used for subscription based models or trial periods.
|
||||
*/
|
||||
inline Time getExpiryTime() const { return Time (static_cast<int64> (status[expiryTimeProp])); }
|
||||
|
||||
/** Optionally allows the app to provide the user's email address if
|
||||
it is known.
|
||||
You don't need to call this, but if you do it may save the user
|
||||
typing it in.
|
||||
*/
|
||||
void setUserEmail (const String& usernameOrEmail);
|
||||
|
||||
/** Returns the user's email address if known. */
|
||||
String getUserEmail() const;
|
||||
|
||||
/** Attempts to perform an unlock using a block of key-file data provided.
|
||||
You may wish to use this as a way of allowing a user to unlock your app
|
||||
by drag-and-dropping a file containing the key data, or by letting them
|
||||
select such a file. This is often needed for allowing registration on
|
||||
machines without internet access.
|
||||
*/
|
||||
bool applyKeyFile (String keyFileContent);
|
||||
|
||||
/** This provides some details about the reply that the server gave in a call
|
||||
to attemptWebserverUnlock().
|
||||
*/
|
||||
struct UnlockResult
|
||||
{
|
||||
/** If an unlock operation fails, this is the error message that the webserver
|
||||
supplied (or a message saying that the server couldn't be contacted)
|
||||
*/
|
||||
String errorMessage;
|
||||
|
||||
/** This is a message that the webserver returned, and which the user should
|
||||
be shown.
|
||||
|
||||
It's not necessarily an error message, e.g. it might say that there's a
|
||||
new version of the app available or some other status update.
|
||||
*/
|
||||
String informativeMessage;
|
||||
|
||||
/** If the webserver wants the user to be directed to a web-page for further
|
||||
information, this is the URL that it would like them to go to.
|
||||
*/
|
||||
String urlToLaunch;
|
||||
|
||||
/** If the unlock operation succeeded, this will be set to true. */
|
||||
bool succeeded;
|
||||
};
|
||||
|
||||
/** Contacts the webserver and attempts to perform a registration with the
|
||||
given user details.
|
||||
|
||||
The return value will either be a success, or a failure with an error message
|
||||
from the server, so you should show this message to your user.
|
||||
|
||||
Because this method blocks while it contacts the server, you must run it on
|
||||
a background thread, not on the message thread. For an easier way to create
|
||||
a GUI to do the unlocking, see OnlineUnlockForm.
|
||||
*/
|
||||
UnlockResult attemptWebserverUnlock (const String& email, const String& password);
|
||||
|
||||
/** Attempts to load the status from the state retrieved by getState().
|
||||
Call this somewhere in your app's startup code.
|
||||
*/
|
||||
void load();
|
||||
|
||||
/** Triggers a call to saveState which you can use to store the current unlock status
|
||||
in your app's settings.
|
||||
*/
|
||||
void save();
|
||||
|
||||
/** This class contains some utility functions that might help with machine ID generation. */
|
||||
struct MachineIDUtilities
|
||||
{
|
||||
/** Returns a character that represents the current OS.
|
||||
E.g. 'M' for Mac, 'W' for windows, etc
|
||||
*/
|
||||
static char getPlatformPrefix();
|
||||
|
||||
/** Returns an encoded hash string from the given input string, prefixing it with
|
||||
a letter to represent the current OS type.
|
||||
*/
|
||||
static String getEncodedIDString (const String& inputString);
|
||||
|
||||
/** Utility function that you may want to use in your machine-ID generation code.
|
||||
This adds an ID string to the given array which is a hash of the filesystem ID of the
|
||||
given file.
|
||||
*/
|
||||
static bool addFileIDToList (StringArray& result, const File& file);
|
||||
|
||||
/** Utility function that you may want to use in your machine-ID generation code.
|
||||
This adds some ID strings to the given array which represent each MAC address of the machine.
|
||||
*/
|
||||
static void addMACAddressesToList (StringArray& result);
|
||||
|
||||
/** This method calculates some machine IDs based on things like network
|
||||
MAC addresses, hard-disk IDs, etc, but if you want, you can overload
|
||||
it to generate your own list of IDs.
|
||||
|
||||
The IDs that are returned should be short alphanumeric strings
|
||||
without any punctuation characters. Since users may need to type
|
||||
them, case is ignored when comparing them.
|
||||
|
||||
Note that the first item in the list is considered to be the
|
||||
"main" ID, and this will be the one that is displayed to the user
|
||||
and registered with the marketplace webserver. Subsequent IDs are
|
||||
just used as fallback to avoid false negatives when checking for
|
||||
registration on machines which have had hardware added/removed
|
||||
since the product was first registered.
|
||||
*/
|
||||
static StringArray getLocalMachineIDs();
|
||||
};
|
||||
|
||||
private:
|
||||
ValueTree status;
|
||||
|
||||
UnlockResult handleXmlReply (XmlElement);
|
||||
UnlockResult handleFailedConnection();
|
||||
|
||||
static const char* unlockedProp;
|
||||
static const char* expiryTimeProp;
|
||||
|
||||
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (OnlineUnlockStatus)
|
||||
};
|
||||
|
||||
} // namespace juce
|
@ -0,0 +1,109 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
namespace juce
|
||||
{
|
||||
|
||||
TracktionMarketplaceStatus::TracktionMarketplaceStatus() {}
|
||||
|
||||
URL TracktionMarketplaceStatus::getServerAuthenticationURL()
|
||||
{
|
||||
return URL ("https://www.tracktion.com/marketplace/authenticate.php");
|
||||
}
|
||||
|
||||
String TracktionMarketplaceStatus::getWebsiteName()
|
||||
{
|
||||
return "tracktion.com";
|
||||
}
|
||||
|
||||
bool TracktionMarketplaceStatus::doesProductIDMatch (const String& returnedIDFromServer)
|
||||
{
|
||||
return getProductID() == returnedIDFromServer;
|
||||
}
|
||||
|
||||
String TracktionMarketplaceStatus::readReplyFromWebserver (const String& email, const String& password)
|
||||
{
|
||||
URL url (getServerAuthenticationURL()
|
||||
.withParameter ("product", getProductID())
|
||||
.withParameter ("email", email)
|
||||
.withParameter ("pw", password)
|
||||
.withParameter ("os", SystemStats::getOperatingSystemName())
|
||||
.withParameter ("mach", getLocalMachineIDs()[0]));
|
||||
|
||||
DBG ("Trying to unlock via URL: " << url.toString (true));
|
||||
|
||||
{
|
||||
ScopedLock lock (streamCreationLock);
|
||||
stream.reset (new WebInputStream (url, true));
|
||||
}
|
||||
|
||||
if (stream->connect (nullptr))
|
||||
{
|
||||
auto* thread = Thread::getCurrentThread();
|
||||
|
||||
if (thread->threadShouldExit() || stream->isError())
|
||||
return {};
|
||||
|
||||
auto contentLength = stream->getTotalLength();
|
||||
auto downloaded = 0;
|
||||
|
||||
const size_t bufferSize = 0x8000;
|
||||
HeapBlock<char> buffer (bufferSize);
|
||||
|
||||
while (! (stream->isExhausted() || stream->isError() || thread->threadShouldExit()))
|
||||
{
|
||||
auto max = jmin ((int) bufferSize, contentLength < 0 ? std::numeric_limits<int>::max()
|
||||
: static_cast<int> (contentLength - downloaded));
|
||||
|
||||
auto actualBytesRead = stream->read (buffer.get() + downloaded, max - downloaded);
|
||||
|
||||
if (actualBytesRead < 0 || thread->threadShouldExit() || stream->isError())
|
||||
break;
|
||||
|
||||
downloaded += actualBytesRead;
|
||||
|
||||
if (downloaded == contentLength)
|
||||
break;
|
||||
}
|
||||
|
||||
if (thread->threadShouldExit() || stream->isError() || (contentLength > 0 && downloaded < contentLength))
|
||||
return {};
|
||||
|
||||
return { CharPointer_UTF8 (buffer.get()) };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
void TracktionMarketplaceStatus::userCancelled()
|
||||
{
|
||||
ScopedLock lock (streamCreationLock);
|
||||
|
||||
if (stream != nullptr)
|
||||
stream->cancel();
|
||||
}
|
||||
|
||||
} // namespace juce
|
@ -0,0 +1,65 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
namespace juce
|
||||
{
|
||||
|
||||
/**
|
||||
An implementation of the OnlineUnlockStatus class which talks to the
|
||||
Tracktion Marketplace server.
|
||||
|
||||
For details about how to use this class, see the docs for the base
|
||||
class: OnlineUnlockStatus. Basically, you need to inherit from it, and
|
||||
implement all the pure virtual methods to tell it about your product.
|
||||
|
||||
@see OnlineUnlockStatus, OnlineUnlockForm, KeyGeneration
|
||||
|
||||
@tags{ProductUnlocking}
|
||||
*/
|
||||
class JUCE_API TracktionMarketplaceStatus : public OnlineUnlockStatus
|
||||
{
|
||||
public:
|
||||
TracktionMarketplaceStatus();
|
||||
|
||||
/** @internal */
|
||||
bool doesProductIDMatch (const String& returnedIDFromServer) override;
|
||||
/** @internal */
|
||||
URL getServerAuthenticationURL() override;
|
||||
/** @internal */
|
||||
String getWebsiteName() override;
|
||||
/** @internal */
|
||||
String readReplyFromWebserver (const String& email, const String& password) override;
|
||||
/** @internal */
|
||||
void userCancelled() override;
|
||||
|
||||
private:
|
||||
CriticalSection streamCreationLock;
|
||||
std::unique_ptr<WebInputStream> stream;
|
||||
|
||||
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (TracktionMarketplaceStatus)
|
||||
};
|
||||
|
||||
} // namespace juce
|
@ -0,0 +1,856 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
namespace juce
|
||||
{
|
||||
|
||||
#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \
|
||||
METHOD (isBillingSupported, "isBillingSupported", "(ILjava/lang/String;Ljava/lang/String;)I") \
|
||||
METHOD (getSkuDetails, "getSkuDetails", "(ILjava/lang/String;Ljava/lang/String;Landroid/os/Bundle;)Landroid/os/Bundle;") \
|
||||
METHOD (getBuyIntent, "getBuyIntent", "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Landroid/os/Bundle;") \
|
||||
METHOD (getBuyIntentExtraParams, "getBuyIntentExtraParams", "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Landroid/os/Bundle;)Landroid/os/Bundle;") \
|
||||
METHOD (getPurchases, "getPurchases", "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Landroid/os/Bundle;") \
|
||||
METHOD (consumePurchase, "consumePurchase", "(ILjava/lang/String;Ljava/lang/String;)I") \
|
||||
METHOD (getPurchaseHistory, "getPurchaseHistory", "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Landroid/os/Bundle;)Landroid/os/Bundle;")
|
||||
|
||||
DECLARE_JNI_CLASS (IInAppBillingService, "com/android/vending/billing/IInAppBillingService");
|
||||
#undef JNI_CLASS_MEMBERS
|
||||
|
||||
#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \
|
||||
STATICMETHOD (asInterface, "asInterface", "(Landroid/os/IBinder;)Lcom/android/vending/billing/IInAppBillingService;") \
|
||||
|
||||
DECLARE_JNI_CLASS (IInAppBillingServiceStub, "com/android/vending/billing/IInAppBillingService$Stub");
|
||||
#undef JNI_CLASS_MEMBERS
|
||||
|
||||
//==============================================================================
|
||||
struct ServiceConnection : public AndroidInterfaceImplementer
|
||||
{
|
||||
virtual void onServiceConnected (jobject component, jobject iBinder) = 0;
|
||||
virtual void onServiceDisconnected (jobject component) = 0;
|
||||
|
||||
jobject invoke (jobject proxy, jobject method, jobjectArray args) override
|
||||
{
|
||||
auto* env = getEnv();
|
||||
auto methodName = juceString ((jstring) env->CallObjectMethod (method, JavaMethod.getName));
|
||||
|
||||
if (methodName == "onServiceConnected")
|
||||
{
|
||||
onServiceConnected (env->GetObjectArrayElement (args, 0),
|
||||
env->GetObjectArrayElement (args, 1));
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (methodName == "onServiceDisconnected")
|
||||
{
|
||||
onServiceDisconnected (env->GetObjectArrayElement (args, 0));
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return AndroidInterfaceImplementer::invoke (proxy, method, args);
|
||||
}
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
struct InAppPurchases::Pimpl : private AsyncUpdater,
|
||||
private ServiceConnection
|
||||
{
|
||||
Pimpl (InAppPurchases& parent) : owner (parent)
|
||||
{
|
||||
auto* env = getEnv();
|
||||
auto intent = env->NewObject (AndroidIntent, AndroidIntent.constructWithString,
|
||||
javaString ("com.android.vending.billing.InAppBillingService.BIND").get());
|
||||
env->CallObjectMethod (intent, AndroidIntent.setPackage, javaString ("com.android.vending").get());
|
||||
|
||||
serviceConnection = GlobalRef (CreateJavaInterface (this, "android/content/ServiceConnection").get());
|
||||
android.activity.callBooleanMethod (JuceAppActivity.bindService, intent,
|
||||
serviceConnection.get(), 1 /*BIND_AUTO_CREATE*/);
|
||||
|
||||
if (threadPool == nullptr)
|
||||
threadPool.reset (new ThreadPool (1));
|
||||
}
|
||||
|
||||
~Pimpl()
|
||||
{
|
||||
threadPool = nullptr;
|
||||
|
||||
if (serviceConnection != nullptr)
|
||||
{
|
||||
android.activity.callVoidMethod (JuceAppActivity.unbindService, serviceConnection.get());
|
||||
serviceConnection.clear();
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
bool isInAppPurchasesSupported() { return isInAppPurchasesSupported (inAppBillingService); }
|
||||
|
||||
void getProductsInformation (const StringArray& productIdentifiers)
|
||||
{
|
||||
auto callback = [this](const Array<InAppPurchases::Product>& products)
|
||||
{
|
||||
const ScopedLock lock (getProductsInformationJobResultsLock);
|
||||
getProductsInformationJobResults.insert (0, products);
|
||||
triggerAsyncUpdate();
|
||||
};
|
||||
|
||||
threadPool->addJob (new GetProductsInformationJob (*this, getPackageName(),
|
||||
productIdentifiers, callback), true);
|
||||
}
|
||||
|
||||
void purchaseProduct (const String& productIdentifier, bool isSubscription,
|
||||
const StringArray& subscriptionIdentifiers, bool creditForUnusedSubscription)
|
||||
{
|
||||
// Upgrading/downgrading only makes sense for subscriptions!
|
||||
jassert (subscriptionIdentifiers.isEmpty() || isSubscription);
|
||||
|
||||
auto buyIntentBundle = getBuyIntentBundle (productIdentifier, isSubscription,
|
||||
subscriptionIdentifiers, creditForUnusedSubscription);
|
||||
auto* env = getEnv();
|
||||
|
||||
auto responseCodeString = javaString ("RESPONSE_CODE");
|
||||
auto responseCode = env->CallIntMethod (buyIntentBundle.get(), JavaBundle.getInt, responseCodeString.get());
|
||||
|
||||
if (responseCode == 0)
|
||||
{
|
||||
auto buyIntentString = javaString ("BUY_INTENT");
|
||||
auto pendingIntent = LocalRef<jobject> (env->CallObjectMethod (buyIntentBundle.get(), JavaBundle.getParcelable, buyIntentString.get()));
|
||||
|
||||
auto requestCode = 1001;
|
||||
auto intentSender = LocalRef<jobject> (env->CallObjectMethod (pendingIntent.get(), AndroidPendingIntent.getIntentSender));
|
||||
auto fillInIntent = LocalRef<jobject> (env->NewObject (AndroidIntent, AndroidIntent.constructor));
|
||||
auto flagsMask = LocalRef<jobject> (env->CallStaticObjectMethod (JavaInteger, JavaInteger.valueOf, 0));
|
||||
auto flagsValues = LocalRef<jobject> (env->CallStaticObjectMethod (JavaInteger, JavaInteger.valueOf, 0));
|
||||
auto extraFlags = LocalRef<jobject> (env->CallStaticObjectMethod (JavaInteger, JavaInteger.valueOf, 0));
|
||||
|
||||
android.activity.callVoidMethod (JuceAppActivity.startIntentSenderForResult, intentSender.get(), requestCode,
|
||||
fillInIntent.get(), flagsMask.get(), flagsValues.get(), extraFlags.get());
|
||||
}
|
||||
else if (responseCode == 7)
|
||||
{
|
||||
// Item already bought.
|
||||
notifyAboutPurchaseResult ({ {}, productIdentifier, juceString (getPackageName()), {}, {} }, true, statusCodeToUserString (responseCode));
|
||||
}
|
||||
}
|
||||
|
||||
void restoreProductsBoughtList (bool, const juce::String&)
|
||||
{
|
||||
auto callback = [this](const GetProductsBoughtJob::Result& r)
|
||||
{
|
||||
const ScopedLock lock (getProductsBoughtJobResultsLock);
|
||||
getProductsBoughtJobResults.insert (0, r);
|
||||
triggerAsyncUpdate();
|
||||
};
|
||||
|
||||
threadPool->addJob (new GetProductsBoughtJob (*this,
|
||||
getPackageName(), callback), true);
|
||||
}
|
||||
|
||||
void consumePurchase (const String& productIdentifier, const String& purchaseToken)
|
||||
{
|
||||
auto callback = [this](const ConsumePurchaseJob::Result& r)
|
||||
{
|
||||
const ScopedLock lock (consumePurchaseJobResultsLock);
|
||||
consumePurchaseJobResults.insert (0, r);
|
||||
triggerAsyncUpdate();
|
||||
};
|
||||
|
||||
threadPool->addJob (new ConsumePurchaseJob (*this, getPackageName(), productIdentifier,
|
||||
purchaseToken, callback), true);
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
void startDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
// Not available on this platform.
|
||||
ignoreUnused (downloads);
|
||||
jassertfalse;
|
||||
}
|
||||
|
||||
void pauseDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
// Not available on this platform.
|
||||
ignoreUnused (downloads);
|
||||
jassertfalse;
|
||||
}
|
||||
|
||||
void resumeDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
// Not available on this platform.
|
||||
ignoreUnused (downloads);
|
||||
jassertfalse;
|
||||
}
|
||||
|
||||
void cancelDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
// Not available on this platform.
|
||||
ignoreUnused (downloads);
|
||||
jassertfalse;
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
LocalRef<jobject> getBuyIntentBundle (const String& productIdentifier, bool isSubscription,
|
||||
const StringArray& subscriptionIdentifiers, bool creditForUnusedSubscription)
|
||||
{
|
||||
auto* env = getEnv();
|
||||
|
||||
auto skuString = javaString (productIdentifier);
|
||||
auto productTypeString = javaString (isSubscription ? "subs" : "inapp");
|
||||
auto devString = javaString ("");
|
||||
|
||||
if (subscriptionIdentifiers.isEmpty())
|
||||
return LocalRef<jobject> (inAppBillingService.callObjectMethod (IInAppBillingService.getBuyIntent, 3,
|
||||
getPackageName().get(), skuString.get(),
|
||||
productTypeString.get(), devString.get()));
|
||||
|
||||
auto skuList = LocalRef<jobject> (env->NewObject (JavaArrayList, JavaArrayList.constructor,
|
||||
(int) subscriptionIdentifiers.size()));
|
||||
|
||||
if (skuList.get() == 0)
|
||||
{
|
||||
jassertfalse;
|
||||
return LocalRef<jobject> (0);
|
||||
}
|
||||
|
||||
for (const auto& identifier : subscriptionIdentifiers)
|
||||
env->CallBooleanMethod (skuList.get(), JavaArrayList.add, javaString (identifier).get());
|
||||
|
||||
auto extraParams = LocalRef<jobject> (env->NewObject (JavaBundle, JavaBundle.constructor));
|
||||
|
||||
if (extraParams.get() == 0)
|
||||
{
|
||||
jassertfalse;
|
||||
return LocalRef<jobject> (0);
|
||||
}
|
||||
|
||||
auto skusToReplaceString = javaString ("skusToReplace");
|
||||
auto replaceSkusProrationString = javaString ("replaceSkusProration");
|
||||
|
||||
env->CallVoidMethod (extraParams.get(), JavaBundle.putStringArrayList, skusToReplaceString.get(), skuList.get());
|
||||
env->CallVoidMethod (extraParams.get(), JavaBundle.putBoolean, replaceSkusProrationString.get(), creditForUnusedSubscription);
|
||||
|
||||
return LocalRef<jobject> (inAppBillingService.callObjectMethod (IInAppBillingService.getBuyIntentExtraParams, 6,
|
||||
getPackageName().get(), skuString.get(),
|
||||
productTypeString.get(), devString.get(),
|
||||
extraParams.get()));
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
void notifyAboutPurchaseResult (const InAppPurchases::Purchase& purchase, bool success, const String& statusDescription)
|
||||
{
|
||||
owner.listeners.call ([&] (Listener& l) { l.productPurchaseFinished ({ purchase, {} }, success, statusDescription); });
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
bool checkIsReady()
|
||||
{
|
||||
// It may take a few seconds for the in-app purchase service to connect
|
||||
for (auto retries = 0; retries < 10 && inAppBillingService.get() == 0; ++retries)
|
||||
Thread::sleep (500);
|
||||
|
||||
return (inAppBillingService.get() != 0);
|
||||
}
|
||||
|
||||
static bool isInAppPurchasesSupported (jobject iapService)
|
||||
{
|
||||
if (iapService != nullptr)
|
||||
{
|
||||
auto* env = getEnv();
|
||||
|
||||
auto inAppString = javaString ("inapp");
|
||||
auto subsString = javaString ("subs");
|
||||
|
||||
if (env->CallIntMethod (iapService, IInAppBillingService.isBillingSupported, 3,
|
||||
getPackageName().get(), inAppString.get()) != 0)
|
||||
return false;
|
||||
|
||||
if (env->CallIntMethod (iapService, IInAppBillingService.isBillingSupported, 3,
|
||||
getPackageName().get(), subsString.get()) != 0)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Connecting to the in-app purchase server failed! This could have multiple reasons:
|
||||
// 1) Your phone/emulator must support the google play store
|
||||
// 2) Your phone must be logged into the google play store and be able to receive updates
|
||||
// 3) It can take a few seconds after instantiation of the InAppPurchase class for
|
||||
// in-app purchases to be avaialable on Android.
|
||||
return false;
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
void onServiceConnected (jobject, jobject iBinder) override
|
||||
{
|
||||
auto* env = getEnv();
|
||||
|
||||
LocalRef<jobject> iapService (env->CallStaticObjectMethod (IInAppBillingServiceStub,
|
||||
IInAppBillingServiceStub.asInterface,
|
||||
iBinder));
|
||||
|
||||
if (isInAppPurchasesSupported (iapService))
|
||||
inAppBillingService = GlobalRef (iapService);
|
||||
|
||||
// If you hit this assert, then in-app purchases is not available on your device,
|
||||
// most likely due to too old version of Google Play API (hint: update Google Play on the device).
|
||||
jassert (isInAppPurchasesSupported());
|
||||
}
|
||||
|
||||
void onServiceDisconnected (jobject) override
|
||||
{
|
||||
inAppBillingService.clear();
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
static LocalRef<jstring> getPackageName()
|
||||
{
|
||||
return LocalRef<jstring> ((jstring) (android.activity.callObjectMethod (JuceAppActivity.getPackageName)));
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
struct GetProductsInformationJob : public ThreadPoolJob
|
||||
{
|
||||
using Callback = std::function<void(const Array<InAppPurchases::Product>&)>;
|
||||
|
||||
GetProductsInformationJob (Pimpl& parent,
|
||||
const LocalRef<jstring>& packageNameToUse,
|
||||
const StringArray& productIdentifiersToUse,
|
||||
const Callback& callbackToUse)
|
||||
: ThreadPoolJob ("GetProductsInformationJob"),
|
||||
owner (parent),
|
||||
packageName (packageNameToUse.get()),
|
||||
productIdentifiers (productIdentifiersToUse),
|
||||
callback (callbackToUse)
|
||||
{}
|
||||
|
||||
ThreadPoolJob::JobStatus runJob() override
|
||||
{
|
||||
jassert (callback);
|
||||
|
||||
if (owner.checkIsReady())
|
||||
{
|
||||
// Google's Billing API limitation
|
||||
auto maxQuerySize = 20;
|
||||
auto pi = 0;
|
||||
|
||||
Array<InAppPurchases::Product> results;
|
||||
StringArray identifiersToUse;
|
||||
|
||||
for (auto i = 0; i < productIdentifiers.size(); ++i)
|
||||
{
|
||||
identifiersToUse.add (productIdentifiers[i].toLowerCase());
|
||||
++pi;
|
||||
|
||||
if (pi == maxQuerySize || i == productIdentifiers.size() - 1)
|
||||
{
|
||||
auto inAppProducts = processRetrievedProducts (queryProductsInformationFromService (identifiersToUse, "inapp"));
|
||||
auto subsProducts = processRetrievedProducts (queryProductsInformationFromService (identifiersToUse, "subs"));
|
||||
|
||||
results.addArray (inAppProducts);
|
||||
results.addArray (subsProducts);
|
||||
identifiersToUse.clear();
|
||||
pi = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (callback)
|
||||
callback (results);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (callback)
|
||||
callback ({});
|
||||
}
|
||||
|
||||
return jobHasFinished;
|
||||
}
|
||||
|
||||
private:
|
||||
LocalRef<jobject> queryProductsInformationFromService (const StringArray& productIdentifiersToQuery, const String& productType)
|
||||
{
|
||||
auto* env = getEnv();
|
||||
|
||||
auto skuList = LocalRef<jobject> (env->NewObject (JavaArrayList, JavaArrayList.constructor, productIdentifiersToQuery.size()));
|
||||
|
||||
if (skuList.get() == 0)
|
||||
return LocalRef<jobject> (0);
|
||||
|
||||
for (const auto& pi : productIdentifiersToQuery)
|
||||
env->CallBooleanMethod (skuList.get(), JavaArrayList.add, javaString (pi).get());
|
||||
|
||||
auto querySkus = LocalRef<jobject> (env->NewObject (JavaBundle, JavaBundle.constructor));
|
||||
|
||||
if (querySkus.get() == 0)
|
||||
return LocalRef<jobject> (0);
|
||||
|
||||
auto itemIdListString = javaString ("ITEM_ID_LIST");
|
||||
|
||||
env->CallVoidMethod (querySkus.get(), JavaBundle.putStringArrayList, itemIdListString.get(), skuList.get());
|
||||
|
||||
auto productTypeString = javaString (productType);
|
||||
|
||||
auto productDetails = LocalRef<jobject> (owner.inAppBillingService.callObjectMethod (IInAppBillingService.getSkuDetails,
|
||||
3, (jstring) packageName.get(),
|
||||
productTypeString.get(), querySkus.get()));
|
||||
|
||||
return productDetails;
|
||||
}
|
||||
|
||||
Array<InAppPurchases::Product> processRetrievedProducts (LocalRef<jobject> retrievedProducts)
|
||||
{
|
||||
Array<InAppPurchases::Product> products;
|
||||
|
||||
if (owner.checkIsReady())
|
||||
{
|
||||
auto* env = getEnv();
|
||||
|
||||
auto responseCodeString = javaString ("RESPONSE_CODE");
|
||||
|
||||
auto responseCode = env->CallIntMethod (retrievedProducts.get(), JavaBundle.getInt, responseCodeString.get());
|
||||
|
||||
if (responseCode == 0)
|
||||
{
|
||||
auto detailsListString = javaString ("DETAILS_LIST");
|
||||
|
||||
auto responseList = LocalRef<jobject> (env->CallObjectMethod (retrievedProducts.get(), JavaBundle.getStringArrayList,
|
||||
detailsListString.get()));
|
||||
|
||||
if (responseList != 0)
|
||||
{
|
||||
auto iterator = LocalRef<jobject> (env->CallObjectMethod (responseList.get(), JavaArrayList.iterator));
|
||||
|
||||
if (iterator.get() != 0)
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
if (! env->CallBooleanMethod (iterator, JavaIterator.hasNext))
|
||||
break;
|
||||
|
||||
auto response = juce::LocalRef<jstring> ((jstring)env->CallObjectMethod (iterator, JavaIterator.next));
|
||||
|
||||
if (response.get() != 0)
|
||||
{
|
||||
var responseData = JSON::parse (juceString (response.get()));
|
||||
|
||||
if (DynamicObject* object = responseData.getDynamicObject())
|
||||
{
|
||||
NamedValueSet& props = object->getProperties();
|
||||
|
||||
static Identifier productIdIdentifier ("productId");
|
||||
static Identifier titleIdentifier ("title");
|
||||
static Identifier descriptionIdentifier ("description");
|
||||
static Identifier priceIdentifier ("price");
|
||||
static Identifier priceCurrencyCodeIdentifier ("price_currency_code");
|
||||
|
||||
var productId = props[productIdIdentifier];
|
||||
var title = props[titleIdentifier];
|
||||
var description = props[descriptionIdentifier];
|
||||
var price = props[priceIdentifier];
|
||||
var priceCurrencyCode = props[priceCurrencyCodeIdentifier];
|
||||
|
||||
products.add ( { productId.toString(),
|
||||
title.toString(),
|
||||
description.toString(),
|
||||
price.toString(),
|
||||
priceCurrencyCode.toString() } );
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
Pimpl& owner;
|
||||
GlobalRef packageName;
|
||||
const StringArray productIdentifiers;
|
||||
Callback callback;
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
struct GetProductsBoughtJob : public ThreadPoolJob
|
||||
{
|
||||
struct Result
|
||||
{
|
||||
bool success = false;
|
||||
Array<InAppPurchases::Listener::PurchaseInfo> purchases;
|
||||
String statusDescription;
|
||||
};
|
||||
|
||||
using Callback = std::function<void(const Result&)>;
|
||||
|
||||
GetProductsBoughtJob (Pimpl& parent,
|
||||
const LocalRef<jstring>& packageNameToUse,
|
||||
const Callback& callbackToUse)
|
||||
: ThreadPoolJob ("GetProductsBoughtJob"),
|
||||
owner (parent),
|
||||
packageName (packageNameToUse.get()),
|
||||
callback (callbackToUse)
|
||||
{}
|
||||
|
||||
ThreadPoolJob::JobStatus runJob() override
|
||||
{
|
||||
jassert (callback);
|
||||
|
||||
if (owner.checkIsReady())
|
||||
{
|
||||
auto inAppPurchases = getProductsBought ("inapp", 0);
|
||||
auto subsPurchases = getProductsBought ("subs", 0);
|
||||
|
||||
inAppPurchases.addArray (subsPurchases);
|
||||
|
||||
Array<InAppPurchases::Listener::PurchaseInfo> purchases;
|
||||
|
||||
for (const auto& purchase : inAppPurchases)
|
||||
purchases.add ({ purchase, {} });
|
||||
|
||||
if (callback)
|
||||
callback ({true, purchases, "Success"});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (callback)
|
||||
callback ({false, {}, "In-App purchases unavailable"});
|
||||
}
|
||||
|
||||
return jobHasFinished;
|
||||
}
|
||||
|
||||
private:
|
||||
Array<InAppPurchases::Purchase> getProductsBought (const String& productType, jstring continuationToken)
|
||||
{
|
||||
Array<InAppPurchases::Purchase> purchases;
|
||||
auto* env = getEnv();
|
||||
|
||||
auto productTypeString = javaString (productType);
|
||||
auto ownedItems = LocalRef<jobject> (owner.inAppBillingService.callObjectMethod (IInAppBillingService.getPurchases, 3,
|
||||
(jstring) packageName.get(), productTypeString.get(),
|
||||
continuationToken));
|
||||
|
||||
if (ownedItems.get() != 0)
|
||||
{
|
||||
auto responseCodeString = javaString ("RESPONSE_CODE");
|
||||
auto responseCode = env->CallIntMethod (ownedItems.get(), JavaBundle.getInt, responseCodeString.get());
|
||||
|
||||
if (responseCode == 0)
|
||||
{
|
||||
auto itemListString = javaString ("INAPP_PURCHASE_ITEM_LIST");
|
||||
auto dataListString = javaString ("INAPP_PURCHASE_DATA_LIST");
|
||||
auto signatureListString = javaString ("INAPP_DATA_SIGNATURE_LIST");
|
||||
auto continuationTokenString = javaString ("INAPP_CONTINUATION_TOKEN");
|
||||
|
||||
auto ownedSkus = LocalRef<jobject> (env->CallObjectMethod (ownedItems.get(), JavaBundle.getStringArrayList, itemListString.get()));
|
||||
auto purchaseDataList = LocalRef<jobject> (env->CallObjectMethod (ownedItems.get(), JavaBundle.getStringArrayList, dataListString.get()));
|
||||
auto signatureList = LocalRef<jobject> (env->CallObjectMethod (ownedItems.get(), JavaBundle.getStringArrayList, signatureListString.get()));
|
||||
auto newContinuationToken = LocalRef<jstring> ((jstring) env->CallObjectMethod (ownedItems.get(), JavaBundle.getString, continuationTokenString.get()));
|
||||
|
||||
for (auto i = 0; i < env->CallIntMethod (purchaseDataList.get(), JavaArrayList.size); ++i)
|
||||
{
|
||||
auto sku = juceString ((jstring) (env->CallObjectMethod (ownedSkus.get(), JavaArrayList.get, i)));
|
||||
auto purchaseData = juceString ((jstring) (env->CallObjectMethod (purchaseDataList.get(), JavaArrayList.get, i)));
|
||||
auto signature = juceString ((jstring) (env->CallObjectMethod (signatureList.get(), JavaArrayList.get, i)));
|
||||
|
||||
var responseData = JSON::parse (purchaseData);
|
||||
|
||||
if (auto* object = responseData.getDynamicObject())
|
||||
{
|
||||
auto& props = object->getProperties();
|
||||
|
||||
static const Identifier orderIdIdentifier ("orderId"),
|
||||
packageNameIdentifier ("packageName"),
|
||||
productIdIdentifier ("productId"),
|
||||
purchaseTimeIdentifier ("purchaseTime"),
|
||||
purchaseTokenIdentifier ("purchaseToken");
|
||||
|
||||
var orderId = props[orderIdIdentifier];
|
||||
var appPackageName = props[packageNameIdentifier];
|
||||
var productId = props[productIdIdentifier];
|
||||
var purchaseTime = props[purchaseTimeIdentifier];
|
||||
var purchaseToken = props[purchaseTokenIdentifier];
|
||||
|
||||
String purchaseTimeString = Time (purchaseTime.toString().getLargeIntValue()).toString (true, true, true, true);
|
||||
purchases.add ({ orderId.toString(), productId.toString(), appPackageName.toString(), purchaseTimeString, purchaseToken.toString() });
|
||||
}
|
||||
}
|
||||
|
||||
if (newContinuationToken.get() != 0)
|
||||
getProductsBought (productType, newContinuationToken.get());
|
||||
}
|
||||
}
|
||||
|
||||
return purchases;
|
||||
}
|
||||
|
||||
Pimpl& owner;
|
||||
GlobalRef packageName;
|
||||
Callback callback;
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
class ConsumePurchaseJob : public ThreadPoolJob
|
||||
{
|
||||
public:
|
||||
struct Result
|
||||
{
|
||||
bool success = false;
|
||||
String productIdentifier;
|
||||
String statusDescription;
|
||||
};
|
||||
|
||||
using Callback = std::function<void(const Result&)>;
|
||||
|
||||
ConsumePurchaseJob (Pimpl& parent,
|
||||
const LocalRef<jstring>& packageNameToUse,
|
||||
const String& productIdentifierToUse,
|
||||
const String& purchaseTokenToUse,
|
||||
const Callback& callbackToUse)
|
||||
: ThreadPoolJob ("ConsumePurchaseJob"),
|
||||
owner (parent),
|
||||
packageName (packageNameToUse.get()),
|
||||
productIdentifier (productIdentifierToUse),
|
||||
purchaseToken (purchaseTokenToUse),
|
||||
callback (callbackToUse)
|
||||
{}
|
||||
|
||||
ThreadPoolJob::JobStatus runJob() override
|
||||
{
|
||||
jassert (callback);
|
||||
|
||||
if (owner.checkIsReady())
|
||||
{
|
||||
auto token = (! purchaseToken.isEmpty() ? purchaseToken : getPurchaseTokenForProductId (productIdentifier, false, 0));
|
||||
|
||||
if (token.isEmpty())
|
||||
{
|
||||
if (callback)
|
||||
callback ({ false, productIdentifier, NEEDS_TRANS ("Item not owned") });
|
||||
|
||||
return jobHasFinished;
|
||||
}
|
||||
|
||||
auto responseCode = owner.inAppBillingService.callIntMethod (IInAppBillingService.consumePurchase, 3,
|
||||
(jstring)packageName.get(), javaString (token).get());
|
||||
|
||||
if (callback)
|
||||
callback ({ responseCode == 0, productIdentifier, statusCodeToUserString (responseCode) });
|
||||
}
|
||||
else
|
||||
{
|
||||
if (callback)
|
||||
callback ({false, {}, "In-App purchases unavailable"});
|
||||
}
|
||||
|
||||
return jobHasFinished;
|
||||
}
|
||||
|
||||
private:
|
||||
String getPurchaseTokenForProductId (const String productIdToLookFor, bool isSubscription, jstring continuationToken)
|
||||
{
|
||||
auto productTypeString = javaString (isSubscription ? "subs" : "inapp");
|
||||
auto ownedItems = LocalRef<jobject> (owner.inAppBillingService.callObjectMethod (IInAppBillingService.getPurchases, 3,
|
||||
(jstring) packageName.get(), productTypeString.get(),
|
||||
continuationToken));
|
||||
|
||||
if (ownedItems.get() != 0)
|
||||
{
|
||||
auto* env = getEnv();
|
||||
|
||||
auto responseCodeString = javaString ("RESPONSE_CODE");
|
||||
auto responseCode = env->CallIntMethod (ownedItems.get(), JavaBundle.getInt, responseCodeString.get());
|
||||
|
||||
if (responseCode == 0)
|
||||
{
|
||||
auto dataListString = javaString ("INAPP_PURCHASE_DATA_LIST");
|
||||
auto continuationTokenString = javaString ("INAPP_CONTINUATION_TOKEN");
|
||||
|
||||
auto purchaseDataList = LocalRef<jobject> (env->CallObjectMethod (ownedItems.get(), JavaBundle.getStringArrayList, dataListString.get()));
|
||||
auto newContinuationToken = LocalRef<jstring> ((jstring) env->CallObjectMethod (ownedItems.get(), JavaBundle.getString, continuationTokenString.get()));
|
||||
|
||||
for (auto i = 0; i < env->CallIntMethod (purchaseDataList.get(), JavaArrayList.size); ++i)
|
||||
{
|
||||
auto purchaseData = juceString ((jstring) (env->CallObjectMethod (purchaseDataList.get(), JavaArrayList.get, i)));
|
||||
|
||||
var responseData = JSON::parse (purchaseData);
|
||||
|
||||
if (auto* object = responseData.getDynamicObject())
|
||||
{
|
||||
static const Identifier productIdIdentifier ("productId"),
|
||||
purchaseTokenIdentifier ("purchaseToken");
|
||||
|
||||
auto& props = object->getProperties();
|
||||
var productId = props[productIdIdentifier];
|
||||
|
||||
if (productId.toString() == productIdToLookFor)
|
||||
return props[purchaseTokenIdentifier].toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (newContinuationToken.get() != 0)
|
||||
return getPurchaseTokenForProductId (productIdToLookFor, isSubscription, newContinuationToken.get());
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
Pimpl& owner;
|
||||
GlobalRef packageName;
|
||||
const String productIdentifier, purchaseToken;
|
||||
Callback callback;
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
void handleAsyncUpdate() override
|
||||
{
|
||||
{
|
||||
const ScopedLock lock (getProductsInformationJobResultsLock);
|
||||
|
||||
for (int i = getProductsInformationJobResults.size(); --i >= 0;)
|
||||
{
|
||||
const auto& result = getProductsInformationJobResults.getReference (i);
|
||||
|
||||
owner.listeners.call ([&] (Listener& l) { l.productsInfoReturned (result); });
|
||||
getProductsInformationJobResults.remove (i);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const ScopedLock lock (getProductsBoughtJobResultsLock);
|
||||
|
||||
for (int i = getProductsBoughtJobResults.size(); --i >= 0;)
|
||||
{
|
||||
const auto& result = getProductsBoughtJobResults.getReference (i);
|
||||
|
||||
owner.listeners.call ([&] (Listener& l) { l.purchasesListRestored (result.purchases, result.success, result.statusDescription); });
|
||||
getProductsBoughtJobResults.remove (i);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const ScopedLock lock (consumePurchaseJobResultsLock);
|
||||
|
||||
for (int i = consumePurchaseJobResults.size(); --i >= 0;)
|
||||
{
|
||||
const auto& result = consumePurchaseJobResults.getReference (i);
|
||||
|
||||
owner.listeners.call ([&] (Listener& l) { l.productConsumed (result.productIdentifier, result.success, result.statusDescription); });
|
||||
consumePurchaseJobResults.remove (i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
void inAppPurchaseCompleted (jobject intentData)
|
||||
{
|
||||
auto* env = getEnv();
|
||||
|
||||
auto inAppPurchaseDataString = javaString ("INAPP_PURCHASE_DATA");
|
||||
auto inAppDataSignatureString = javaString ("INAPP_DATA_SIGNATURE");
|
||||
auto responseCodeString = javaString ("RESPONSE_CODE");
|
||||
|
||||
auto pd = LocalRef<jstring> ((jstring) env->CallObjectMethod (intentData, AndroidIntent.getStringExtra, inAppPurchaseDataString.get()));
|
||||
auto sig = LocalRef<jstring> ((jstring) env->CallObjectMethod (intentData, AndroidIntent.getStringExtra, inAppDataSignatureString.get()));
|
||||
auto purchaseDataString = pd.get() != 0 ? juceString (pd.get()) : String();
|
||||
auto dataSignatureString = sig.get() != 0 ? juceString (sig.get()) : String();
|
||||
|
||||
var responseData = JSON::parse (purchaseDataString);
|
||||
|
||||
auto responseCode = env->CallIntMethod (intentData, AndroidIntent.getIntExtra, responseCodeString.get());
|
||||
auto statusCodeUserString = statusCodeToUserString (responseCode);
|
||||
|
||||
if (auto* object = responseData.getDynamicObject())
|
||||
{
|
||||
auto& props = object->getProperties();
|
||||
|
||||
static const Identifier orderIdIdentifier ("orderId"),
|
||||
packageNameIdentifier ("packageName"),
|
||||
productIdIdentifier ("productId"),
|
||||
purchaseTimeIdentifier ("purchaseTime"),
|
||||
purchaseTokenIdentifier ("purchaseToken"),
|
||||
developerPayloadIdentifier ("developerPayload");
|
||||
|
||||
var orderId = props[orderIdIdentifier];
|
||||
var packageName = props[packageNameIdentifier];
|
||||
var productId = props[productIdIdentifier];
|
||||
var purchaseTime = props[purchaseTimeIdentifier];
|
||||
var purchaseToken = props[purchaseTokenIdentifier];
|
||||
var developerPayload = props[developerPayloadIdentifier];
|
||||
|
||||
auto purchaseTimeString = Time (purchaseTime.toString().getLargeIntValue())
|
||||
.toString (true, true, true, true);
|
||||
|
||||
notifyAboutPurchaseResult ({ orderId.toString(), productId.toString(), packageName.toString(),
|
||||
purchaseTimeString, purchaseToken.toString() },
|
||||
true, statusCodeUserString);
|
||||
return;
|
||||
}
|
||||
|
||||
notifyAboutPurchaseResult ({}, false, statusCodeUserString);
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
static String statusCodeToUserString (int statusCode)
|
||||
{
|
||||
switch (statusCode)
|
||||
{
|
||||
case 0: return NEEDS_TRANS ("Success");
|
||||
case 1: return NEEDS_TRANS ("Cancelled by user");
|
||||
case 2: return NEEDS_TRANS ("Service unavailable");
|
||||
case 3: return NEEDS_TRANS ("Billing unavailable");
|
||||
case 4: return NEEDS_TRANS ("Item unavailable");
|
||||
case 5: return NEEDS_TRANS ("Internal error");
|
||||
case 6: return NEEDS_TRANS ("Generic error");
|
||||
case 7: return NEEDS_TRANS ("Item already owned");
|
||||
case 8: return NEEDS_TRANS ("Item not owned");
|
||||
default: jassertfalse; return NEEDS_TRANS ("Unknown status");
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
InAppPurchases& owner;
|
||||
GlobalRef inAppBillingService, serviceConnection;
|
||||
std::unique_ptr<ThreadPool> threadPool;
|
||||
|
||||
CriticalSection getProductsInformationJobResultsLock,
|
||||
getProductsBoughtJobResultsLock,
|
||||
consumePurchaseJobResultsLock;
|
||||
|
||||
Array<Array<InAppPurchases::Product>> getProductsInformationJobResults;
|
||||
Array<GetProductsBoughtJob::Result> getProductsBoughtJobResults;
|
||||
Array<ConsumePurchaseJob::Result> consumePurchaseJobResults;
|
||||
};
|
||||
|
||||
|
||||
//==============================================================================
|
||||
void juce_inAppPurchaseCompleted (void* intentData)
|
||||
{
|
||||
if (auto* instance = InAppPurchases::getInstance())
|
||||
instance->pimpl->inAppPurchaseCompleted (static_cast<jobject> (intentData));
|
||||
}
|
||||
|
||||
} // namespace juce
|
@ -0,0 +1,746 @@
|
||||
/*
|
||||
==============================================================================
|
||||
|
||||
This file is part of the JUCE library.
|
||||
Copyright (c) 2017 - ROLI Ltd.
|
||||
|
||||
JUCE is an open source library subject to commercial or open-source
|
||||
licensing.
|
||||
|
||||
By using JUCE, you agree to the terms of both the JUCE 5 End-User License
|
||||
Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
|
||||
27th April 2017).
|
||||
|
||||
End User License Agreement: www.juce.com/juce-5-licence
|
||||
Privacy Policy: www.juce.com/juce-5-privacy-policy
|
||||
|
||||
Or: You may also use this code under the terms of the GPL v3 (see
|
||||
www.gnu.org/licenses).
|
||||
|
||||
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
|
||||
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
|
||||
DISCLAIMED.
|
||||
|
||||
==============================================================================
|
||||
*/
|
||||
|
||||
namespace juce
|
||||
{
|
||||
|
||||
struct SKDelegateAndPaymentObserver
|
||||
{
|
||||
SKDelegateAndPaymentObserver() : delegate ([getClass().createInstance() init])
|
||||
{
|
||||
Class::setThis (delegate.get(), this);
|
||||
}
|
||||
|
||||
virtual ~SKDelegateAndPaymentObserver() {}
|
||||
|
||||
virtual void didReceiveResponse (SKProductsRequest*, SKProductsResponse*) = 0;
|
||||
virtual void requestDidFinish (SKRequest*) = 0;
|
||||
virtual void requestDidFailWithError (SKRequest*, NSError*) = 0;
|
||||
virtual void updatedTransactions (SKPaymentQueue*, NSArray<SKPaymentTransaction*>*) = 0;
|
||||
virtual void restoreCompletedTransactionsFailedWithError (SKPaymentQueue*, NSError*) = 0;
|
||||
virtual void restoreCompletedTransactionsFinished (SKPaymentQueue*) = 0;
|
||||
virtual void updatedDownloads (SKPaymentQueue*, NSArray<SKDownload*>*) = 0;
|
||||
|
||||
protected:
|
||||
std::unique_ptr<NSObject<SKProductsRequestDelegate, SKPaymentTransactionObserver>, NSObjectDeleter> delegate;
|
||||
|
||||
private:
|
||||
struct Class : public ObjCClass<NSObject<SKProductsRequestDelegate, SKPaymentTransactionObserver>>
|
||||
{
|
||||
//==============================================================================
|
||||
Class() : ObjCClass<NSObject<SKProductsRequestDelegate, SKPaymentTransactionObserver>> ("SKDelegateAndPaymentObserverBase_")
|
||||
{
|
||||
addIvar<SKDelegateAndPaymentObserver*> ("self");
|
||||
|
||||
addMethod (@selector (productsRequest:didReceiveResponse:), didReceiveResponse, "v@:@@");
|
||||
addMethod (@selector (requestDidFinish:), requestDidFinish, "v@:@");
|
||||
addMethod (@selector (request:didFailWithError:), requestDidFailWithError, "v@:@@");
|
||||
addMethod (@selector (paymentQueue:updatedTransactions:), updatedTransactions, "v@:@@");
|
||||
addMethod (@selector (paymentQueue:restoreCompletedTransactionsFailedWithError:), restoreCompletedTransactionsFailedWithError, "v@:@@");
|
||||
addMethod (@selector (paymentQueueRestoreCompletedTransactionsFinished:), restoreCompletedTransactionsFinished, "v@:@");
|
||||
addMethod (@selector (paymentQueue:updatedDownloads:), updatedDownloads, "v@:@@");
|
||||
|
||||
registerClass();
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
static SKDelegateAndPaymentObserver& getThis (id self) { return *getIvar<SKDelegateAndPaymentObserver*> (self, "self"); }
|
||||
static void setThis (id self, SKDelegateAndPaymentObserver* s) { object_setInstanceVariable (self, "self", s); }
|
||||
|
||||
//==============================================================================
|
||||
static void didReceiveResponse (id self, SEL, SKProductsRequest* request, SKProductsResponse* response) { getThis (self).didReceiveResponse (request, response); }
|
||||
static void requestDidFinish (id self, SEL, SKRequest* request) { getThis (self).requestDidFinish (request); }
|
||||
static void requestDidFailWithError (id self, SEL, SKRequest* request, NSError* err) { getThis (self).requestDidFailWithError (request, err); }
|
||||
static void updatedTransactions (id self, SEL, SKPaymentQueue* queue, NSArray<SKPaymentTransaction*>* trans) { getThis (self).updatedTransactions (queue, trans); }
|
||||
static void restoreCompletedTransactionsFailedWithError (id self, SEL, SKPaymentQueue* q, NSError* err) { getThis (self).restoreCompletedTransactionsFailedWithError (q, err); }
|
||||
static void restoreCompletedTransactionsFinished (id self, SEL, SKPaymentQueue* queue) { getThis (self).restoreCompletedTransactionsFinished (queue); }
|
||||
static void updatedDownloads (id self, SEL, SKPaymentQueue* queue, NSArray<SKDownload*>* downloads) { getThis (self).updatedDownloads (queue, downloads); }
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
static Class& getClass()
|
||||
{
|
||||
static Class c;
|
||||
return c;
|
||||
}
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
struct InAppPurchases::Pimpl : public SKDelegateAndPaymentObserver
|
||||
{
|
||||
/** AppStore implementation of hosted content download. */
|
||||
struct DownloadImpl : public Download
|
||||
{
|
||||
DownloadImpl (SKDownload* downloadToUse) : download (downloadToUse) {}
|
||||
|
||||
String getProductId() const override { return nsStringToJuce (download.contentIdentifier); }
|
||||
String getContentVersion() const override { return nsStringToJuce (download.contentVersion); }
|
||||
|
||||
#if JUCE_IOS
|
||||
int64 getContentLength() const override { return download.contentLength; }
|
||||
Status getStatus() const override { return SKDownloadStateToDownloadStatus (download.downloadState); }
|
||||
#else
|
||||
int64 getContentLength() const override { return [download.contentLength longLongValue]; }
|
||||
Status getStatus() const override { return SKDownloadStateToDownloadStatus (download.state); }
|
||||
#endif
|
||||
|
||||
SKDownload* download;
|
||||
};
|
||||
|
||||
/** Represents a pending request initialised with [SKProductRequest start]. */
|
||||
struct PendingProductInfoRequest
|
||||
{
|
||||
enum class Type
|
||||
{
|
||||
query = 0,
|
||||
purchase
|
||||
};
|
||||
|
||||
Type type;
|
||||
std::unique_ptr<SKProductsRequest, NSObjectDeleter> request;
|
||||
};
|
||||
|
||||
/** Represents a pending request started from [SKReceiptRefreshRequest start]. */
|
||||
struct PendingReceiptRefreshRequest
|
||||
{
|
||||
String subscriptionsSharedSecret;
|
||||
std::unique_ptr<SKReceiptRefreshRequest, NSObjectDeleter> request;
|
||||
};
|
||||
|
||||
/** Represents a transaction with pending downloads. Only after all downloads
|
||||
are finished, the transaction is marked as finished. */
|
||||
struct PendingDownloadsTransaction
|
||||
{
|
||||
PendingDownloadsTransaction (SKPaymentTransaction* t) : transaction (t)
|
||||
{
|
||||
addDownloadsFromSKTransaction (transaction);
|
||||
}
|
||||
|
||||
void addDownloadsFromSKTransaction (SKPaymentTransaction* transactionToUse)
|
||||
{
|
||||
for (SKDownload* download in transactionToUse.downloads)
|
||||
downloads.add (new DownloadImpl (download));
|
||||
}
|
||||
|
||||
bool canBeMarkedAsFinished() const
|
||||
{
|
||||
for (SKDownload* d in transaction.downloads)
|
||||
{
|
||||
#if JUCE_IOS
|
||||
SKDownloadState state = d.downloadState;
|
||||
#else
|
||||
SKDownloadState state = d.state;
|
||||
#endif
|
||||
if (state != SKDownloadStateFinished
|
||||
&& state != SKDownloadStateFailed
|
||||
&& state != SKDownloadStateCancelled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
OwnedArray<DownloadImpl> downloads;
|
||||
SKPaymentTransaction* const transaction;
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
Pimpl (InAppPurchases& p) : owner (p) { [[SKPaymentQueue defaultQueue] addTransactionObserver: delegate.get()]; }
|
||||
~Pimpl() noexcept { [[SKPaymentQueue defaultQueue] removeTransactionObserver: delegate.get()]; }
|
||||
|
||||
//==============================================================================
|
||||
bool isInAppPurchasesSupported() const { return true; }
|
||||
|
||||
void getProductsInformation (const StringArray& productIdentifiers)
|
||||
{
|
||||
auto* productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers: [NSSet setWithArray: createNSArrayFromStringArray (productIdentifiers)]];
|
||||
|
||||
pendingProductInfoRequests.add (new PendingProductInfoRequest { PendingProductInfoRequest::Type::query,
|
||||
std::unique_ptr<SKProductsRequest, NSObjectDeleter> (productsRequest) });
|
||||
|
||||
productsRequest.delegate = delegate.get();
|
||||
[productsRequest start];
|
||||
}
|
||||
|
||||
void purchaseProduct (const String& productIdentifier, bool, const StringArray&, bool)
|
||||
{
|
||||
if (! [SKPaymentQueue canMakePayments])
|
||||
{
|
||||
owner.listeners.call ([&] (Listener& l) { l.productPurchaseFinished ({}, false, NEEDS_TRANS ("Payments not allowed")); });
|
||||
return;
|
||||
}
|
||||
|
||||
auto* productIdentifiers = [NSArray arrayWithObject: juceStringToNS (productIdentifier)];
|
||||
auto* productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithArray:productIdentifiers]];
|
||||
|
||||
pendingProductInfoRequests.add (new PendingProductInfoRequest { PendingProductInfoRequest::Type::purchase,
|
||||
std::unique_ptr<SKProductsRequest, NSObjectDeleter> (productsRequest) });
|
||||
|
||||
productsRequest.delegate = delegate.get();
|
||||
[productsRequest start];
|
||||
}
|
||||
|
||||
void restoreProductsBoughtList (bool includeDownloadInfo, const String& subscriptionsSharedSecret)
|
||||
{
|
||||
if (includeDownloadInfo)
|
||||
{
|
||||
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
|
||||
}
|
||||
else
|
||||
{
|
||||
auto* receiptRequest = [[SKReceiptRefreshRequest alloc] init];
|
||||
|
||||
pendingReceiptRefreshRequests.add (new PendingReceiptRefreshRequest { subscriptionsSharedSecret,
|
||||
std::unique_ptr<SKReceiptRefreshRequest, NSObjectDeleter> ([receiptRequest retain]) });
|
||||
receiptRequest.delegate = delegate.get();
|
||||
[receiptRequest start];
|
||||
}
|
||||
}
|
||||
|
||||
void consumePurchase (const String&, const String&) {}
|
||||
|
||||
//==============================================================================
|
||||
void startDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
[[SKPaymentQueue defaultQueue] startDownloads: downloadsToSKDownloads (removeInvalidDownloads (downloads))];
|
||||
}
|
||||
|
||||
void pauseDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
[[SKPaymentQueue defaultQueue] pauseDownloads: downloadsToSKDownloads (removeInvalidDownloads (downloads))];
|
||||
}
|
||||
|
||||
void resumeDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
[[SKPaymentQueue defaultQueue] resumeDownloads: downloadsToSKDownloads (removeInvalidDownloads (downloads))];
|
||||
}
|
||||
|
||||
void cancelDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
[[SKPaymentQueue defaultQueue] cancelDownloads: downloadsToSKDownloads (removeInvalidDownloads (downloads))];
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
void didReceiveResponse (SKProductsRequest* request, SKProductsResponse* response) override
|
||||
{
|
||||
for (auto i = 0; i < pendingProductInfoRequests.size(); ++i)
|
||||
{
|
||||
auto& pendingRequest = *pendingProductInfoRequests[i];
|
||||
|
||||
if (pendingRequest.request.get() == request)
|
||||
{
|
||||
if (pendingRequest.type == PendingProductInfoRequest::Type::query) notifyProductsInfoReceived (response.products);
|
||||
else if (pendingRequest.type == PendingProductInfoRequest::Type::purchase) startPurchase (response.products);
|
||||
else break;
|
||||
|
||||
pendingProductInfoRequests.remove (i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown request received!
|
||||
jassertfalse;
|
||||
}
|
||||
|
||||
void requestDidFinish (SKRequest* request) override
|
||||
{
|
||||
if (auto receiptRefreshRequest = getAs<SKReceiptRefreshRequest> (request))
|
||||
{
|
||||
for (auto i = 0; i < pendingReceiptRefreshRequests.size(); ++i)
|
||||
{
|
||||
auto& pendingRequest = *pendingReceiptRefreshRequests[i];
|
||||
|
||||
if (pendingRequest.request.get() == receiptRefreshRequest)
|
||||
{
|
||||
processReceiptRefreshResponseWithSubscriptionsSharedSecret (pendingRequest.subscriptionsSharedSecret);
|
||||
pendingReceiptRefreshRequests.remove (i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void requestDidFailWithError (SKRequest* request, NSError* error) override
|
||||
{
|
||||
if (auto receiptRefreshRequest = getAs<SKReceiptRefreshRequest> (request))
|
||||
{
|
||||
for (auto i = 0; i < pendingReceiptRefreshRequests.size(); ++i)
|
||||
{
|
||||
auto& pendingRequest = *pendingReceiptRefreshRequests[i];
|
||||
|
||||
if (pendingRequest.request.get() == receiptRefreshRequest)
|
||||
{
|
||||
auto errorDetails = error != nil ? (", " + nsStringToJuce ([error localizedDescription])) : String();
|
||||
owner.listeners.call ([&] (Listener& l) { l.purchasesListRestored ({}, false, NEEDS_TRANS ("Receipt fetch failed") + errorDetails); });
|
||||
pendingReceiptRefreshRequests.remove (i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void updatedTransactions (SKPaymentQueue*, NSArray<SKPaymentTransaction*>* transactions) override
|
||||
{
|
||||
for (SKPaymentTransaction* transaction in transactions)
|
||||
{
|
||||
switch (transaction.transactionState)
|
||||
{
|
||||
case SKPaymentTransactionStatePurchasing: break;
|
||||
case SKPaymentTransactionStateDeferred: break;
|
||||
case SKPaymentTransactionStateFailed: processTransactionFinish (transaction, false); break;
|
||||
case SKPaymentTransactionStatePurchased: processTransactionFinish (transaction, true); break;
|
||||
case SKPaymentTransactionStateRestored: processTransactionFinish (transaction, true); break;
|
||||
default: jassertfalse; break; // Unexpected transaction state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void restoreCompletedTransactionsFailedWithError (SKPaymentQueue*, NSError* error) override
|
||||
{
|
||||
owner.listeners.call ([&] (Listener& l) { l.purchasesListRestored ({}, false, nsStringToJuce (error.localizedDescription)); });
|
||||
}
|
||||
|
||||
void restoreCompletedTransactionsFinished (SKPaymentQueue*) override
|
||||
{
|
||||
owner.listeners.call ([this] (Listener& l) { l.purchasesListRestored (restoredPurchases, true, NEEDS_TRANS ("Success")); });
|
||||
restoredPurchases.clear();
|
||||
}
|
||||
|
||||
void updatedDownloads (SKPaymentQueue*, NSArray<SKDownload*>* downloads) override
|
||||
{
|
||||
for (SKDownload* download in downloads)
|
||||
{
|
||||
if (auto* pendingDownload = getPendingDownloadFor (download))
|
||||
{
|
||||
#if JUCE_IOS
|
||||
switch (download.downloadState)
|
||||
#else
|
||||
switch (download.state)
|
||||
#endif
|
||||
{
|
||||
case SKDownloadStateWaiting: break;
|
||||
case SKDownloadStatePaused: owner.listeners.call ([&] (Listener& l) { l.productDownloadPaused (*pendingDownload); }); break;
|
||||
case SKDownloadStateActive: owner.listeners.call ([&] (Listener& l) { l.productDownloadProgressUpdate (*pendingDownload,
|
||||
download.progress,
|
||||
RelativeTime (download.timeRemaining)); }); break;
|
||||
case SKDownloadStateFinished:
|
||||
case SKDownloadStateFailed:
|
||||
case SKDownloadStateCancelled: processDownloadFinish (pendingDownload, download); break;
|
||||
|
||||
default: jassertfalse; break; // Unexpected download state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
void notifyProductsInfoReceived (NSArray<SKProduct*>* products)
|
||||
{
|
||||
Array<Product> productsToReturn;
|
||||
|
||||
for (SKProduct* skProduct in products)
|
||||
productsToReturn.add (SKProductToIAPProduct (skProduct));
|
||||
|
||||
owner.listeners.call ([&] (Listener& l) { l.productsInfoReturned (productsToReturn); });
|
||||
}
|
||||
|
||||
void startPurchase (NSArray<SKProduct*>* products)
|
||||
{
|
||||
if ([products count] > 0)
|
||||
{
|
||||
// Only one product can be bought at once!
|
||||
jassert ([products count] == 1);
|
||||
|
||||
auto* product = products[0];
|
||||
auto* payment = [SKPayment paymentWithProduct: product];
|
||||
[[SKPaymentQueue defaultQueue] addPayment: payment];
|
||||
}
|
||||
else
|
||||
{
|
||||
owner.listeners.call ([] (Listener& l) { l.productPurchaseFinished ({}, false, NEEDS_TRANS ("Your app is not setup for payments")); });
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
Array<Download*> removeInvalidDownloads (const Array<Download*>& downloadsToUse)
|
||||
{
|
||||
Array<Download*> downloads (downloadsToUse);
|
||||
|
||||
for (int i = downloads.size(); --i >= 0;)
|
||||
{
|
||||
auto hasPendingDownload = hasDownloadInPendingDownloadsTransaction (*downloads[i]);
|
||||
|
||||
// Invalid download passed, it does not exist in pending downloads list
|
||||
jassert (hasPendingDownload);
|
||||
|
||||
if (! hasPendingDownload)
|
||||
downloads.remove (i);
|
||||
}
|
||||
|
||||
return downloads;
|
||||
}
|
||||
|
||||
bool hasDownloadInPendingDownloadsTransaction (const Download& download)
|
||||
{
|
||||
for (auto* pdt : pendingDownloadsTransactions)
|
||||
for (auto* pendingDownload : pdt->downloads)
|
||||
if (pendingDownload == &download)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
void processTransactionFinish (SKPaymentTransaction* transaction, bool success)
|
||||
{
|
||||
auto orderId = nsStringToJuce (transaction.transactionIdentifier);
|
||||
auto packageName = nsStringToJuce ([[NSBundle mainBundle] bundleIdentifier]);
|
||||
auto productId = nsStringToJuce (transaction.payment.productIdentifier);
|
||||
auto purchaseTime = Time (1000 * (int64) transaction.transactionDate.timeIntervalSince1970)
|
||||
.toString (true, true, true, true);
|
||||
|
||||
Purchase purchase { orderId, productId, packageName, purchaseTime, {} };
|
||||
|
||||
Array<Download*> downloads;
|
||||
|
||||
// If transaction failed or there are no downloads, finish the transaction immediately, otherwise
|
||||
// finish the transaction only after all downloads are finished.
|
||||
if (transaction.transactionState == SKPaymentTransactionStateFailed
|
||||
|| transaction.downloads == nil
|
||||
|| [transaction.downloads count] == 0)
|
||||
{
|
||||
[[SKPaymentQueue defaultQueue] finishTransaction: transaction];
|
||||
}
|
||||
else
|
||||
{
|
||||
// On application startup or when the app is resumed we may receive multiple
|
||||
// "purchased" callbacks with the same underlying transaction. Sadly, only
|
||||
// the last set of downloads will be valid.
|
||||
auto* pdt = getPendingDownloadsTransactionForSKTransaction (transaction);
|
||||
|
||||
if (pdt == nullptr)
|
||||
{
|
||||
pdt = pendingDownloadsTransactions.add (new PendingDownloadsTransaction (transaction));
|
||||
}
|
||||
else
|
||||
{
|
||||
pdt->downloads.clear();
|
||||
pdt->addDownloadsFromSKTransaction (transaction);
|
||||
}
|
||||
|
||||
for (auto* download : pdt->downloads)
|
||||
downloads.add (download);
|
||||
}
|
||||
|
||||
if (transaction.transactionState == SKPaymentTransactionStateRestored)
|
||||
restoredPurchases.add ({ purchase, downloads });
|
||||
else
|
||||
owner.listeners.call ([&] (Listener& l) { l.productPurchaseFinished ({ purchase, downloads }, success,
|
||||
SKPaymentTransactionStateToString (transaction.transactionState)); });
|
||||
}
|
||||
|
||||
PendingDownloadsTransaction* getPendingDownloadsTransactionForSKTransaction (SKPaymentTransaction* transaction)
|
||||
{
|
||||
for (auto* pdt : pendingDownloadsTransactions)
|
||||
if (pdt->transaction == transaction)
|
||||
return pdt;
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
PendingDownloadsTransaction* getPendingDownloadsTransactionSKDownloadFor (SKDownload* download)
|
||||
{
|
||||
for (auto* pdt : pendingDownloadsTransactions)
|
||||
for (auto* pendingDownload : pdt->downloads)
|
||||
if (pendingDownload->download == download)
|
||||
return pdt;
|
||||
|
||||
jassertfalse;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Download* getPendingDownloadFor (SKDownload* download)
|
||||
{
|
||||
if (auto* pdt = getPendingDownloadsTransactionSKDownloadFor (download))
|
||||
for (auto* pendingDownload : pdt->downloads)
|
||||
if (pendingDownload->download == download)
|
||||
return pendingDownload;
|
||||
|
||||
jassertfalse;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void processDownloadFinish (Download* pendingDownload, SKDownload* download)
|
||||
{
|
||||
if (auto* pdt = getPendingDownloadsTransactionSKDownloadFor (download))
|
||||
{
|
||||
#if JUCE_IOS
|
||||
SKDownloadState state = download.downloadState;
|
||||
#else
|
||||
SKDownloadState state = download.state;
|
||||
#endif
|
||||
|
||||
auto contentURL = state == SKDownloadStateFinished
|
||||
? URL (nsStringToJuce (download.contentURL.absoluteString))
|
||||
: URL();
|
||||
|
||||
owner.listeners.call ([&] (Listener& l) { l.productDownloadFinished (*pendingDownload, contentURL); });
|
||||
|
||||
if (pdt->canBeMarkedAsFinished())
|
||||
{
|
||||
// All downloads finished, mark transaction as finished too.
|
||||
[[SKPaymentQueue defaultQueue] finishTransaction: pdt->transaction];
|
||||
|
||||
pendingDownloadsTransactions.removeObject (pdt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
void processReceiptRefreshResponseWithSubscriptionsSharedSecret (const String& secret)
|
||||
{
|
||||
auto* receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
|
||||
|
||||
if (auto* receiptData = [NSData dataWithContentsOfURL: receiptURL])
|
||||
fetchReceiptDetailsFromAppStore (receiptData, secret);
|
||||
else
|
||||
owner.listeners.call ([&] (Listener& l) { l.purchasesListRestored ({}, false, NEEDS_TRANS ("Receipt fetch failed")); });
|
||||
}
|
||||
|
||||
void fetchReceiptDetailsFromAppStore (NSData* receiptData, const String& secret)
|
||||
{
|
||||
auto* requestContents = [NSMutableDictionary dictionaryWithCapacity: (NSUInteger) (secret.isNotEmpty() ? 2 : 1)];
|
||||
[requestContents setObject: [receiptData base64EncodedStringWithOptions:0] forKey: nsStringLiteral ("receipt-data")];
|
||||
|
||||
if (secret.isNotEmpty())
|
||||
[requestContents setObject: juceStringToNS (secret) forKey: nsStringLiteral ("password")];
|
||||
|
||||
NSError* error;
|
||||
auto* requestData = [NSJSONSerialization dataWithJSONObject: requestContents
|
||||
options: 0
|
||||
error: &error];
|
||||
if (requestData == nil)
|
||||
{
|
||||
sendReceiptFetchFail();
|
||||
return;
|
||||
}
|
||||
|
||||
#if JUCE_IN_APP_PURCHASES_USE_SANDBOX_ENVIRONMENT
|
||||
auto storeURL = "https://sandbox.itunes.apple.com/verifyReceipt";
|
||||
#else
|
||||
auto storeURL = "https://buy.itunes.apple.com/verifyReceipt";
|
||||
#endif
|
||||
|
||||
// TODO: use juce URL here
|
||||
auto* storeRequest = [NSMutableURLRequest requestWithURL: [NSURL URLWithString: nsStringLiteral (storeURL)]];
|
||||
[storeRequest setHTTPMethod: nsStringLiteral ("POST")];
|
||||
[storeRequest setHTTPBody: requestData];
|
||||
|
||||
auto* task = [[NSURLSession sharedSession] dataTaskWithRequest: storeRequest
|
||||
completionHandler:
|
||||
^(NSData* data, NSURLResponse*, NSError* connectionError)
|
||||
{
|
||||
if (connectionError != nil)
|
||||
{
|
||||
sendReceiptFetchFail();
|
||||
}
|
||||
else
|
||||
{
|
||||
NSError* err;
|
||||
|
||||
if (NSDictionary* receiptDetails = [NSJSONSerialization JSONObjectWithData: data options: 0 error: &err])
|
||||
processReceiptDetails (receiptDetails);
|
||||
else
|
||||
sendReceiptFetchFail();
|
||||
}
|
||||
}];
|
||||
|
||||
[task resume];
|
||||
}
|
||||
|
||||
void processReceiptDetails (NSDictionary* receiptDetails)
|
||||
{
|
||||
if (auto receipt = getAs<NSDictionary> (receiptDetails[nsStringLiteral ("receipt")]))
|
||||
{
|
||||
if (auto bundleId = getAs<NSString> (receipt[nsStringLiteral ("bundle_id")]))
|
||||
{
|
||||
if (auto inAppPurchases = getAs<NSArray> (receipt[nsStringLiteral ("in_app")]))
|
||||
{
|
||||
Array<Listener::PurchaseInfo> purchases;
|
||||
|
||||
for (id inAppPurchaseData in inAppPurchases)
|
||||
{
|
||||
if (auto* purchaseData = getAs<NSDictionary> (inAppPurchaseData))
|
||||
{
|
||||
// Ignore products that were cancelled.
|
||||
if (purchaseData[nsStringLiteral ("cancellation_date")] != nil)
|
||||
continue;
|
||||
|
||||
if (auto transactionId = getAs<NSString> (purchaseData[nsStringLiteral ("original_transaction_id")]))
|
||||
{
|
||||
if (auto productId = getAs<NSString> (purchaseData[nsStringLiteral ("product_id")]))
|
||||
{
|
||||
auto purchaseTime = getPurchaseDateMs (purchaseData[nsStringLiteral ("purchase_date_ms")]);
|
||||
|
||||
if (purchaseTime > 0)
|
||||
{
|
||||
purchases.add ({ { nsStringToJuce (transactionId),
|
||||
nsStringToJuce (productId),
|
||||
nsStringToJuce (bundleId),
|
||||
Time (purchaseTime).toString (true, true, true, true),
|
||||
{} }, {} });
|
||||
}
|
||||
else
|
||||
{
|
||||
return sendReceiptFetchFailAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return sendReceiptFetchFailAsync();
|
||||
}
|
||||
}
|
||||
|
||||
MessageManager::callAsync ([this, purchases] { owner.listeners.call ([&] (Listener& l) { l.purchasesListRestored (purchases, true, NEEDS_TRANS ("Success")); }); });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendReceiptFetchFailAsync();
|
||||
}
|
||||
|
||||
void sendReceiptFetchFail()
|
||||
{
|
||||
owner.listeners.call ([] (Listener& l) { l.purchasesListRestored ({}, false, NEEDS_TRANS ("Receipt fetch failed")); });
|
||||
}
|
||||
|
||||
void sendReceiptFetchFailAsync()
|
||||
{
|
||||
MessageManager::callAsync ([this] { sendReceiptFetchFail(); });
|
||||
}
|
||||
|
||||
static int64 getPurchaseDateMs (id date)
|
||||
{
|
||||
if (auto dateAsNumber = getAs<NSNumber> (date))
|
||||
{
|
||||
return [dateAsNumber longLongValue];
|
||||
}
|
||||
else if (auto dateAsString = getAs<NSString> (date))
|
||||
{
|
||||
auto* formatter = [[NSNumberFormatter alloc] init];
|
||||
[formatter setNumberStyle: NSNumberFormatterDecimalStyle];
|
||||
dateAsNumber = [formatter numberFromString: dateAsString];
|
||||
[formatter release];
|
||||
return [dateAsNumber longLongValue];
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
static Product SKProductToIAPProduct (SKProduct* skProduct)
|
||||
{
|
||||
NSNumberFormatter* numberFormatter = [[NSNumberFormatter alloc] init];
|
||||
[numberFormatter setFormatterBehavior: NSNumberFormatterBehavior10_4];
|
||||
[numberFormatter setNumberStyle: NSNumberFormatterCurrencyStyle];
|
||||
[numberFormatter setLocale: skProduct.priceLocale];
|
||||
|
||||
auto identifier = nsStringToJuce (skProduct.productIdentifier);
|
||||
auto title = nsStringToJuce (skProduct.localizedTitle);
|
||||
auto description = nsStringToJuce (skProduct.localizedDescription);
|
||||
auto priceLocale = nsStringToJuce ([skProduct.priceLocale objectForKey: NSLocaleLanguageCode]);
|
||||
auto price = nsStringToJuce ([numberFormatter stringFromNumber: skProduct.price]);
|
||||
|
||||
[numberFormatter release];
|
||||
|
||||
return { identifier, title, description, price, priceLocale };
|
||||
}
|
||||
|
||||
static String SKPaymentTransactionStateToString (SKPaymentTransactionState state)
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case SKPaymentTransactionStatePurchasing: return NEEDS_TRANS ("Purchasing");
|
||||
case SKPaymentTransactionStatePurchased: return NEEDS_TRANS ("Success");
|
||||
case SKPaymentTransactionStateFailed: return NEEDS_TRANS ("Failure");
|
||||
case SKPaymentTransactionStateRestored: return NEEDS_TRANS ("Restored");
|
||||
case SKPaymentTransactionStateDeferred: return NEEDS_TRANS ("Deferred");
|
||||
default: jassertfalse; return NEEDS_TRANS ("Unknown status");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static Download::Status SKDownloadStateToDownloadStatus (SKDownloadState state)
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case SKDownloadStateWaiting: return Download::Status::waiting;
|
||||
case SKDownloadStateActive: return Download::Status::active;
|
||||
case SKDownloadStatePaused: return Download::Status::paused;
|
||||
case SKDownloadStateFinished: return Download::Status::finished;
|
||||
case SKDownloadStateFailed: return Download::Status::failed;
|
||||
case SKDownloadStateCancelled: return Download::Status::cancelled;
|
||||
default: jassertfalse; return Download::Status::waiting;
|
||||
}
|
||||
}
|
||||
|
||||
static NSArray<SKDownload*>* downloadsToSKDownloads (const Array<Download*>& downloads)
|
||||
{
|
||||
NSMutableArray<SKDownload*>* skDownloads = [NSMutableArray arrayWithCapacity: (NSUInteger) downloads.size()];
|
||||
|
||||
for (const auto& d : downloads)
|
||||
if (auto impl = dynamic_cast<DownloadImpl*>(d))
|
||||
[skDownloads addObject: impl->download];
|
||||
|
||||
return skDownloads;
|
||||
}
|
||||
|
||||
template <typename ObjCType>
|
||||
static ObjCType* getAs (id o)
|
||||
{
|
||||
if (o == nil || ! [o isKindOfClass: [ObjCType class]])
|
||||
return nil;
|
||||
|
||||
return (ObjCType*) o;
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
InAppPurchases& owner;
|
||||
|
||||
OwnedArray<PendingProductInfoRequest> pendingProductInfoRequests;
|
||||
OwnedArray<PendingReceiptRefreshRequest> pendingReceiptRefreshRequests;
|
||||
|
||||
OwnedArray<PendingDownloadsTransaction> pendingDownloadsTransactions;
|
||||
Array<Listener::PurchaseInfo> restoredPurchases;
|
||||
};
|
||||
|
||||
} // namespace juce
|
Reference in New Issue
Block a user