/*
* Copyright 2014 serso aka se.solovyev
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* Contact details
*
* Email: se.solovyev@gmail.com
* Site: http://se.solovyev.org
*/
package org.solovyev.android.checkout;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.Immutable;
import static java.util.Collections.sort;
import static java.util.Collections.unmodifiableCollection;
import static java.util.Collections.unmodifiableList;
/**
* Class that can load information about products, SKUs and purchases. This class can't be
* instantiated directly but only through {@link Checkout#loadInventory(Request, Callback)} or
* {@link Checkout#makeInventory()} method calls.
* This class lifecycle is bound to the lifecycle of {@link Checkout} in which it was created. If
* {@link Checkout} stops this class loading also stops and no
* {@link Callback#onLoaded(Inventory.Products)} method is called.
*/
@SuppressWarnings({"WeakerAccess", "unused"})
public interface Inventory {
/**
* Loads {@link Products} and asynchronously delivers it to the provided {@link Callback}. Data
* to be loaded is defined by {@link Request} argument. For each load request a task is created
* whose identifier is returned in this method. The task can be later cancelled via
* {@link #cancel(int)} method.
* @param request request definition
* @return task identifier
*/
int load(@Nonnull Request request, @Nonnull Callback callback);
/**
* Cancels all load tasks, if any.
*/
void cancel();
/**
* Cancels a task by id. Id can be obtained from {@link #load(Request, Callback)} method.
* @param id task id
*/
void cancel(int id);
/**
* @return true if there is at least one task that is still running, false otherwise
*/
boolean isLoading();
/**
* A callback of {@link #load(Request, Callback)} method.
*/
interface Callback {
/**
* Called when all the products were loaded. Note that this method is called even if the
* loading fails.
* @param products loaded products
*/
void onLoaded(@Nonnull Inventory.Products products);
}
/**
* Set of products in the inventory.
*/
@Immutable
final class Products implements Iterable<Inventory.Product> {
@Nonnull
static final Products sEmpty = new Products();
@Nonnull
private final Map<String, Inventory.Product> mMap = new HashMap<>();
Products() {
for (String product : ProductTypes.ALL) {
mMap.put(product, new Product(product, false));
}
}
@Nonnull
public static Products empty() {
return sEmpty;
}
void add(@Nonnull Inventory.Product product) {
mMap.put(product.id, product);
}
/**
* @param productId product id
* @return product by id
*/
@Nonnull
public Inventory.Product get(@Nonnull String productId) {
ProductTypes.checkSupported(productId);
return mMap.get(productId);
}
/**
* @return unmodifiable iterator which iterates over all products
*/
@Override
public Iterator<Inventory.Product> iterator() {
return unmodifiableCollection(mMap.values()).iterator();
}
/**
* @return number of products
*/
public int size() {
return mMap.size();
}
void merge(@Nonnull Products products) {
for (Map.Entry<String, Product> entry : mMap.entrySet()) {
if (!entry.getValue().supported) {
final Product product = products.mMap.get(entry.getKey());
if (product != null) {
entry.setValue(product);
}
}
}
}
}
/**
* One product in the inventory. Contains list of purchases and optionally list of SKUs (if
* {@link Request} contains information about SKUs)
*/
@Immutable
final class Product {
/**
* Product ID, see {@link org.solovyev.android.checkout.ProductTypes}
*/
@Nonnull
public final String id;
/**
* True if product is supported by {@link Inventory}. Note that Billing for this product
* might not be supported:
* this just indicates that {@link Inventory} loaded purchases/SKUs for the product.
*/
public final boolean supported;
/**
* This list is loaded only if {@link Request#loadPurchases(String)} was called for this
* product.
*/
@Nonnull
final List<Purchase> mPurchases = new ArrayList<>();
/**
* This list is loaded only if {@link Request#loadSkus(String, List)} was called for this
* product and contains only SKUs listed in the original request.
*/
@Nonnull
final List<Sku> mSkus = new ArrayList<>();
Product(@Nonnull String id, boolean supported) {
ProductTypes.checkSupported(id);
this.id = id;
this.supported = supported;
}
public boolean isPurchased(@Nonnull Sku sku) {
return isPurchased(sku.id.code);
}
public boolean isPurchased(@Nonnull String sku) {
return hasPurchaseInState(sku, Purchase.State.PURCHASED);
}
public boolean hasPurchaseInState(@Nonnull String sku, @Nonnull Purchase.State state) {
return getPurchaseInState(sku, state) != null;
}
@Nullable
public Purchase getPurchaseInState(@Nonnull String sku, @Nonnull Purchase.State state) {
return Purchases.getPurchaseInState(mPurchases, sku, state);
}
@Nullable
public Purchase getPurchaseInState(@Nonnull Sku sku, @Nonnull Purchase.State state) {
return getPurchaseInState(sku.id.code, state);
}
/**
* This list doesn't contain duplicates, i.e. each element in the list has unique SKU
* @return unmodifiable list of purchases sorted by purchase date (latest first)
*/
@Nonnull
public List<Purchase> getPurchases() {
return unmodifiableList(mPurchases);
}
void setPurchases(@Nonnull List<Purchase> purchases) {
Check.isTrue(mPurchases.isEmpty(), "Must be called only once");
mPurchases.addAll(Purchases.neutralize(purchases));
sort(mPurchases, PurchaseComparator.latestFirst());
}
/**
* @return unmodifiable list of SKUs in the product
*/
@Nonnull
public List<Sku> getSkus() {
return unmodifiableList(mSkus);
}
void setSkus(@Nonnull List<Sku> skus) {
Check.isTrue(mSkus.isEmpty(), "Must be called only once");
mSkus.addAll(skus);
}
@Nullable
public Sku getSku(@Nonnull String sku) {
for (Sku s : mSkus) {
if (s.id.code.equals(sku)) {
return s;
}
}
return null;
}
}
/**
* This class defines what data should be loaded into the {@link Products} after
* {@link #load(Request, Callback)} finishes.
*/
final class Request {
// list of SKUs for which the details are loaded
private final Map<String, List<String>> mSkus = new HashMap<>();
// set of products for which purchase information is loaded
private final Set<String> mProducts = new HashSet<>();
private Request() {
for (String product : ProductTypes.ALL) {
mSkus.put(product, new ArrayList<String>(5));
}
}
@Nonnull
Request copy() {
final Request copy = new Request();
copy.mSkus.putAll(mSkus);
copy.mProducts.addAll(mProducts);
return copy;
}
/**
* Creates an empty load request. Only {@link Product#supported} flag is set in
* {@link #load(Request, Callback)} for all available for billing products.
* @return empty request
* @see ProductTypes
*/
@Nonnull
public static Request create() {
return new Request();
}
/**
* Makes {@link Inventory} to load purchases for all available for billing products,
* see {@link ProductTypes#ALL}.
* @return this request
* @see BillingRequests#getAllPurchases(String, RequestListener)
*/
@Nonnull
public Request loadAllPurchases() {
mProducts.addAll(ProductTypes.ALL);
return this;
}
/**
* Makes {@link Inventory} to load all purchases for the given <var>product</var>.
* @param product product
* @return this request
* @see BillingRequests#getAllPurchases(String, RequestListener)
*/
@Nonnull
public Request loadPurchases(@Nonnull String product) {
ProductTypes.checkSupported(product);
mProducts.add(product);
return this;
}
boolean shouldLoadPurchases(@Nonnull String product) {
return mProducts.contains(product);
}
/**
* Same as {@link #loadSkus(String, List)}.
*/
@Nonnull
public Request loadSkus(@Nonnull String product, @Nonnull String... skus) {
Check.isTrue(skus.length > 0, "No SKUs listed, can't load them");
return loadSkus(product, Arrays.asList(skus));
}
/**
* Makes {@link Inventory} to load SKU details for the given list of <var>skus</var>. As
* SKU identifier is unique only in product <var>product</var> id must be also provided to
* this method.
* @param product product
* @param skus list of SKU identifiers for which SKU details should be loaded
* @return this request
*/
@Nonnull
public Request loadSkus(@Nonnull String product, @Nonnull List<String> skus) {
for (String sku : skus) {
loadSkus(product, sku);
}
return this;
}
/**
* Same as {@link #loadSkus(String, List)} with one element in the list.
*/
@Nonnull
public Request loadSkus(@Nonnull String product, @Nonnull String sku) {
ProductTypes.checkSupported(product);
Check.isNotEmpty(sku);
final List<String> list = mSkus.get(product);
Check.isTrue(!list.contains(sku), "Adding same SKU is not allowed");
list.add(sku);
return this;
}
boolean shouldLoadSkus(@Nonnull String product) {
ProductTypes.checkSupported(product);
return !mSkus.get(product).isEmpty();
}
@Nonnull
List<String> getSkus(@Nonnull String product) {
return mSkus.get(product);
}
}
}