public class NamedFragmentPagerAdapter extends FragmentPagerAdapter {

    private List<String> fragmentNames = new ArrayList<String>();
    private List<Fragment> fragments = new ArrayList<Fragment>();

    /**
     * Creates a new fragment pager adapter.
     *
     * @param fragmentManager
     *         the fragment manager to use for transactions
     */
    public NamedFragmentPagerAdapter(FragmentManager fragmentManager) {
        super(fragmentManager);
    }

    /**
     * Looks up a fragment in the fragment manager's pool of managed fragments.
     *
     * Very dirty fix to get fragments back into the adapter after an orientation change. Relies on the
     * internal fragment naming scheme.
     *
     * @param fragmentPagerAdapter
     *         the fragment pager adapter
     * @param fragmentManager
     *         the fragment manager
     * @param viewPagerId
     *         the ID of the view pager containing the fragment
     * @param position
     *         the position of the tab
     * @return the fragment if found in the cache or null
     */
    public static Fragment findFragmentByPosition(FragmentPagerAdapter fragmentPagerAdapter,
            FragmentManager fragmentManager, int viewPagerId, int position) {
        return fragmentManager.findFragmentByTag(makeFragmentName(viewPagerId, fragmentPagerAdapter
                .getItemId(position)));
    }

    /**
     * Copied from {@link FragmentPagerAdapter}.
     *
     * @param viewId
     *         the ID of the view pager
     * @param id
     *         the ID of the tab
     * @return the fragment's tag name
     */
    private static String makeFragmentName(int viewId, long id) {
        return "android:switcher:" + viewId + ":" + id;
    }

    @Override
    public Fragment getItem(int position) {
        return fragments.get(position);
    }

    @Override
    public int getCount() {
        return fragments.size();
    }

    @Override
    public CharSequence getPageTitle(int position) {
        return fragmentNames.get(position);
    }

    /**
     * Adds a fragment to the pager adapter.
     *
     * @param name
     *         the name of the fragment to display in a tab
     * @param fragment
     *         the fragment to add
     */
    public void addFragment(String name, Fragment fragment) {
        fragmentNames.add(name);
        fragments.add(fragment);
    }

    /*@Override
    public Object instantiateItem(ViewGroup container, int position) {
        Fragment fragment = (Fragment) super.instantiateItem(container, position);
        while (position > fragments.size() - 1) {
            fragments.add(null);
            fragmentNames.add(null);
        }
        fragments.set(position, fragment);
        if (fragmentNames.get(position) == null) {
            fragmentNames.set(position, "");
        }
        return fragment;
    }*/
} public class TimelineAdapter extends RecyclerView.Adapter<TimelineAdapter.FeedItemHolder> {

    private final Activity activity;
    private final NavigationHandler navigationHandler;
    private final UserHandler userHandler;
    private final PLYAndroid client;

    private Retrieval retrieval;

    private int loadItems;
    private int settings;

    private int itemWidthPx;
    private int imageMaxHeightPx;
    private int avatarSizePx;

    private List<BaseObject> feedItems = new ArrayList<BaseObject>();
    private String[] feedUrls = new String[TimeRel.values().length];

    // listeners would be garbage-collected without reference
    private DataChangeListener.OnProductCreateListener onProductCreateListener;
    private DataChangeListener.OnProductUpdateListener onProductUpdateListener;
    private DataChangeListener.OnOpinionCreateListener onOpinionCreateListener;
    private DataChangeListener.OnOpinionUpdateListener onOpinionUpdateListener;
    private DataChangeListener.OnImageCreateListener onImageCreateListener;
    private DataChangeListener.OnImageUpdateListener onImageUpdateListener;

    private AtomicBoolean loading = new AtomicBoolean(false);

    /**
     * Creates the social feed adapter and starts to retrieve the first batch of data.
     *
     * Use {@link #setItemDimensions} before attaching the adapter to a view. Use {@link #setupListeners} and
     * {@link #destroyListeners} to listen for data changes by the user.
     *
     * @param activity
     *         the activity the adapter's view is attached to
     * @param navigationHandler
     *         the activity's navigation handler to open new screens
     * @param userHandler
     *         the activity's user handler to get current user information
     * @param client
     *         the PLYAndroid REST client to use for querying for data
     * @param retrieval
     *         configured call to retrieve the initial set of items
     * @param loadItems
     *         the amount of items to load in one batch
     * @param settings
     *         the display settings for this timeline
     */
    public TimelineAdapter(Activity activity, NavigationHandler navigationHandler, UserHandler userHandler,
            PLYAndroid client, Retrieval retrieval, int loadItems, int settings) {
        this.activity = activity;
        this.navigationHandler = navigationHandler;
        this.userHandler = userHandler;
        this.client = client;
        this.retrieval = retrieval;
        this.loadItems = loadItems;
        this.settings = settings;
        setHasStableIds(true);
        retrieveFeedItems(TimeRel.INITIAL);
    }

    /**
     * Compares an updated or new image to the current default image of a product, checking if it would make
     * the new default image.
     *
     * @param currentDefault
     *         the product's current default image
     * @param image
     *         the new or updated image
     * @return 1 if the specified image is the new default image of the product; -1 if the specified image is
     * the current default and was down-voted (to trigger updates even though it might still remain the
     * default image); 0 else
     */
    public static int triggersDefaultImageChange(ProductImage currentDefault, ProductImage image) {
        if (currentDefault == null) {
            // no default image yet - new image is the new default
            return 1;
        }
        List<SimpleUserInfo> currentUpVoters = currentDefault.getUpVoters();
        List<SimpleUserInfo> currentDownVoters = currentDefault.getDownVoters();
        int cntCurrentUpVoters = currentUpVoters == null ? 0 : currentUpVoters.size();
        int cntCurrentDownVoters = currentDownVoters == null ? 0 : currentDownVoters.size();
        List<SimpleUserInfo> newUpVoters = image.getUpVoters();
        List<SimpleUserInfo> newDownVoters = image.getDownVoters();
        int cntNewUpVoters = newUpVoters == null ? 0 : newUpVoters.size();
        int cntNewDownVoters = newDownVoters == null ? 0 : newDownVoters.size();
        int scoreDifference = cntNewUpVoters - cntNewDownVoters - (cntCurrentUpVoters - cntCurrentDownVoters);
        if (currentDefault.getId().equals(image.getId())) {
            // current default image was downvoted - another image may become default (else no change)
            return scoreDifference < 0 ? -1 : 0;
        }
        if (scoreDifference > 0) {
            // new image has more upvoters and is the new default
            return 1;
        }
        if (scoreDifference == 0) {
            if (image.getCreated() > currentDefault.getCreated()) {
                // new image has same amount of upvoters and is newer making it the new default
                return 1;
            }
        }
        return 0;
    }

    /**
     * Removes an item with the specified ID of a list of feed items if found.
     *
     * @param items
     *         the list of feed items
     * @param id
     *         the ID of the object to remove
     */
    private static void filterItem(Iterable<BaseObject> items, String id) {
        Iterator<BaseObject> it = items.iterator();
        while (it.hasNext()) {
            BaseObject obj = it.next();
            if (id.equals(obj.getId())) {
                it.remove();
                break;
            }
        }
    }

    /**
     * Sets the fixed width of items and the maximum height of images. Images are retrieved in {@link
     * #onBindViewHolder(FeedItemHolder, int)} in the dimensions set here.
     *
     * @param itemWidthPx
     *         the width of an item
     * @param imageMaxHeightPx
     *         the maximum height of an image
     * @param avatarSizePx
     *         the width/height of the avatar of an item's author
     */
    public void setItemDimensions(int itemWidthPx, int imageMaxHeightPx, int avatarSizePx) {
        this.itemWidthPx = itemWidthPx;
        this.imageMaxHeightPx = imageMaxHeightPx;
        this.avatarSizePx = avatarSizePx;
    }

    /**
     * Retrieves items from the timeline.
     *
     * @param timeRel
     *         whether to retrieve the initial batch of items, or earlier or later items
     * @return false if a previous retrieval is still ongoing, true else
     */
    public boolean retrieveFeedItems(TimeRel timeRel) {
        if (!loading.compareAndSet(false, true)) {
            // TODO reset loading if true for a long time (might happen due to orientation changes)
            // already retrieving items
            return false;
        }
        final TimeRel finalTimeRel = feedItems.isEmpty() ? TimeRel.INITIAL : timeRel;
        Log.d(getClass().getSimpleName(), "Retrieving " + finalTimeRel.name() + " timeline ...");
        LoadingIndicator.show();
        PLYCompletion<ResultSetWithCursor> completion = new PLYCompletion<ResultSetWithCursor>() {
            @Override
            public void onSuccess(ResultSetWithCursor result) {
            }

            @Override
            public void onPostSuccess(ResultSetWithCursor result) {
                Log.d("FeedCallback", "Received ResultSetWithCursor for " + finalTimeRel.name() + " " +
                        retrieval.type().name() + " timeline");
                List<BaseObject> newItems = result.getResults();
                // if (retrieval.type() == TimelineType.PRODUCT) {
                // filter the default product image since it's displayed and can be opened in the app bar
                // TODO filter default image and make it clickable in the app bar instead (prob: updates)
                // }
                int newItemCnt = newItems.size();
                if (newItemCnt != 0) {
                    int curItemCnt = feedItems.size();
                    feedUrls[TimeRel.INITIAL.value] = result.getThisResultsUrl();
                    if (finalTimeRel == TimeRel.INITIAL) {
                        feedItems = newItems;
                        feedUrls[TimeRel.EARLIER.value] = result.getSinceThisResultsUrl();
                        feedUrls[TimeRel.LATER.value] = result.getUntilThisResultsUrl();
                        notifyDataSetChanged();
                    } else if (finalTimeRel == TimeRel.EARLIER) {
                        newItems.addAll(feedItems);
                        feedItems = newItems;
                        feedUrls[TimeRel.EARLIER.value] = result.getSinceThisResultsUrl();
                        notifyItemRangeInserted(0, newItemCnt);
                    } else {
                        feedItems.addAll(newItems);
                        feedUrls[TimeRel.LATER.value] = result.getUntilThisResultsUrl();
                        notifyItemRangeInserted(curItemCnt, newItemCnt);
                    }
                } else {
                    if (finalTimeRel == TimeRel.INITIAL) {
                        feedItems.clear();
                        feedUrls[TimeRel.EARLIER.value] = null;
                        feedUrls[TimeRel.LATER.value] = null;
                        notifyDataSetChanged();
                    }
                }
                loading.set(false);
                LoadingIndicator.hide();
            }

            @Override
            public void onError(PLYAndroid.QueryError error) {
                // TODO check if server or client error and display message / retry if init
                Log.e("FeedCallback", error.getMessage(), error.getException());
                loading.set(false);
                LoadingIndicator.hide();
            }
        };
        if (finalTimeRel == TimeRel.EARLIER) {
            TimelineService.getTimelineFromURL(client, feedUrls[TimeRel.EARLIER.value], completion);
        } else if (finalTimeRel == TimeRel.LATER) {
            TimelineService.getTimelineFromURL(client, feedUrls[TimeRel.LATER.value], completion);
        } else {
            retrieval.initiate(client, loadItems, settings, completion);
        }
        return true;
    }

    @Override
    public FeedItemHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        if (viewType == R.id.feed_item_product) {
            ProductPreview productPreview = new ProductPreview(activity, navigationHandler, itemWidthPx,
                    imageMaxHeightPx, avatarSizePx);
            return new FeedItemHolder(productPreview);
        } else if (viewType == R.id.feed_item_opinion) {
            OpinionView opinionView = new OpinionView(activity, navigationHandler, itemWidthPx, avatarSizePx);
            return new FeedItemHolder(opinionView);
        } else if (viewType == R.id.feed_item_image) {
            ImagePreview imagePreview = new ImagePreview(activity, navigationHandler, itemWidthPx,
                    imageMaxHeightPx, avatarSizePx);
            return new FeedItemHolder(imagePreview);
        }
        return null;
    }

    @Override
    public void onBindViewHolder(FeedItemHolder holder, int position) {
        BaseObject item = feedItems.get(position);
        if (item instanceof Product) {
            // PRODUCT
            Product product = (Product) item;
            ProductImage productImage = product.getDefaultImage();
            String productImageUrl = null;
            int[] productDominantColor = null;
            if (productImage != null) {
                float widthToHeightRatio = productImage.getWidth() / (float) productImage.getHeight();
                int imageHeightPx = Math.min(Math.round(itemWidthPx / widthToHeightRatio), imageMaxHeightPx);
                productImageUrl = ImageService.getImageForSizeURL(client, productImage.getImageFileId(),
                        itemWidthPx, imageHeightPx, true, null);
                productDominantColor = productImage.getDominantColor();
            }
            SimpleUserInfo author = product.getCreatedBy();
            String authorImageUrl = ImageService.getUserAvatarURL(client, author.getId(), avatarSizePx);
            holder.productPreview.setProduct(product, productImageUrl, productDominantColor, author,
                    authorImageUrl, userHandler, client);
        } else if (item instanceof Opine) {
            // OPINION
            Opine opinion = (Opine) item;
            SimpleUserInfo author = opinion.getCreatedBy();
            String authorImageUrl = ImageService.getUserAvatarURL(client, author.getId(), avatarSizePx);
            holder.opinionView.setOpinion(opinion, author, authorImageUrl, userHandler, client);
        } else if (item instanceof ProductImage) {
            // IMAGE
            ProductImage productImage = (ProductImage) item;
            float widthToHeightRatio = productImage.getWidth() / (float) productImage.getHeight();
            int imageHeightPx = Math.min(Math.round(itemWidthPx / widthToHeightRatio), imageMaxHeightPx);
            String productImageUrl = ImageService.getImageForSizeURL(client, productImage.getImageFileId(),
                    itemWidthPx, imageHeightPx, true, null);
            SimpleUserInfo author = productImage.getCreatedBy();
            String authorImageUrl = ImageService.getUserAvatarURL(client, author.getId(), avatarSizePx);
            holder.imagePreview.setImage(productImage, productImageUrl, productImage.getDominantColor(),
                    author, authorImageUrl, userHandler);
        } else {
            // UNSUPPORTED
            throw new RuntimeException("Unsupported item in timeline: " + item.getBeautifiedClass());
        }
    }

    @Override
    public int getItemCount() {
        return feedItems.size();
    }

    @Override
    public long getItemId(int position) {
        return feedItems.get(position).hashCode();
    }

    @Override
    public int getItemViewType(int position) {
        BaseObject item = feedItems.get(position);
        if (item instanceof Product) {
            return R.id.feed_item_product;
        } else if (item instanceof Opine) {
            return R.id.feed_item_opinion;
        } else if (item instanceof ProductImage) {
            return R.id.feed_item_image;
        } else {
            return 0;
        }
    }

    /**
     * Refreshes the timeline using new settings.
     *
     * @param settings
     *         the new settings to apply
     */
    public void updateSettings(int settings) {
        this.settings = settings;
        retrieveFeedItems(TimeRel.INITIAL);
    }

    /**
     * Sets up and registers listeners to be notified of product/opinion/image creations and updates by the
     * user.
     *
     * Call {@link #destroyListeners} to remove the listeners without having to wait for garbage collection.
     */
    public void setupListeners() {
        if (onProductCreateListener != null) {
            return;
        }
        final TimelineType timelineType = retrieval.type();
        final String identifier = retrieval.identifier();
        onProductCreateListener = new DataChangeListener.OnProductCreateListener() {
            @Override
            public void onProductCreate(Product product) {
                if (timelineType == TimelineType.PRODUCT) {
                    // don't care about new products in a product timeline
                    return;
                }
                if (timelineType == TimelineType.USER && (userHandler.getUser() == null || !userHandler
                        .getUser().getId().equals(identifier))) {
                    // don't care about new products (created by oneself) in another user's timeline
                    return;
                }
                // load the initial set of timeline items if the user added a new product
                Log.v("TimelinePCallback", "Product creation event received");
                retrieveFeedItems(TimeRel.INITIAL);
            }
        };
        DataChangeListener.addOnProductCreateListener(onProductCreateListener);
        onProductUpdateListener = new DataChangeListener.OnProductUpdateListener() {
            @Override
            public void onProductUpdate(final Product product) {
                if (timelineType == TimelineType.PRODUCT && !product.getGtin().equals(identifier)) {
                    // don't care about product updates in another product's timeline
                    return;
                }
                // refresh the product if it has already been loaded
                Log.v("TimelinePCallback", "Product update received for ID " + product.getId());
                if (!loading.compareAndSet(false, true)) {
                    // ignore the update if the feed is currently working
                    return;
                }
                int cntFeedItems = feedItems.size();
                for (int i = 0; i < cntFeedItems; i++) {
                    BaseObject baseObject = feedItems.get(i);
                    if (baseObject instanceof Product) {
                        Product p = (Product) baseObject;
                        if (p.getId().equals(product.getId())) {
                            final int position = i;
                            activity.runOnUiThread(new Runnable() {
                                @Override
                                public void run() {
                                    feedItems.set(position, product);
                                    notifyItemChanged(position);
                                }
                            });
                        }
                    } else if (baseObject instanceof Opine) {
                        // update the product contained in opinions
                        final Opine o = (Opine) baseObject;
                        if (o.getProduct().getId().equals(product.getId())) {
                            final int position = i;
                            activity.runOnUiThread(new Runnable() {
                                @Override
                                public void run() {
                                    o.setProduct(product);
                                    notifyItemChanged(position);
                                }
                            });
                        }
                    } else if (baseObject instanceof ProductImage) {
                        // update the product contained in product images
                        final ProductImage pi = (ProductImage) baseObject;
                        if (pi.getProduct().getId().equals(product.getId())) {
                            final int position = i;
                            activity.runOnUiThread(new Runnable() {
                                @Override
                                public void run() {
                                    pi.setProduct(product);
                                    notifyItemChanged(position);
                                }
                            });
                        }
                    }
                }
                activity.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        loading.set(false);
                    }
                });
            }
        };
        DataChangeListener.addOnProductUpdateListener(onProductUpdateListener);
        onOpinionCreateListener = new DataChangeListener.OnOpinionCreateListener() {
            @Override
            public void onOpinionCreate(Opine opinion) {
                if (timelineType == TimelineType.PRODUCT && !opinion.getGtin().equals(identifier)) {
                    // don't care about new opinions in a different product's timeline
                    return;
                }
                if (timelineType == TimelineType.USER && userHandler.getUser() != null && !userHandler
                        .getUser().getId().equals(identifier)) {
                    // don't care about new opinions (created by oneself) in another user's timeline
                    return;
                }
                // load the initial set of timeline items if the user added a new opinion
                Log.v("TimelineOCallback", "Opinion creation event received");
                retrieveFeedItems(TimeRel.INITIAL);
            }
        };
        DataChangeListener.addOnOpinionCreateListener(onOpinionCreateListener);
        onOpinionUpdateListener = new DataChangeListener.OnOpinionUpdateListener() {
            @Override
            public void onOpinionUpdate(final Opine opinion) {
                if (timelineType == TimelineType.PRODUCT && !opinion.getGtin().equals(identifier)) {
                    // don't care about opinion updates in a different product's timeline
                    return;
                }
                if (timelineType == TimelineType.USER && userHandler.getUser() != null && !userHandler
                        .getUser().getId().equals(identifier)) {
                    // don't care about opinion updates in another user's timeline
                    return;
                }
                // refresh the opinion if it has already been loaded
                Log.v("TimelineOCallback", "Opinion update received for ID " + opinion.getId());
                if (!loading.compareAndSet(false, true)) {
                    // ignore the update if the feed is currently working
                    return;
                }
                int cntFeedItems = feedItems.size();
                for (int i = 0; i < cntFeedItems; i++) {
                    BaseObject baseObject = feedItems.get(i);
                    if (baseObject instanceof Opine) {
                        Opine o = (Opine) baseObject;
                        if (o.getId().equals(opinion.getId())) {
                            final int position = i;
                            if (opinion.getProduct() == null) {
                                // an updated opinion may not return the product it belongs to - re-set
                                opinion.setProduct(o.getProduct());
                            }
                            activity.runOnUiThread(new Runnable() {
                                @Override
                                public void run() {
                                    feedItems.set(position, opinion);
                                    notifyItemChanged(position);
                                }
                            });
                            break;
                        }
                    }
                }
                activity.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        loading.set(false);
                    }
                });
            }
        };
        DataChangeListener.addOnOpinionUpdateListener(onOpinionUpdateListener);
        onImageCreateListener = new DataChangeListener.OnImageCreateListener() {
            @Override
            public void onImageCreate(ProductImage image) {
                if (timelineType == TimelineType.PRODUCT) {
                    // if in the product timeline simply reload the initial data set
                    if (image.getGtin().equals(identifier)) {
                        Log.v("TimelineICallback", "Image creation event received");
                        // only care if this product timeline identifies the same product as the image
                        retrieveFeedItems(TimeRel.INITIAL);
                    }
                } else {
                    Log.v("TimelineICallback", "Image creation event received");
                    // default image changes for products if no images existed or none were upvoted
                    if (!loading.compareAndSet(false, true)) {
                        // ignore the update if the feed is currently working
                        return;
                    }
                    int cntFeedItems = feedItems.size();
                    for (int i = 0; i < cntFeedItems; i++) {
                        BaseObject baseObject = feedItems.get(i);
                        if (baseObject instanceof Product) {
                            Product p = (Product) baseObject;
                            if (p.getGtin().equals(image.getGtin())) {
                                if (triggersDefaultImageChange(p.getDefaultImage(), image) == 1) {
                                    // new image is the new default
                                    p.setDefaultImage(image);
                                    final int position = i;
                                    activity.runOnUiThread(new Runnable() {
                                        @Override
                                        public void run() {
                                            notifyItemChanged(position);
                                        }
                                    });
                                }
                            }
                        }
                    }
                    activity.runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            loading.set(false);
                        }
                    });
                }
            }
        };
        DataChangeListener.addOnImageCreateListener(onImageCreateListener);
        onImageUpdateListener = new DataChangeListener.OnImageUpdateListener() {
            @Override
            public void onImageUpdate(final ProductImage image) {
                if (timelineType == TimelineType.PRODUCT && !image.getGtin().equals(identifier)) {
                    // don't care about image updates in a different product's timeline
                    return;
                }
                Log.v("TimelineICallback", "Image update received for ID " + image.getId());
                // up-/downvoting may result in default product image changes
                if (!loading.compareAndSet(false, true)) {
                    // ignore the update if the feed is currently working
                    return;
                }
                int cntFeedItems = feedItems.size();
                for (int i = 0; i < cntFeedItems; i++) {
                    BaseObject baseObject = feedItems.get(i);
                    if (baseObject instanceof ProductImage) {
                        ProductImage img = (ProductImage) baseObject;
                        if (img.getId().equals(image.getId())) {
                            final int position = i;
                            if (image.getProduct() == null) {
                                // an updated image may not return the product it belongs to - re-set
                                image.setProduct(img.getProduct());
                            }
                            activity.runOnUiThread(new Runnable() {
                                @Override
                                public void run() {
                                    feedItems.set(position, image);
                                    notifyItemChanged(position);
                                }
                            });
                        }
                    } else if (baseObject instanceof Product) {
                        final Product p = (Product) baseObject;
                        if (p.getGtin().equals(image.getGtin())) {
                            final int position = i;
                            int defaultImageChange = triggersDefaultImageChange(p.getDefaultImage(), image);
                            Log.v("TimelineICallback", "Triggers default image change: " +
                                    defaultImageChange);
                            if (defaultImageChange == 1) {
                                // updated image is the new default
                                p.setDefaultImage(image);
                                activity.runOnUiThread(new Runnable() {
                                    @Override
                                    public void run() {
                                        notifyItemChanged(position);
                                    }
                                });
                            } else if (defaultImageChange == -1 && p.getImageCount() > 1) {
                                // updated image was the default before but may not be anymore
                                ProductService.getProductForGtin(client, p.getGtin(), null, false, null,
                                        new PLYCompletion<Product>() {
                                            @Override
                                            public void onSuccess(Product result) {
                                            }

                                            @Override
                                            public void onPostSuccess(Product result) {
                                                ProductImage newDefaultImage = result.getDefaultImage();
                                                p.setDefaultImage(newDefaultImage);
                                                notifyItemChanged(position);
                                            }

                                            @Override
                                            public void onError(PLYAndroid.QueryError error) {
                                                Log.w(TimelineAdapter.class.getSimpleName(), error
                                                        .getMessage());
                                            }
                                        });
                            }
                        }
                    }
                    // TODO update images in opinions once implemented
                }
                activity.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        loading.set(false);
                    }
                });
            }
        };
        DataChangeListener.addOnImageUpdateListener(onImageUpdateListener);
    }

    /**
     * Destroys listeners to stop being notified of any updates.
     */
    public void destroyListeners() {
        if (onProductCreateListener == null) {
            return;
        }
        DataChangeListener.removeOnProductCreateListener(onProductCreateListener);
        DataChangeListener.removeOnProductUpdateListener(onProductUpdateListener);
        DataChangeListener.removeOnOpinionCreateListener(onOpinionCreateListener);
        DataChangeListener.removeOnOpinionUpdateListener(onOpinionUpdateListener);
        DataChangeListener.removeOnImageCreateListener(onImageCreateListener);
        DataChangeListener.removeOnImageUpdateListener(onImageUpdateListener);
        onProductCreateListener = null;
        onProductUpdateListener = null;
        onOpinionCreateListener = null;
        onOpinionUpdateListener = null;
        onImageCreateListener = null;
        onImageUpdateListener = null;
    }

    /**
     * Enum for a time relation.
     */
    public enum TimeRel {
        INITIAL(0),
        EARLIER(1),
        LATER(2);

        public final int value;

        TimeRel(int value) {
            this.value = value;
        }
    }

    /**
     * Enum for the type of timeline.
     */
    public enum TimelineType {
        GLOBAL, PRODUCT, USER
    }

    /**
     * Interface passed to a {@link TimelineAdapter} to retrieve the first set of feed items. Subsequent
     * retrievals are done using the URLs returned from the initial set of results.
     */
    public interface Retrieval {

        /**
         * Starts the initial retrieval of the feed.
         *
         * @param client
         *         the PLYAndroid client to use for retrievals
         * @param loadItems
         *         the amount of items to load in one retrieval
         * @param settings
         *         the display settings for this timeline
         * @param completion
         *         the completion callback to run once results are returned
         */
        void initiate(PLYAndroid client, int loadItems, int settings, PLYCompletion<ResultSetWithCursor>
                completion);

        /**
         * @return the type this timeline retrieval represents
         */
        TimelineType type();

        /**
         * @return the gtin if a product timeline, the user ID if a user timeline, null if a global timeline
         */
        String identifier();

    }

    /**
     * ViewHolder cached by the RecyclerView. Holds a feed item cardview of one type.
     */
    public static class FeedItemHolder extends RecyclerView.ViewHolder {

        public ProductPreview productPreview;
        public OpinionView opinionView;
        public ImagePreview imagePreview;

        /**
         * Creates the ViewHolder caching a Product feed item.
         *
         * @param productPreview
         *         the ProductPreview to cache
         */
        public FeedItemHolder(ProductPreview productPreview) {
            super(productPreview);
            this.productPreview = productPreview;
        }

        /**
         * Creates the ViewHolder caching an Opinion feed item.
         *
         * @param opinionView
         *         the OpinionView to cache
         */
        public FeedItemHolder(OpinionView opinionView) {
            super(opinionView);
            this.opinionView = opinionView;
        }

        /**
         * Creates the ViewHolder caching an Image feed item.
         *
         * @param imagePreview
         *         the ImagePreview to cache
         */
        public FeedItemHolder(ImagePreview imagePreview) {
            super(imagePreview);
            this.imagePreview = imagePreview;
        }
    }
} public class LoadingIndicator {

    private static WeakReference<View> loadingIndicatorRef;

    private static Handler mainHandler;

    /**
     * Sets the loading indicator view to show and hide on command. This widget must be attached to the
     * activity's layout and must be at least on the same hierarchy level as overlapping views (it may be
     * hidden under sibling views).
     *
     * @param loadingIndicator
     *         the view to show and hide on command
     * @param mainHandler
     *         a handler associated with the main thread
     */
    public static void set(View loadingIndicator, Handler mainHandler) {
        loadingIndicatorRef = new WeakReference<View>(loadingIndicator);
        LoadingIndicator.mainHandler = mainHandler;
    }

    /**
     * Brings the loading indicator to the front and makes it {@link View#VISIBLE}.
     */
    public static void show() {
        final View loadingIndicator = getLoadingIndicator();
        if (loadingIndicator == null) {
            return;
        }
        if (ThreadUtil.isMainThread()) {
            loadingIndicator.bringToFront();
            loadingIndicator.setVisibility(View.VISIBLE);
        } else {
            mainHandler.post(new Runnable() {
                @Override
                public void run() {
                    loadingIndicator.bringToFront();
                    loadingIndicator.setVisibility(View.VISIBLE);
                }
            });
        }
    }

    /**
     * Sets the loading indicator's visibility to {@link View#GONE}.
     */
    public static void hide() {
        final View loadingIndicator = getLoadingIndicator();
        if (loadingIndicator == null) {
            return;
        }
        if (ThreadUtil.isMainThread()) {
            loadingIndicator.setVisibility(View.GONE);
        } else {
            mainHandler.post(new Runnable() {
                @Override
                public void run() {
                    loadingIndicator.setVisibility(View.GONE);
                }
            });
        }
    }

    /**
     * @return the loading indicator view or null if none set
     */
    private static View getLoadingIndicator() {
        return loadingIndicatorRef == null ? null : loadingIndicatorRef.get();
    }
} public interface AppBarHandler {

    /**
     * Transforms the app bar to fit in with the timeline screen.
     *
     * @param view
     *         the fragment's root view
     */
    void setTimelineAppBar(View view);

    /**
     * Transforms the app bar to fit in with the product screen. May include a custom view (i.e. product title
     * and available actions) as well as a backdrop view (i.e. product image) to be scrolled off the screen.
     *
     * @param view
     *         the fragment's root view
     * @param title
     *         the name of the product to display as toolbar title
     * @param customView
     *         the custom view possibly containing the product title and actions
     * @param addCollapsedHeight
     *         the height to collapse to in addition to the standard toolbar size (in px)
     * @param expandedHeight
     *         the height to expand to in total (in px)
     * @param backdrop
     *         the backdrop view to scroll off
     */
    void setProductAppBar(View view, String title, View customView, int addCollapsedHeight, int
            expandedHeight, View backdrop);

    /**
     * Transforms the app bar to fit in with the edit product screen.
     *
     * @param view
     *         the fragment's root view
     * @param title
     *         the name of the product to display as toolbar title
     */
    void setEditProductAppBar(View view, String title);

    /**
     * Transforms the app bar to fit in with the product screen. May include a custom view (i.e. product title
     * and available actions) as well as a backdrop view (i.e. product image) to be scrolled off the screen.
     *
     * @param view
     *         the fragment's root view
     * @param title
     *         the user name to display as toolbar title in collapsed state
     * @param customView
     *         the custom view possibly containing the user's avatar and name
     * @param addCollapsedHeight
     *         the height to collapse to in addition to the standard toolbar size (in px)
     * @param expandedHeight
     *         the height to expand to in total (in px)
     * @param backdrop
     *         the backdrop view to scroll off
     */
    void setProfileAppBar(View view, String title, View customView, int addCollapsedHeight, int
            expandedHeight, View backdrop);

    /**
     * Transforms the app bar to fit in with the user settings screen.
     *
     * @param view
     *         the fragment's root view
     * @param title
     *         the user name to display as toolbar title
     */
    void setProfileEditAppBar(View view, String title);

    /**
     * Transforms the app bar to fit in with the opinion screen.
     *
     * @param view
     *         the fragment's root view
     * @param title
     *         the name of the product to display as toolbar title
     */
    void setOpinionAppBar(View view, String title);

    /**
     * @return true if the status bar is translucent, false else
     */
    boolean hasTranslucentStatusBar();

    /**
     * @return the current height value in px of the toolbar in collapsed state
     */
    int getCurrentCollapsedToolbarHeight();

    /**
     * @return the current height value in px of the toolbar in expanded state
     */
    int getCurrentExpandedToolbarHeight();

    /**
     * @return the current height value in px the content is padded from the top but not clipped by the parent
     * view when scrolling up
     */
    int getCurrentContentPadding();

} public class DataChangeListener {

    private static final int GC_EACH_CNT_CALL = 100;

    private static final Collection<WeakReference<OnProductCreateListener>> onProductCreateListeners = new
            ArrayList<WeakReference<OnProductCreateListener>>();
    private static final Collection<WeakReference<OnProductUpdateListener>> onProductUpdateListeners = new
            ArrayList<WeakReference<OnProductUpdateListener>>();
    private static final Collection<WeakReference<OnOpinionCreateListener>> onOpinionCreateListeners = new
            ArrayList<WeakReference<OnOpinionCreateListener>>();
    private static final Collection<WeakReference<OnOpinionUpdateListener>> onOpinionUpdateListeners = new
            ArrayList<WeakReference<OnOpinionUpdateListener>>();
    private static final Collection<WeakReference<OnImageCreateListener>> onImageCreateListeners = new
            ArrayList<WeakReference<OnImageCreateListener>>();
    private static final Collection<WeakReference<OnImageUpdateListener>> onImageUpdateListeners = new
            ArrayList<WeakReference<OnImageUpdateListener>>();

    private static int cntCallForGc = 0;

    /**
     * Adds a listener to be notified of product creations.
     *
     * Only a weak reference to the listener is kept - maintain a strong reference to avoid garbage collection
     * of the listener.
     *
     * @param onProductCreateListener
     *         the listener to add
     */
    public static void addOnProductCreateListener(OnProductCreateListener onProductCreateListener) {
        checkGc();
        synchronized (onProductCreateListeners) {
            onProductCreateListeners.add(new WeakReference<OnProductCreateListener>(onProductCreateListener));
        }
    }

    /**
     * Removes the specified listener.
     *
     * @param onProductCreateListener
     *         the listener to remove
     */
    public static void removeOnProductCreateListener(OnProductCreateListener onProductCreateListener) {
        synchronized (onProductCreateListeners) {
            removeFromWeakReferences(onProductCreateListeners, onProductCreateListener);
        }
    }

    /**
     * Adds a listener to be notified of product updates.
     *
     * Only a weak reference to the listener is kept - maintain a strong reference to avoid garbage collection
     * of the listener.
     *
     * @param onProductUpdateListener
     *         the listener to add
     */
    public static void addOnProductUpdateListener(OnProductUpdateListener onProductUpdateListener) {
        checkGc();
        synchronized (onProductUpdateListeners) {
            onProductUpdateListeners.add(new WeakReference<OnProductUpdateListener>(onProductUpdateListener));
        }
    }

    /**
     * Removes the specified listener.
     *
     * @param onProductUpdateListener
     *         the listener to remove
     */
    public static void removeOnProductUpdateListener(OnProductUpdateListener onProductUpdateListener) {
        synchronized (onProductUpdateListeners) {
            removeFromWeakReferences(onProductUpdateListeners, onProductUpdateListener);
        }
    }

    /**
     * Adds a listener to be notified of opinion creations.
     *
     * Only a weak reference to the listener is kept - maintain a strong reference to avoid garbage collection
     * of the listener.
     *
     * @param onOpinionCreateListener
     *         the listener to add
     */
    public static void addOnOpinionCreateListener(OnOpinionCreateListener onOpinionCreateListener) {
        checkGc();
        synchronized (onOpinionCreateListeners) {
            onOpinionCreateListeners.add(new WeakReference<OnOpinionCreateListener>(onOpinionCreateListener));
        }
    }

    /**
     * Removes the specified listener.
     *
     * @param onOpinionCreateListener
     *         the listener to remove
     */
    public static void removeOnOpinionCreateListener(OnOpinionCreateListener onOpinionCreateListener) {
        synchronized (onOpinionCreateListeners) {
            removeFromWeakReferences(onOpinionCreateListeners, onOpinionCreateListener);
        }
    }

    /**
     * Adds a listener to be notified of opinion updates.
     *
     * Only a weak reference to the listener is kept - maintain a strong reference to avoid garbage collection
     * of the listener.
     *
     * @param onOpinionUpdateListener
     *         the listener to add
     */
    public static void addOnOpinionUpdateListener(OnOpinionUpdateListener onOpinionUpdateListener) {
        checkGc();
        synchronized (onOpinionUpdateListeners) {
            onOpinionUpdateListeners.add(new WeakReference<OnOpinionUpdateListener>(onOpinionUpdateListener));
        }
    }

    /**
     * Removes the specified listener.
     *
     * @param onOpinionUpdateListener
     *         the listener to remove
     */
    public static void removeOnOpinionUpdateListener(OnOpinionUpdateListener onOpinionUpdateListener) {
        synchronized (onOpinionUpdateListeners) {
            removeFromWeakReferences(onOpinionUpdateListeners, onOpinionUpdateListener);
        }
    }

    /**
     * Adds a listener to be notified of image creations.
     *
     * Only a weak reference to the listener is kept - maintain a strong reference to avoid garbage collection
     * of the listener.
     *
     * @param onImageCreateListener
     *         the listener to add
     */
    public static void addOnImageCreateListener(OnImageCreateListener onImageCreateListener) {
        checkGc();
        synchronized (onImageCreateListeners) {
            onImageCreateListeners.add(new WeakReference<OnImageCreateListener>(onImageCreateListener));
        }
    }

    /**
     * Removes the specified listener.
     *
     * @param onImageCreateListener
     *         the listener to remove
     */
    public static void removeOnImageCreateListener(OnImageCreateListener onImageCreateListener) {
        synchronized (onImageCreateListeners) {
            removeFromWeakReferences(onImageCreateListeners, onImageCreateListener);
        }
    }

    /**
     * Adds a listener to be notified of image updates.
     *
     * Only a weak reference to the listener is kept - maintain a strong reference to avoid garbage collection
     * of the listener.
     *
     * @param onImageUpdateListener
     *         the listener to add
     */
    public static void addOnImageUpdateListener(OnImageUpdateListener onImageUpdateListener) {
        checkGc();
        synchronized (onImageUpdateListeners) {
            onImageUpdateListeners.add(new WeakReference<OnImageUpdateListener>(onImageUpdateListener));
        }
    }

    /**
     * Removes the specified listener.
     *
     * @param onImageUpdateListener
     *         the listener to remove
     */
    public static void removeOnImageUpdateListener(OnImageUpdateListener onImageUpdateListener) {
        synchronized (onImageUpdateListeners) {
            removeFromWeakReferences(onImageUpdateListeners, onImageUpdateListener);
        }
    }

    /**
     * Notifies all registered {@code OnProductCreateListener}s of a new product.
     *
     * @param product
     *         the product that has been created
     */
    public static void productCreate(Product product) {
        synchronized (onProductCreateListeners) {
            for (WeakReference<OnProductCreateListener> listenerRef : onProductCreateListeners) {
                OnProductCreateListener listener = listenerRef.get();
                if (listener != null && !listenerRef.isEnqueued()) {
                    listener.onProductCreate(product);
                }
            }
        }
    }

    /**
     * Notifies all registered {@code OnProductUpdateListener}s of an updated product.
     *
     * @param product
     *         the product that has been updated (in its updated state)
     */
    public static void productUpdate(Product product) {
        synchronized (onProductUpdateListeners) {
            for (WeakReference<OnProductUpdateListener> listenerRef : onProductUpdateListeners) {
                OnProductUpdateListener listener = listenerRef.get();
                if (listener != null && !listenerRef.isEnqueued()) {
                    listener.onProductUpdate(product);
                }
            }
        }
    }

    /**
     * Notifies all registered {@code OnOpinionCreateListener}s of a new opinion.
     *
     * @param opinion
     *         the opinion that has been created
     */
    public static void opinionCreate(Opine opinion) {
        synchronized (onOpinionCreateListeners) {
            for (WeakReference<OnOpinionCreateListener> listenerRef : onOpinionCreateListeners) {
                OnOpinionCreateListener listener = listenerRef.get();
                if (listener != null && !listenerRef.isEnqueued()) {
                    listener.onOpinionCreate(opinion);
                }
            }
        }
    }

    /**
     * Notifies all registered {@code OnOpinionUpdateListener}s of an updated opinion.
     *
     * @param opinion
     *         the opinion that has been updated (in its updated state)
     */
    public static void opinionUpdate(Opine opinion) {
        synchronized (onOpinionUpdateListeners) {
            for (WeakReference<OnOpinionUpdateListener> listenerRef : onOpinionUpdateListeners) {
                OnOpinionUpdateListener listener = listenerRef.get();
                if (listener != null && !listenerRef.isEnqueued()) {
                    listener.onOpinionUpdate(opinion);
                }
            }
        }
    }

    /**
     * Notifies all registered {@code OnImageCreateListener}s of a new image.
     *
     * @param image
     *         the image that has been created
     */
    public static void imageCreate(ProductImage image) {
        synchronized (onImageCreateListeners) {
            for (WeakReference<OnImageCreateListener> listenerRef : onImageCreateListeners) {
                OnImageCreateListener listener = listenerRef.get();
                if (listener != null && !listenerRef.isEnqueued()) {
                    listener.onImageCreate(image);
                }
            }
        }
    }

    /**
     * Notifies all registered {@code OnImageUpdateListener}s of an updated image.
     *
     * @param image
     *         the image that has been updated (in its updated state)
     */
    public static void imageUpdate(ProductImage image) {
        synchronized (onImageUpdateListeners) {
            for (WeakReference<OnImageUpdateListener> listenerRef : onImageUpdateListeners) {
                OnImageUpdateListener listener = listenerRef.get();
                if (listener != null && !listenerRef.isEnqueued()) {
                    listener.onImageUpdate(image);
                }
            }
        }
    }

    /**
     * Removes an object from a list of objects contained in weak references.
     *
     * @param references
     *         the list to remove the object encapsulated in a weak reference from
     * @param object
     *         the object to search the weak references for
     * @param <T>
     *         the type of object
     */
    public static <T> void removeFromWeakReferences(Iterable<WeakReference<T>> references, T object) {
        for (Iterator<WeakReference<T>> iterator = references.iterator(); iterator.hasNext(); ) {
            WeakReference<T> reference = iterator.next();
            if (reference.get() == object) {
                iterator.remove();
            }
        }
    }

    /**
     * Increases the count of calls and, once it reaches {@link #GC_EACH_CNT_CALL}, removes from all lists of
     * listeners those that have been garbage collected or are enqueued.
     */
    private static void checkGc() {
        cntCallForGc++;
        if (cntCallForGc >= GC_EACH_CNT_CALL) {
            cntCallForGc = 0;
            Log.v(DataChangeListener.class.getSimpleName(), "Starting garbage collection of dead listeners");
            synchronized (onProductCreateListeners) {
                gcDeadReferences(onProductCreateListeners);
            }
            synchronized (onProductUpdateListeners) {
                gcDeadReferences(onProductUpdateListeners);
            }
            synchronized (onOpinionCreateListeners) {
                gcDeadReferences(onOpinionCreateListeners);
            }
            synchronized (onOpinionUpdateListeners) {
                gcDeadReferences(onOpinionUpdateListeners);
            }
            synchronized (onImageCreateListeners) {
                gcDeadReferences(onImageCreateListeners);
            }
            synchronized (onImageUpdateListeners) {
                gcDeadReferences(onImageUpdateListeners);
            }
        }
    }

    /**
     * Removes from a list of weak references those that have been garbage collected or are enqueued.
     *
     * @param references
     *         the list of weak references to go through
     */
    private static <T> void gcDeadReferences(Iterable<WeakReference<T>> references) {
        for (Iterator<WeakReference<T>> iterator = references.iterator(); iterator.hasNext(); ) {
            WeakReference reference = iterator.next();
            if (reference.get() == null || reference.isEnqueued()) {
                iterator.remove();
            }
        }
    }

    /**
     * Listens for new products.
     */
    public interface OnProductCreateListener {
        void onProductCreate(Product product);
    }

    /**
     * Listens for updated products.
     */
    public interface OnProductUpdateListener {
        void onProductUpdate(Product product);
    }

    /**
     * Listens for new opinions.
     */
    public interface OnOpinionCreateListener {
        void onOpinionCreate(Opine opinion);
    }

    /**
     * Listens for updated opinions.
     */
    public interface OnOpinionUpdateListener {
        void onOpinionUpdate(Opine opinion);
    }

    /**
     * Listens for new images.
     */
    public interface OnImageCreateListener {
        void onImageCreate(ProductImage image);
    }

    /**
     * Listens for updated images.
     */
    public interface OnImageUpdateListener {
        void onImageUpdate(ProductImage image);
    }
} public interface FloatingActionButtonHandler {

    /**
     * Configures the floating action button by providing a graphic and an action to execute on clicks.
     *
     * @param drawable
     *         the image drawable displayed in the button
     * @param onClickListener
     *         the action to execute on clicks
     */
    void configureFab(int drawable, View.OnClickListener onClickListener);

} public interface HasAppBarHandler {

    /**
     * @return the app bar handler
     */
    AppBarHandler getAppBarHandler();

} public interface HasFloatingActionButtonHandler {

    /**
     * @return the floating action button handler
     */
    FloatingActionButtonHandler getFloatingActionButtonHandler();

} public interface HasNavigationHandler {

    /**
     * @return the navigation handler
     */
    NavigationHandler getNavigationHandler();

} public interface HasPLYAndroidHolder {

    /**
     * @return the PLYAndroid handler
     */
    PLYAndroidHolder getPLYAndroidHolder();

} public interface HasTimelineSettingsHandler {

    /**
     * @return the timeline settings handler
     */
    TimelineSettingsHandler getTimelineSettingsHandler();

} public interface HasUserHandler {

    /**
     * @return the user handler
     */
    UserHandler getUserHandler();

} public interface NavigationHandler {

    /**
     * Shows the dialog to sign in or up.
     *
     * @param queryOnSuccess
     *         a query to run on successful login (this is used for example if a query fails due to
     *         insufficient credentials and needs to be repeated once logged in) or null
     * @param queryOnSuccessCompletion
     *         a completion callback to go hand in hand with {@code queryOnSuccess} or null
     * @param queryError
     *         the error resulting from {@code queryOnSuccess} if it was run before
     */
    void showSignInDialog(PLYAndroid.Query queryOnSuccess, PLYCompletion queryOnSuccessCompletion,
            PLYAndroid.QueryError queryError);

    /**
     * Shows the dialog to sign in using username and password.
     *
     * @param userName
     *         an optional value to prefill the user name field with
     * @param queryOnSuccess
     *         a query to run on successful login (this is used for example if a query fails due to
     *         insufficient credentials and needs to be repeated once logged in) or null
     * @param queryOnSuccessCompletion
     *         a completion callback to go hand in hand with {@code queryOnSuccess} or null
     * @param queryError
     *         the error resulting from {@code queryOnSuccess} if it was run before
     */
    void showSignInExistingUserDialog(CharSequence userName, PLYAndroid.Query queryOnSuccess, PLYCompletion
            queryOnSuccessCompletion, PLYAndroid.QueryError queryError);

    /**
     * Shows the dialog to sign up as a new user.
     *
     * @param emailAddress
     *         the email address to preset (null for none)
     * @param userName
     *         the user name to preset (null for none)
     * @param queryOnSuccess
     *         a query to run on successful signup (this is used for example if a query fails due to
     *         insufficient credentials and needs to be repeated once logged in) or null
     * @param queryOnSuccessCompletion
     *         a completion callback to go hand in hand with {@code queryOnSuccess} or null
     * @param queryError
     *         the error resulting from {@code queryOnSuccess} if it was run before
     */
    void showSignUpDialog(CharSequence emailAddress, CharSequence userName, PLYAndroid.Query
            queryOnSuccess, PLYCompletion queryOnSuccessCompletion, PLYAndroid.QueryError queryError);

    /**
     * Opens the global timeline.
     */
    void openTimeline();

    /**
     * Looks up the product with the specified GTIN in the ProductLayer database in an async fashion, opening
     * the product page on success or prompting the user to enter details of the new product.
     *
     * @param gtin
     *         the GTIN to look up
     */
    void lookUpProduct(String gtin);

    /**
     * Opens a page enabling the user to enter details and take photos of a new product.
     *
     * @param gtin
     *         the GTIN of the new product
     */
    void openProductCreatePage(String gtin);

    /**
     * Opens a page enabling the user to change details of an existing product.
     *
     * @param product
     *         the product to edit
     */
    void openProductEditPage(Product product);

    /**
     * Opens the product page.
     *
     * @param product
     *         the product to show
     */
    void openProductPage(Product product);

    /**
     * Opens the opinion page.
     *
     * @param product
     *         the product to write an opinion about
     * @param parentOpinion
     *         the opinion to reply to or null if not a reply
     */
    void openOpinionPage(Product product, Opine parentOpinion);

    /**
     * Opens the user profile page.
     *
     * @param user
     *         the user to show
     * @param selectedTab
     *         the index of the tab on the profile page to switch to
     */
    void openProfilePage(User user, int selectedTab);

    /**
     * Opens the user settings page.
     *
     * @param user
     *         the user to edit (= the currently logged in user)
     */
    void openProfileEditPage(User user);

    /**
     * Sends a search query and displays the results.
     *
     * @param query
     *         the query to send
     */
    void search(String query);

} public interface PLYAndroidHolder {

    /**
     * @return the REST client of the ProductLayer SDK to perform API calls with
     */
    PLYAndroid getPLYAndroid();

} public interface TimelineSettingsHandler {

    /**
     * Configures the timeline settings view and displays a button to pop them up.
     *
     * @param tag
     *         the tag of the timeline to persistently save settings for
     * @param availableSettings
     *         the settings that are available to select for this timeline
     * @param defaultSettings
     *         the settings that are enabled by default for this timeline if they haven't been adapted yet
     * @param timeline
     *         the timeline to apply the settings on
     */
    void configureTimelineSettings(String tag, TimelineSetting[] availableSettings, int defaultSettings,
            TimelineFragment timeline);

    /**
     * Shows the timeline settings button.
     */
    void showTimelineSettings();

    /**
     * Hides the timeline settings button.
     */
    void hideTimelineSettings();

    /**
     * Loads the currently enabled settings for a timeline from persistent memory.
     *
     * @param tag
     *         the tag of the timeline to load settings for
     * @param defaultSettings
     *         the default settings to load if none have been stored yet
     * @return the currently enabled timeline settings
     */
    int getTimelineSettings(String tag, int defaultSettings);

    /**
     * Settings to include/exclude certain types of timeline entries.
     */
    enum TimelineSetting {
        FRIENDS_ONLY(1), INCLUDE_IMAGES(2);

        public final int value;

        TimelineSetting(int value) {
            this.value = value;
        }
    }

} public interface UserHandler {

    /**
     * @return the current user
     */
    User getUser();

    /**
     * Updates the currently stored user object if its retrieval time is newer than the current one.
     *
     * @param user
     *         the new user object
     * @param userRetrievalTime
     *         the retrieval time of the user object or null for now
     */
    void setUser(User user, Long userRetrievalTime);

    /**
     * Checks whether the specified user is the currently logged in one.
     *
     * @param user
     *         the user to compare with the current one
     * @return true if this user is currently logged in, false else
     */
    boolean isCurrentUser(User user);

    /**
     * @param key
     *         the social provider key
     * @return true if the current user is connected with the specified social network, false else
     */
    boolean isSocialNetworkConnected(String key);

    /**
     * @return the nanotime of the last remote user retrieval
     */
    long getUserRetrievalTime();

    /**
     * @return the current user's friends (followed users)
     */
    ArrayList<User> getFriends();

    /**
     * Sets the currently stored user friends.
     *
     * @param friends
     *         the current user's followed users
     */
    void setFriends(ArrayList<User> friends);

    /**
     * Tests whether the specified user is a friend of the currently logged in user.
     *
     * @param user
     *         the possible friend
     * @return true if the specified user is in the stored friends list (or if the specified user is the
     * current user), false else
     */
    boolean isFriend(User user);

    /**
     * Tests whether the specified user is a friend of the currently logged in user.
     *
     * @param user
     *         the possible friend
     * @return true if the specified user is in the stored friends list (or if the specified user is the
     * current user), false else
     */
    boolean isFriend(SimpleUserInfo user);

    /**
     * Adds a user to the current user's friend list.
     *
     * @param user
     *         the user to add
     */
    void addFriend(User user);

    /**
     * Removes a user from the current user's friend list.
     *
     * @param user
     *         the user to remove
     */
    void removeFriend(User user);

    /**
     * Sets a user's score.
     *
     * @param points
     *         the new score
     */
    void setPoints(long points);

    /**
     * Updates a user's list of unlocked achievements.
     *
     * @param keys
     *         the keys of the unlocked achievements
     */
    void newAchievements(List<String> keys);

    /**
     * @return whether the user handler is currently restoring a saved state (non-blocking)
     */
    boolean isLoadingState();

    /**
     * Adds a listener to be notified of friends list updates.
     *
     * Only a weak reference to the listener is kept - maintain a strong reference to avoid garbage collection
     * of the listener.
     *
     * @param onFriendsUpdateListener
     *         the listener to add
     */
    void addOnFriendsUpdateListener(OnFriendsUpdateListener onFriendsUpdateListener);

    /**
     * Removes the specified listener.
     *
     * @param onFriendsUpdateListener
     *         the listener to remove
     */
    void removeOnFriendsUpdateListener(OnFriendsUpdateListener onFriendsUpdateListener);

    /**
     * Adds a listener to be notified of points updates.
     *
     * Only a weak reference to the listener is kept - maintain a strong reference to avoid garbage collection
     * of the listener.
     *
     * @param onPointsUpdateListener
     *         the listener to add
     */
    void addOnPointsUpdateListener(OnPointsUpdateListener onPointsUpdateListener);

    /**
     * Removes the specified listener.
     *
     * @param onPointsUpdateListener
     *         the listener to remove
     */
    void removeOnPointsUpdateListener(OnPointsUpdateListener onPointsUpdateListener);

    /**
     * Adds a listener to be notified of new unlocked achievements.
     *
     * Only a weak reference to the listener is kept - maintain a strong reference to avoid garbage collection
     * of the listener.
     *
     * @param onAchievementsUnlockedListener
     *         the listener to add
     */
    void addOnAchievementsUnlockedListener(OnAchievementsUnlockedListener onAchievementsUnlockedListener);

    /**
     * Removes the specified listener.
     *
     * @param onAchievementsUnlockedListener
     *         the listener to remove
     */
    void removeOnAchievementsUnlockedListener(OnAchievementsUnlockedListener onAchievementsUnlockedListener);

    /**
     * Listens for updates to the friends list.
     */
    interface OnFriendsUpdateListener {

        /**
         * Called when the user's list of friends has been updated.
         */
        void onFriendsUpdate();

    }

    /**
     * Listens for updates to the user's score.
     */
    interface OnPointsUpdateListener {

        /**
         * Called when the user's score has been updated.
         *
         * @param points
         *         the new score
         */
        void onPointsUpdate(long points);

    }

    /**
     * Listens for new achievements unlocked by the user.
     */
    interface OnAchievementsUnlockedListener {

        /**
         * Called when one or more achievements have been unlocked by the user.
         *
         * @param keys
         *         the keys of the achievements that have been unlocked
         */
        void onAchievementsUnlocked(List<String> keys);

    }

} public class ColorUtil {

    /**
     * Tints a drawable without modifying the source resource.
     *
     * @param drawable
     *         the drawable to tint
     * @param color
     *         the color to use
     */
    public static void mutateAndTintDrawable(Drawable drawable, int color) {
        drawable = DrawableCompat.wrap(drawable);
        drawable.mutate();
        tintDrawable(drawable, color);
    }

    /**
     * Tints a drawable. Also tints the original resource, use {@link #mutateAndTintDrawable} to not touch the
     * source resource.
     *
     * @param drawable
     *         the drawable to tint
     * @param color
     *         the color to use
     */
    public static void tintDrawable(Drawable drawable, int color) {
        DrawableCompat.setTint(drawable, color);
    }

    /**
     * Tints all items in a menu.
     *
     * @param menu
     *         the menu items to tint
     * @param color
     *         the color to use
     * @param excludeGroup
     *         any group ID to exclude from tinting (0 for items without group)
     */
    public static void tintMenuItems(Menu menu, int color, int excludeGroup) {
        int cntMenuItems = menu.size();
        for (int i = 0; i < cntMenuItems; i++) {
            MenuItem menuItem = menu.getItem(i);
            if (menuItem.getGroupId() == excludeGroup) {
                continue;
            }
            Drawable icon = menuItem.getIcon();
            if (icon == null) {
                continue;
            }
            mutateAndTintDrawable(icon, color);
            menuItem.setIcon(icon);
        }
    }

} public class MetricsUtil {

    private static int orientation;

    // parsed directly from DisplayMetrics
    private static float density;
    private static int densityDpi;
    private static int widthPx;
    private static int heightPx;

    // calculated from above
    private static int widthDp;
    private static int heightDp;

    /**
     * Stores all data from DisplayMetrics and translates dimensions into density-independent pixels. Must be
     * called once before getting data.
     *
     * @param activity
     *         the current activity
     */
    public static void update(Activity activity) {
        orientation = activity.getResources().getConfiguration().orientation;
        DisplayMetrics displayMetrics = new DisplayMetrics();
        activity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
        density = displayMetrics.density;
        densityDpi = displayMetrics.densityDpi;
        widthPx = displayMetrics.widthPixels;
        heightPx = displayMetrics.heightPixels;
        updateDp();
        Log.d(MetricsUtil.class.getSimpleName(), "Orientation: " + nameForOrientation(orientation) +
                "\nWidth (px): " + widthPx + ", Height (px): " + heightPx + "\nDPI: " + densityDpi + ", " +
                "Density Factor: " + density + "\nWidth (dp): " + widthDp + ", Height (dp): " + heightDp);
    }

    /**
     * Translates pixels (px) into density-independent pixels (dp) by dividing px by {@link
     * DisplayMetrics#density}.
     *
     * @param px
     *         a value in pixels (px)
     * @return the corresponding value in density-independent pixels (dp)
     */
    public static int inDp(int px) {
        return Math.round(px / density);
    }

    /**
     * Translates density-independent pixels (dp) into pixels (px) by multiplying dp with {@link
     * DisplayMetrics#density}.
     *
     * @param dp
     *         a value in density-independent pixels (dp)
     * @return the corresponsing value in pixels (px)
     */
    public static int inPx(int dp) {
        return Math.round(dp * density);
    }

    private static void updateDp() {
        widthDp = inDp(widthPx);
        heightDp = inDp(heightPx);
    }

    /**
     * @param orientation
     *         the screen orientation retrieved by {@link #getOrientation()}
     * @return the textual representation of the orientation value, either Portrait, Landscape, or Undefined
     */
    public static String nameForOrientation(int orientation) {
        String orientationName = "Undefined";
        if (orientation == Configuration.ORIENTATION_PORTRAIT) {
            orientationName = "Portrait";
        } else if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
            orientationName = "Landscape";
        }
        return orientationName;
    }

    /**
     * @return the screen orientation, one of {@link Configuration#ORIENTATION_PORTRAIT}, {@link
     * Configuration#ORIENTATION_LANDSCAPE}
     */
    public static int getOrientation() {
        return orientation;
    }

    /**
     * @return the screen density
     * @see DisplayMetrics#density
     */
    public static float getDensity() {
        return density;
    }

    /**
     * @return the screen density as dots per inch
     * @see DisplayMetrics#densityDpi
     */
    public static int getDensityDpi() {
        return densityDpi;
    }

    /**
     * @return the width of the screen in pixels
     * @see DisplayMetrics#widthPixels
     */
    public static int getWidthPx() {
        return widthPx;
    }

    /**
     * @return the height of the screen in pixels
     * @see DisplayMetrics#heightPixels
     */
    public static int getHeightPx() {
        return heightPx;
    }

    /**
     * @return the width of the screen in density-independent pixels
     */
    public static int getWidthDp() {
        return widthDp;
    }

    /**
     * @return the height of the screen in density-independent pixels
     */
    public static int getHeightDp() {
        return heightDp;
    }

} public class PhotoUtil {

    private static final int REQUEST_CODE_TAKE_PHOTO = 13014;
    private static final int REQUEST_CODE_PICK_IMAGE = 13015;

    private static final String tempPhotoPath = getPhotoPathShared();

    /**
     * Adds an OnClickListener to the provided view to display a popup menu allowing the user to choose
     * between taking a photo and picking one from the gallery.
     *
     * @param targetActivity
     *         the target activity receiving the camera's/gallery's result (may be null if fragment is set)
     * @param targetFragment
     *         the target fragment receiving the camera's/gallery's result (may be null if activity is set)
     * @param anchor
     *         the view to anchor the popup menu to and to register the OnClickListener with
     */
    public static void registerSelectorPopup(final Activity targetActivity, final Fragment targetFragment,
            View anchor) {
        Context context = targetFragment != null ? targetFragment.getActivity() : targetActivity;
        if (context == null) {
            return;
        }
        final PopupMenu popup = new PopupMenu(context, anchor);
        popup.getMenuInflater().inflate(R.menu.popup_photo_source, popup.getMenu());
        popup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
            @Override
            public boolean onMenuItemClick(MenuItem item) {
                int selectedId = item.getItemId();
                if (selectedId == R.id.popup_take_photo) {
                    takePhoto(targetActivity, targetFragment);
                    return true;
                } else if (selectedId == R.id.popup_pick_from_gallery) {
                    pickGalleryImage(targetActivity, targetFragment);
                    return true;
                }
                return false;
            }
        });
        try {
            // try to display icons
            Field mPopupField = popup.getClass().getDeclaredField("mPopup");
            mPopupField.setAccessible(true);
            MenuPopupHelper mPopup = (MenuPopupHelper) mPopupField.get(popup);
            mPopup.setForceShowIcon(true);
            Integer tintColor = ThemeUtil.getIntegerValue(context, android.R.attr.textColorPrimary);
            if (tintColor != null) {
                ColorUtil.tintMenuItems(popup.getMenu(), tintColor, -1);
            }
        } catch (Exception ignored) {
        }
        anchor.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                popup.show();
            }
        });
    }

    /**
     * Starts the camera app to take a photo.
     *
     * @param targetActivity
     *         the target activity receiving the camera's result (may be null if fragment is set)
     * @param targetFragment
     *         the target fragment receiving the camera's result (may be null if activity is set)
     */
    public static void takePhoto(Activity targetActivity, Fragment targetFragment) {
        Context context = targetFragment != null ? targetFragment.getActivity() : targetActivity;
        if (context == null) {
            return;
        }
        if (CameraUtil.hasCameraAny(context)) {
            Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
            intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(new File(tempPhotoPath)));
            if (targetFragment != null) {
                targetFragment.startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO);
            } else {
                targetActivity.startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO);
            }
        } else {
            if (targetFragment != null) {
                SnackbarUtil.make(targetFragment.getActivity(), targetFragment.getView(), R.string
                        .no_camera_found, Snackbar.LENGTH_SHORT).show();
            }
        }
    }

    /**
     * Opens the gallery for the user to pick an image.
     *
     * @param targetActivity
     *         the target activity receiving the gallery's result (may be null if fragment is set)
     * @param targetFragment
     *         the target fragment receiving the gallery's result (may be null if activity is set)
     */
    public static void pickGalleryImage(Activity targetActivity, Fragment targetFragment) {
        Intent intent = new Intent(Intent.ACTION_GET_CONTENT, null);
        intent.setType("image/*");
        if (targetFragment != null) {
            targetFragment.startActivityForResult(intent, REQUEST_CODE_PICK_IMAGE);
        } else if (targetActivity != null) {
            targetActivity.startActivityForResult(intent, REQUEST_CODE_PICK_IMAGE);
        }
    }

    /**
     * Convenience method to call from an activity's or a fragment's {@code onActivityResult} method to check
     * if a photo was taken by the camera app or if the user picked an image from the gallery. If so, moves
     * the file to the specified path.
     *
     * @param requestCode
     *         the request code passed to the method
     * @param resultCode
     *         the result code passed to the method
     * @param data
     *         any attached result data
     * @param context
     *         the application context
     * @param moveToPath
     *         the file path to move any taken/selected photo to
     * @return true if a photo was taken, false else
     */
    public static boolean onActivityResult(int requestCode, int resultCode, Intent data, Context context,
            String moveToPath) {
        if (requestCode == REQUEST_CODE_TAKE_PHOTO && resultCode != AppCompatActivity.RESULT_CANCELED) {
            // photo taken
            Log.d(PhotoUtil.class.getSimpleName(), "New photo at " + tempPhotoPath);
            StorageUtil.copyFile(new File(tempPhotoPath), new File(moveToPath), true);
            // TODO bug: some devices save to gallery too -> find and delete
            return true;
        } else if (requestCode == REQUEST_CODE_PICK_IMAGE && resultCode != AppCompatActivity
                .RESULT_CANCELED) {
            // gallery image selected
            if (context == null) {
                return false;
            }
            Uri imageUri = data.getData();
            Log.d(PhotoUtil.class.getSimpleName(), "Selected gallery image at " + imageUri);
            InputStream is;
            try {
                is = context.getContentResolver().openInputStream(imageUri);
            } catch (FileNotFoundException e) {
                Log.w(PhotoUtil.class.getSimpleName(), e);
                return false;
            }
            StorageUtil.copyFile(is, new File(moveToPath));
            return true;
        }
        // TODO copying should be done in a background thread
        // TODO clear cache dir periodically
        return false;
    }

    /**
     * @param id
     *         the object ID
     * @param activity
     *         the current activity
     * @return the file path in internal cache any taken photos of products are moved to
     */
    public static String getPhotoPathCache(String id, Activity activity) {
        File photoFileCache = new File(activity.getCacheDir(), id + ".jpg");
        return photoFileCache.getAbsolutePath();
    }

    /**
     * @return the file path to have the external camera app save photos at
     */
    private static String getPhotoPathShared() {
        File photoFileShared = new File(StorageUtil.getExternalDir(), "prodly.jpg");
        return photoFileShared.getAbsolutePath();
    }

} public class SnackbarUtil {

    /**
     * Creates a snackbar that can handle a translucent navigation bar instead of popping up under it.
     *
     * Expects a CoordinatorLayout as the provided view or as a parent at some point in the view hierarchy.
     *
     * @param activity
     *         the currently visible activity
     * @param view
     *         the container view
     * @param text
     *         the message to show
     * @param duration
     *         the duration to show it for (see {@link Snackbar#LENGTH_SHORT} and {@link
     *         Snackbar#LENGTH_LONG}
     * @return the snackbar prepared to be shown
     */
    public static Snackbar make(Activity activity, final View view, CharSequence text, int duration) {
        Snackbar snackbar = Snackbar.make(view, text, duration);
        if (activity == null) {
            return snackbar;
        }
        final int navigationBarHeight = SystemBarsUtil.getNavigationBarHeight(activity);
        if (navigationBarHeight == 0 || !SystemBarsUtil.hasTranslucentNavigationBar(activity)) {
            return snackbar;
        }
        snackbar.setCallback(new Snackbar.Callback() {
            @Override
            public void onShown(Snackbar snackbar) {
                View snackbarView = snackbar.getView();
                CoordinatorLayout.LayoutParams layoutParams = new CoordinatorLayout.LayoutParams
                        (snackbarView.getLayoutParams());
                layoutParams.setMargins(0, 0, 0, navigationBarHeight);
                layoutParams.gravity = Gravity.BOTTOM;
                snackbarView.setLayoutParams(layoutParams);
                super.onShown(snackbar);
            }
        });
        return snackbar;
    }

    /**
     * Creates a snackbar that can handle a translucent navigation bar instead of popping up under it.
     *
     * Expects a CoordinatorLayout as the provided view or as a parent at some point in the view hierarchy.
     *
     * @param activity
     *         the currently visible activity
     * @param view
     *         the container view
     * @param resourceId
     *         the message to show
     * @param duration
     *         the duration to show it for (see {@link Snackbar#LENGTH_SHORT} and {@link
     *         Snackbar#LENGTH_LONG}
     * @return the snackbar prepared to be shown
     */
    public static Snackbar make(Activity activity, View view, int resourceId, int duration) {
        return make(activity, view, activity.getResources().getString(resourceId), duration);
    }
} public class SystemBarsUtil {

    /**
     * @param context
     *         the application context
     * @return the height of the status bar in px
     */
    public static int getStatusBarHeight(Context context) {
        // 25 dp is the default as per Google specs
        int defaultDp = 25;
        if (context == null) {
            return MetricsUtil.inPx(defaultDp);
        }
        Resources resources = context.getResources();
        int resourceId = resources.getIdentifier("status_bar_height", "dimen", "android");
        if (resourceId > 0) {
            return resources.getDimensionPixelSize(resourceId);
        } else {
            return MetricsUtil.inPx(defaultDp);
        }
    }

    /**
     * @return whether this device has support for a natively enabled translucent status bar (Android 4.4+)
     */
    public static boolean supportsTranslucentStatusBarNative() {
        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
    }

    /**
     * @param context
     *         the application context
     * @return true if this device has native support for a translucent status bar or enabled through a
     * proprietary launcher flag, false if neither
     */
    public static boolean supportsTranslucentStatusBar(Context context) {
        return supportsTranslucentStatusBarNative() || getProprietaryTranslucentStatusBarFlag(context) != 0;
    }

    /**
     * Attempts to make the status bar translucent on devices with proprietary launchers supporting this
     * feature.
     *
     * @param activity
     *         the activity to set the flag for
     * @return true on success, false else
     */
    public static boolean setProprietaryTranslucentStatusBar(Activity activity) {
        if (activity == null) {
            return false;
        }
        Window window = activity.getWindow();
        if (window == null) {
            return false;
        }
        View decor = window.getDecorView();
        if (decor == null) {
            return false;
        }
        int flag = getProprietaryTranslucentStatusBarFlag(activity);
        if (flag == 0) {
            return false;
        }
        decor.setSystemUiVisibility(flag);
        return true;
    }

    /**
     * Retrieves the status bar transparency flags of proprietary launchers on Samsung and Sony devices. Adds
     * support for a translucent status bar where it is not supported directly by Android - as in Android
     * versions less than 4.4.
     *
     * @param context
     *         the application context
     * @return the flag for a translucent status bar to set via {@link View#setSystemUiVisibility}.
     */
    public static int getProprietaryTranslucentStatusBarFlag(Context context) {
        if (context == null) {
            return 0;
        }
        int flag = 0;
        String[] libs = context.getPackageManager().getSystemSharedLibraryNames();
        if (libs == null) {
            return flag;
        }
        Collection<String> libsSet = new HashSet<String>(Arrays.asList(libs));
        String flagName;
        if (libsSet.contains("touchwiz")) {
            flagName = "SYSTEM_UI_FLAG_TRANSPARENT_BACKGROUND";
        } else if (libsSet.contains("com.sonyericsson.navigationbar")) {
            flagName = "SYSTEM_UI_FLAG_TRANSPARENT";
        } else {
            return flag;
        }
        try {
            Field field = View.class.getField(flagName);
            if (field.getType() == Integer.TYPE) {
                flag = field.getInt(null);
            }
        } catch (Exception ignored) {
        }
        return flag;
    }

    /**
     * Gets the height of the navigation bar in pixels if one is present at the bottom of the screen.
     *
     * @param context
     *         the application context
     * @return the height of the navigation bar in px or 0 if none is present at the bottom of the screen
     */
    public static int getNavigationBarHeight(Context context) {
        if (context == null) {
            return 0;
        }
        WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        Display display = windowManager.getDefaultDisplay();
        Point size = new Point();
        display.getSize(size);
        Point fullSize = new Point();
        if (Build.VERSION.SDK_INT >= 17) {
            display.getRealSize(fullSize);
        } else if (Build.VERSION.SDK_INT >= 14) {
            try {
                fullSize.x = (Integer) Display.class.getMethod("getRawWidth").invoke(display);
                fullSize.y = (Integer) Display.class.getMethod("getRawHeight").invoke(display);
            } catch (Exception ignored) {
            }
        }
        if (size.y < fullSize.y) {
            // navigation bar at the bottom
            return fullSize.y - size.y;
        } else {
            // no navigation bar at the bottom (none at all or one on the right side)
            return 0;
        }
    }

    /**
     * Checks whether the current theme declares the navigation bar to be translucent. This does not check
     * whether a navigation bar is even displayed on the screen.
     *
     * @param context
     *         the application context
     * @return true if the navigation bar is translucent in the current theme, false else
     * @see #getNavigationBarHeight
     */
    public static boolean hasTranslucentNavigationBar(Context context) {
        @SuppressLint("InlinedApi") @SuppressWarnings("UnnecessaryFullyQualifiedName") Boolean
                translucentNav = ThemeUtil.getBooleanValue(context, android.R.attr
                .windowTranslucentNavigation);
        return translucentNav != null && translucentNav;
    }

} public class ThemeUtil {

    /**
     * Retrieves the integer value of the specified attribute of the current theme.
     *
     * @param context
     *         the application context
     * @param attributeId
     *         the ID of the attribute to find
     * @return the integer value of the specified attribute if found, null else
     */
    public static Integer getIntegerValue(Context context, int attributeId) {
        TypedValue value = new TypedValue();
        if (context != null && context.getTheme().resolveAttribute(attributeId, value, true)) {
            return value.data;
        } else {
            return null;
        }
    }

    /**
     * Retrieves the boolean value of the specified attribute of the current theme.
     *
     * @param context
     *         the application context
     * @param attributeId
     *         the ID of the attribute to find
     * @return the boolean value of the specified attribute if found, null else
     */
    public static Boolean getBooleanValue(Context context, int attributeId) {
        TypedValue value = new TypedValue();
        if (context != null && context.getTheme().resolveAttribute(attributeId, value, true)) {
            return value.coerceToString().equals("true");
        } else {
            return null;
        }
    }

    /**
     * Retrieves the px value of the specified attribute of the current theme.
     *
     * @param activity
     *         the current activity
     * @param attributeId
     *         the ID of the attribute to find
     * @return the px value of the specified attribute if found, null else
     */
    public static Integer getPxValue(Activity activity, int attributeId) {
        TypedValue value = new TypedValue();
        if (activity != null && activity.getTheme().resolveAttribute(attributeId, value, true)) {
            DisplayMetrics displayMetrics = new DisplayMetrics();
            activity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
            return Math.round(value.getDimension(displayMetrics));
        } else {
            return null;
        }
    }

} public class LikeView extends RelativeLayout {

    private static final int DEFAULT_COLOR_ID = R.color.white;
    private static final int DEFAULT_COLOR_ACTIVE_ID = R.color.ply_accent;

    private View likeContainer;
    private View dislikeContainer;

    private ImageView likeImage;
    private ImageView dislikeImage;

    private TextView likeAmountText;
    private TextView dislikeAmountText;

    private TextView likeFriendsText;
    private TextView dislikeFriendsText;

    private Type type;
    private String objectId;

    private Status status = Status.UNDECIDED;
    private int likes;
    private int dislikes;

    private int color;
    private int colorActive;

    private PLYAndroid client;

    public LikeView(Context context, Type type, String objectId) {
        super(context);
        this.type = type;
        this.objectId = objectId;
        //noinspection deprecation
        color = context.getResources().getColor(DEFAULT_COLOR_ID);
        //noinspection deprecation
        colorActive = context.getResources().getColor(DEFAULT_COLOR_ACTIVE_ID);
        setup(context);
    }

    public LikeView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public LikeView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        // use custom attributes
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LikeView);
        int typeInt = a.getInteger(R.styleable.LikeView_likeType, -1);
        objectId = a.getString(R.styleable.LikeView_objectId);
        int iconSize = a.getInteger(R.styleable.LikeView_iconSize, 0);
        //noinspection deprecation
        color = a.getColor(R.styleable.LikeView_iconColor, context.getResources().getColor(DEFAULT_COLOR_ID));
        //noinspection deprecation
        colorActive = a.getColor(R.styleable.LikeView_iconColorActive, context.getResources().getColor
                (DEFAULT_COLOR_ACTIVE_ID));
        a.recycle();
        type = Type.fromValue(typeInt);
        if (type == null) {
            throw new RuntimeException("Missing or invalid likeType attribute in LikeView");
        }
        setup(context);
        if (iconSize == 1) {
            setSizeMini();
        }
    }

    /**
     * Builds a string of user nicknames connected by {@code glue}.
     *
     * @param users
     *         the users to connect in a string
     * @param glue
     *         the glue to connect the users with
     * @return a new string containing all users connected by {@code glue}
     */
    private static String implodeUsers(List<SimpleUserInfo> users, String glue) {
        if (users == null || users.isEmpty()) {
            return "";
        }
        StringBuilder sb = new StringBuilder();
        sb.append(users.get(0).getNickname());
        int cntUsers = users.size();
        if (cntUsers > 1) {
            for (int i = 1; i < cntUsers; i++) {
                sb.append(glue).append(users.get(i).getNickname());
            }
        }
        return sb.toString();
    }

    /**
     * Sets up the layout and click listeners.
     *
     * @param context
     *         the application context
     */
    private void setup(Context context) {
        inflateLayout(context);
        setupListeners();
    }

    /**
     * Inflates the layout and sets the references to the UI elements.
     *
     * @param context
     *         the application context
     */
    private void inflateLayout(Context context) {
        // inflate layout
        LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        inflater.inflate(R.layout.compound_like, this, true);
        // get fields
        likeContainer = findViewById(R.id.like_container);
        dislikeContainer = findViewById(R.id.dislike_container);
        likeImage = (ImageView) findViewById(R.id.like_image);
        likeAmountText = (TextView) findViewById(R.id.like_amount_text);
        dislikeImage = (ImageView) findViewById(R.id.dislike_image);
        dislikeAmountText = (TextView) findViewById(R.id.dislike_amount_text);
        likeFriendsText = (TextView) findViewById(R.id.like_friends_text);
        dislikeFriendsText = (TextView) findViewById(R.id.dislike_friends_text);
    }

    /**
     * Sets up the click listeners on the images to enable user voting.
     */
    private void setupListeners() {
        // like button
        likeContainer.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                if (status == Status.LIKED) {
                    return;
                }
                if (client == null) {
                    return;
                }
                final int prevLikes = likes;
                final int prevDislikes = dislikes;
                final Status prevStatus = status;
                if (status == Status.DISLIKED) {
                    setLikes(likes + 1, dislikes - 1);
                } else {
                    setLikes(likes + 1, dislikes);
                }
                setStatus(Status.LIKED);
                switch (type) {
                    case PRODUCT:
                        ProductService.upVoteProduct(client, objectId, new PLYCompletion<Product>() {
                            @Override
                            public void onSuccess(Product result) {
                                DataChangeListener.productUpdate(result);
                            }

                            @Override
                            public void onError(PLYAndroid.QueryError error) {
                            }

                            @Override
                            public void onPostError(PLYAndroid.QueryError error) {
                                setLikes(prevLikes, prevDislikes);
                                setStatus(prevStatus);
                            }
                        });
                        break;
                    case OPINE:
                        OpineService.upVoteOpine(client, objectId, new PLYCompletion<Opine>() {
                            @Override
                            public void onSuccess(Opine result) {
                                DataChangeListener.opinionUpdate(result);
                            }

                            @Override
                            public void onError(PLYAndroid.QueryError error) {
                            }

                            @Override
                            public void onPostError(PLYAndroid.QueryError error) {
                                setLikes(prevLikes, prevDislikes);
                                setStatus(prevStatus);
                            }
                        });
                        break;
                    case IMAGE:
                        ImageService.upVoteProductImage(client, objectId, new PLYCompletion<ProductImage>() {
                            @Override
                            public void onSuccess(ProductImage result) {
                                DataChangeListener.imageUpdate(result);
                            }

                            @Override
                            public void onError(PLYAndroid.QueryError error) {
                            }

                            @Override
                            public void onPostError(PLYAndroid.QueryError error) {
                                setLikes(prevLikes, prevDislikes);
                                setStatus(prevStatus);
                            }
                        });
                        break;
                    default:
                        break;
                }
            }
        });
        // dislike button
        dislikeContainer.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                if (status == Status.DISLIKED) {
                    return;
                }
                if (client == null) {
                    return;
                }
                final int prevLikes = likes;
                final int prevDislikes = dislikes;
                final Status prevStatus = status;
                if (status == Status.LIKED) {
                    setLikes(likes - 1, dislikes + 1);
                } else {
                    setLikes(likes, dislikes + 1);
                }
                setStatus(Status.DISLIKED);
                switch (type) {
                    case PRODUCT:
                        ProductService.downVoteProduct(client, objectId, new PLYCompletion<Product>() {
                            @Override
                            public void onSuccess(Product result) {
                                DataChangeListener.productUpdate(result);
                            }

                            @Override
                            public void onError(PLYAndroid.QueryError error) {
                            }

                            @Override
                            public void onPostError(PLYAndroid.QueryError error) {
                                setLikes(prevLikes, prevDislikes);
                                setStatus(prevStatus);
                            }
                        });
                        break;
                    case OPINE:
                        OpineService.downVoteOpine(client, objectId, new PLYCompletion<Opine>() {
                            @Override
                            public void onSuccess(Opine result) {
                                DataChangeListener.opinionUpdate(result);
                            }

                            @Override
                            public void onError(PLYAndroid.QueryError error) {
                            }

                            @Override
                            public void onPostError(PLYAndroid.QueryError error) {
                                setLikes(prevLikes, prevDislikes);
                                setStatus(prevStatus);
                            }
                        });
                        break;
                    case IMAGE:
                        ImageService.downVoteProductImage(client, objectId, new PLYCompletion<ProductImage>
                                () {
                            @Override
                            public void onSuccess(ProductImage result) {
                                DataChangeListener.imageUpdate(result);
                            }

                            @Override
                            public void onError(PLYAndroid.QueryError error) {
                            }

                            @Override
                            public void onPostError(PLYAndroid.QueryError error) {
                                setLikes(prevLikes, prevDislikes);
                                setStatus(prevStatus);
                            }
                        });
                        break;
                    default:
                        break;
                }
            }
        });
    }

    /**
     * Sets the ID of the type to vote on.
     *
     * @param objectId
     *         the ID
     */
    public void setObjectId(String objectId) {
        this.objectId = objectId;
    }

    /**
     * Sets the amount of likes and dislikes present for the object.
     *
     * @param likes
     *         the amount of likes
     * @param dislikes
     *         the amount of dislikes
     */
    public void setLikes(int likes, int dislikes) {
        this.likes = likes;
        this.dislikes = dislikes;
        likeAmountText.setText(String.valueOf(likes));
        dislikeAmountText.setText(String.valueOf(dislikes));
    }

    /**
     * Sets the current vote status and updated the visuals.
     *
     * @param status
     *         whether the user has liked or disliked or is still undecided
     */
    public void setStatus(Status status) {
        this.status = status;
        int likeColor = status == Status.LIKED ? colorActive : color;
        int dislikeColor = status == Status.DISLIKED ? colorActive : color;
        Drawable likeDrawable = likeImage.getDrawable();
        // invalidate is required for Android 4
        likeImage.invalidateDrawable(likeDrawable);
        ColorUtil.mutateAndTintDrawable(likeDrawable, likeColor);
        likeAmountText.setTextColor(likeColor);
        Drawable dislikeDrawable = dislikeImage.getDrawable();
        dislikeImage.invalidateDrawable(dislikeDrawable);
        ColorUtil.mutateAndTintDrawable(dislikeDrawable, dislikeColor);
        dislikeAmountText.setTextColor(dislikeColor);
    }

    /**
     * Sets the amount of likes and dislikes as well as the voting status of the specified user.
     *
     * @param upVoters
     *         the users liking the object
     * @param downVoters
     *         the users disliking the object
     * @param userId
     *         the ID of the user to check for in {@code upVoters} and {@code downVoters} to set the voting
     *         status
     * @param friends
     *         the friends to check for in {@code upVoters} and {@code downVoters} to display as having voted
     *         that way
     * @param widthPx
     *         the total available width in px to determine if and where to break up text about friends liking
     *         or disliking the object
     */
    public void setFromVoters(Collection<SimpleUserInfo> upVoters, Collection<SimpleUserInfo> downVoters,
            String userId, Iterable<User> friends, int widthPx) {
        // set amount of likes/dislikes
        int likes = upVoters == null ? 0 : upVoters.size();
        int dislikes = downVoters == null ? 0 : downVoters.size();
        setLikes(likes, dislikes);
        // set voting status
        if (userId == null) {
            setStatus(Status.UNDECIDED);
            likeFriendsText.setVisibility(GONE);
            dislikeFriendsText.setVisibility(GONE);
            return;
        }
        Status status = Status.UNDECIDED;
        if (upVoters != null) {
            for (SimpleUserInfo u : upVoters) {
                if (userId.equals(u.getId())) {
                    status = Status.LIKED;
                    break;
                }
            }
        }
        if (status == Status.UNDECIDED && downVoters != null) {
            for (SimpleUserInfo u : downVoters) {
                if (userId.equals(u.getId())) {
                    status = Status.DISLIKED;
                    break;
                }
            }
        }
        setStatus(status);
        // collect friends that have voted
        List<SimpleUserInfo> upVotingFriends = new ArrayList<SimpleUserInfo>();
        List<SimpleUserInfo> downVotingFriends = new ArrayList<SimpleUserInfo>();
        if (friends != null) {
            for (User friend : friends) {
                if (upVoters != null) {
                    for (SimpleUserInfo u : upVoters) {
                        if (friend.getId().equals(u.getId())) {
                            upVotingFriends.add(u);
                        }
                    }
                }
                if (downVoters != null) {
                    for (SimpleUserInfo u : downVoters) {
                        if (friend.getId().equals(u.getId())) {
                            downVotingFriends.add(u);
                        }
                    }
                }
            }
        }
        Resources resources = getResources();
        int availWidthPx;
        int dislikeMarginLeft;
        if (!upVotingFriends.isEmpty() && !downVotingFriends.isEmpty()) {
            availWidthPx = widthPx / 2 - likeFriendsText.getPaddingLeft() - likeFriendsText.getPaddingRight();
            dislikeMarginLeft = widthPx / 2;
        } else {
            availWidthPx = widthPx - likeFriendsText.getPaddingLeft() - dislikeFriendsText.getPaddingRight();
            dislikeMarginLeft = 0;
        }
        // display friends that have upvoted the object
        if (upVotingFriends.isEmpty()) {
            likeFriendsText.setVisibility(GONE);
        } else {
            String verbText = " " + resources.getQuantityString(R.plurals.likesThis, upVotingFriends.size()
            ) + ".";
            TextPaint likeFriendsTextPaint = likeFriendsText.getPaint();
            Rect verbBounds = new Rect();
            likeFriendsTextPaint.getTextBounds(verbText, 0, verbText.length(), verbBounds);
            int peopleWidthPx = availWidthPx - verbBounds.width();
            String peopleCommaSep = implodeUsers(upVotingFriends, ", ");
            CharSequence people = TextUtils.commaEllipsize(peopleCommaSep, likeFriendsTextPaint,
                    peopleWidthPx, resources.getQuantityString(R.plurals.morePeople, 1), resources
                            .getQuantityString(R.plurals.morePeople, 2));
            if (people.length() == 0) {
                // for some reason TextUtils.commaEllipsize returns an empty string at times
                people = TextUtils.ellipsize(peopleCommaSep, likeFriendsTextPaint, peopleWidthPx, TextUtils
                        .TruncateAt.END);
            }
            String fullText = people + verbText;
            likeFriendsText.setText(fullText);
            likeFriendsText.setVisibility(VISIBLE);
        }
        // display friends that have downvoted the object
        if (downVotingFriends.isEmpty()) {
            dislikeFriendsText.setVisibility(GONE);
        } else {
            String verbText = " " + resources.getQuantityString(R.plurals.likesThisNot, downVotingFriends
                    .size()) + ".";
            TextPaint dislikeFriendsTextPaint = dislikeFriendsText.getPaint();
            Rect verbBounds = new Rect();
            dislikeFriendsTextPaint.getTextBounds(verbText, 0, verbText.length(), verbBounds);
            int peopleWidthPx = availWidthPx - verbBounds.width();
            String peopleCommaSep = implodeUsers(downVotingFriends, ", ");
            CharSequence people = TextUtils.commaEllipsize(peopleCommaSep, dislikeFriendsTextPaint,
                    peopleWidthPx, resources.getQuantityString(R.plurals.morePeople, 1), resources
                            .getQuantityString(R.plurals.morePeople, 2));
            if (people.length() == 0) {
                // for some reason TextUtils.commaEllipsize returns an empty string at times
                people = TextUtils.ellipsize(peopleCommaSep, dislikeFriendsTextPaint, peopleWidthPx,
                        TextUtils.TruncateAt.END);
            }
            String fullText = people + verbText;
            dislikeFriendsText.setText(fullText);
            RelativeLayout.LayoutParams lpForDislikeFriendsText = (LayoutParams) dislikeFriendsText
                    .getLayoutParams();
            lpForDislikeFriendsText.leftMargin = dislikeMarginLeft;
            dislikeFriendsText.setVisibility(VISIBLE);
        }
    }

    /**
     * Sets the PLYAndroid client to send like or dislike requests.
     *
     * @param client
     *         the PLYAndroid client
     */
    public void setClient(PLYAndroid client) {
        this.client = client;
    }

    /**
     * Reduces the height of the view to mini sized icons for the timeline.
     */
    public void setSizeMini() {
        int sizePx = getResources().getDimensionPixelSize(R.dimen.feed_interaction_icon_mini);
        LayoutParams lpForLikeImage = (LayoutParams) likeImage.getLayoutParams();
        lpForLikeImage.width = sizePx;
        lpForLikeImage.height = sizePx;
        LayoutParams lpForDislikeImage = (LayoutParams) dislikeImage.getLayoutParams();
        lpForDislikeImage.width = sizePx;
        lpForDislikeImage.height = sizePx;
    }

    /**
     * Enum for the type of view the like is attached to.
     */
    public enum Type {
        PRODUCT(0), OPINE(1), IMAGE(2);

        public int value;

        Type(int value) {
            this.value = value;
        }

        static Type fromValue(int value) {
            for (Type t : Type.values()) {
                if (t.value == value) {
                    return t;
                }
            }
            return null;
        }
    }

    /**
     * Enum for the voting status of the current user.
     */
    public enum Status {
        UNDECIDED, LIKED, DISLIKED
    }
} public class PLYAndroid {

    private static final String PREFS_NAME = "PLYAndroidPrefs";
    private static final String STATE_API_SCHEMA = "stateApiSchema";
    private static final String STATE_API_HOST = "stateApiHost";
    private static final String STATE_API_PORT = "stateApiPort";
    private static final String STATE_API_VERSION = "stateApiVersion";
    private static final String STATE_API_KEY = "stateApiKey";
    private static final String STATE_PROXY_ENABLED = "stateProxyEnabled";
    private static final String STATE_PROXY_HOST = "stateProxyHost";
    private static final String STATE_PROXY_PORT = "stateProxyPort";
    private static final String STATE_CONFIG = "stateConfig";
    private static final String STATE_USERNAME = "stateUsername";
    private static final String STATE_SESSION = "stateSession";
    private static final String STATE_TOKEN = "stateToken";
    private static final String STATE_USER_AGENT = "stateUserAgent";
    private static final String STATE_PREFERRED_LANGUAGE = "statePreferredLanguage";
    private static final String STATE_ADDITIONAL_LANGUAGES = "stateAdditionalLanguages";
    private static final String STATE_ADDITIONAL_HEADERS = "stateAdditionalHeaders";

    private final ExecutorService threadPool;

    private PLYRestClient client;
    private PLYQueryListener queryListener;
    private PLYUserProgressListener userProgressListener;
    private String language;

    private Handler mainHandler = new Handler(Looper.getMainLooper());

    private volatile Thread stateLoader;
    private volatile boolean stateLoaded;

    /**
     * Initializes a thread pool and the PLYRestClient to handle communication.
     *
     * {@code UserService.login} must be used to log in before using services requiring authentication.
     *
     * @param config
     *         the server connection, API version, any proxy information
     */
    public PLYAndroid(PLYRestClientConfig config) {
        stateLoaded = true;
        threadPool = Executors.newCachedThreadPool();
        initRestClient(config);
    }

    /**
     * Initializes a thread pool and uses the specified PLYRestClient to handle communication.
     *
     * @param client
     *         the REST client
     * @param orderedExecution
     *         true to activate just a single thread to handle service calls in order, false to initialize a
     *         thread pool handling several calls at the same time in an unspecified order
     */
    private PLYAndroid(PLYRestClient client, boolean orderedExecution) {
        stateLoaded = true;
        threadPool = orderedExecution ? Executors.newSingleThreadExecutor() : Executors.newCachedThreadPool();
        this.client = client;
    }

    /**
     * Initializes the REST client using an OkHttp RestTemplate and the provided configuration.
     *
     * @param config
     *         the server connection, API version, any proxy information
     */
    private void initRestClient(PLYRestClientConfig config) {
        client = new PLYRestClient(config);
        client.setRestTemplate(getOkHttpRestTemplate(config));
    }

    /**
     * Builds an HTTP client for Spring using OkHttp.
     *
     * @param config
     *         any proxy configuration
     * @return an OkHttp HTTP client for Spring
     */
    private RestTemplate getOkHttpRestTemplate(PLYRestClientConfig config) {
        OkHttpClient okHttpClient = new OkHttpClient();
        if (config.proxyEnabled) {
            Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(config.proxyHost, config
                    .proxyPort));
            okHttpClient.setProxy(proxy);
        }
        ClientHttpRequestFactory requestFactory = new OkHttpClientHttpRequestFactory(okHttpClient);
        RestTemplate restTemplate = new RestTemplate(requestFactory);
        PLYRestClient.enableConverters(restTemplate);
        return restTemplate;
    }

    /**
     * Creates a PLYAndroid instance executing any service calls strictly in the order they are called with
     * only one being active at a time. Shares the REST client and any headers with the original PLYAndroid
     * client - this is not a deep copy.
     *
     * @return a copied PLYAndroid instance with a new thread pool that executes service calls in order
     */
    public PLYAndroid copyForOrderedThreadExecution() {
        waitForStateLoaded();
        PLYAndroid sequentialClient = new PLYAndroid(client, true);
        sequentialClient.setQueryListener(queryListener);
        sequentialClient.setUserProgressListener(userProgressListener);
        sequentialClient.setLanguage(language);
        return sequentialClient;
    }

    /**
     * Creates a new {@link PLYTask} consisting of a REST client query and a completion object and submits it
     * to the thread pool.
     *
     * @param query
     *         the REST client query to execute
     * @param completion
     *         tasks to do on success and on error
     * @param <T>
     *         the type returned by the query
     * @return a Future object to optionally wait for the results or to cancel the query
     */
    public <T> Future<T> submit(Query<T> query, PLYCompletion<T> completion) {
        return threadPool.submit(new PLYTask<>(query, completion));
    }

    /**
     * @return the PLYRestClient handling synchronous communication with the server
     */
    public PLYRestClient getRestClient() {
        waitForStateLoaded();
        return client;
    }

    /**
     * Sets a callback for queries that complete with an error due to failed authentication.
     *
     * @param queryListener
     *         the query listener to install
     */
    public void setQueryListener(PLYQueryListener queryListener) {
        this.queryListener = queryListener;
    }

    /**
     * Sets a callback for queries that result in user progress (i.e. earned points, achievements).
     *
     * @param userProgressListener
     *         the user progress listener to install
     */
    public void setUserProgressListener(PLYUserProgressListener userProgressListener) {
        this.userProgressListener = userProgressListener;
    }

    /**
     * @return the preferred language in ISO 639-1 format
     */
    public String getLanguage() {
        waitForStateLoaded();
        return language;
    }

    /**
     * Sets the preferred language (sent in Accept-Language header).
     *
     * @param language
     *         the preferred language in ISO 639-1 format
     */
    public void setLanguage(String language) {
        this.language = language;
        client.setPreferredLanguage(language);
    }

    /**
     * Clears any headers the REST client is sending with requests for authorization purposes (client-side
     * logout).
     *
     * @see com.productlayer.android.sdk.services.UserService#logout
     * @see com.productlayer.android.sdk.services.UserService#login
     */
    public void resetAuth() {
        waitForStateLoaded();
        client.setUsername(null);
        client.setPassword(null);
        client.setSession(null);
        client.setToken(null);
    }

    /**
     * @return the currently stored authorization token without any "key=" prefix (or null if none is set), to
     * be used with {@link com.productlayer.android.sdk.services.UserService#login(PLYAndroid, String,
     * Boolean, PLYCompletion)}
     */
    public String getAuthToken() {
        waitForStateLoaded();
        String tokenHeader = client.getToken();
        if (tokenHeader == null) {
            return null;
        }
        return tokenHeader.replace(PLYRestClient.COOKIE_AUTH_TOKEN + "=", "");
    }

    /**
     * @return whether an authorization token is sent with each request (does not guarantee a successful
     * login)
     * @see com.productlayer.android.sdk.services.UserService#isSignedIn(PLYAndroid, PLYCompletion)
     * @see com.productlayer.android.sdk.services.UserService#login(PLYAndroid, String, Boolean,
     * PLYCompletion)
     */
    public boolean isAuthTokenSet() {
        String authToken = getAuthToken();
        return authToken != null && !authToken.isEmpty();
    }

    /**
     * Waits for the state loading thread to finish if it is currently running.
     */
    private void waitForStateLoaded() {
        if (stateLoaded) {
            return;
        }
        if (stateLoader != null) {
            try {
                stateLoader.join(1000);
            } catch (Exception ignored) {
            } finally {
                stateLoaded = true;
                stateLoader = null;
            }
        }
    }

    /**
     * Call this in your activity's {@link Activity#onStop} to save the currently used PLYAndroid instance
     * (i.e. config, auth) to persistent SharedPreferences storage.
     *
     * @param context
     *         the context to retrieve shared preferences with
     * @param saveConfig
     *         true to save static configuration such as API and any proxy information
     * @param saveDynamic
     *         true to save dynamic state such as the authentication token
     */
    public void onStop(Context context, boolean saveConfig, boolean saveDynamic) {
        if (!saveConfig && !saveDynamic) {
            return;
        }
        SharedPreferences settings = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
        SharedPreferences.Editor editor = settings.edit();
        if (saveConfig) {
            // save client config in shared preferences
            editor.putString(STATE_API_SCHEMA, client.getConfigApiSchema());
            editor.putString(STATE_API_HOST, client.getConfigApiHost());
            editor.putInt(STATE_API_PORT, client.getConfigApiPort());
            editor.putString(STATE_API_VERSION, client.getConfigApiVersion());
            editor.putString(STATE_API_KEY, client.getConfigApiKey());
            editor.putBoolean(STATE_PROXY_ENABLED, client.isConfigProxyEnabled());
            editor.putString(STATE_PROXY_HOST, client.getConfigProxyHost());
            editor.putInt(STATE_PROXY_PORT, client.getConfigProxyPort());
        }
        if (saveDynamic) {
            // save auth session data in shared preferences
            editor.putString(STATE_USERNAME, client.getUsername());
            editor.putString(STATE_SESSION, client.getSession());
            editor.putString(STATE_TOKEN, client.getToken());
        }
        editor.apply();
    }

    /**
     * Call this in your activity's {@link Activity#onSaveInstanceState} to save the currently used PLYAndroid
     * instance (i.e. config, auth, locale, other headers) in non-persistent {@code Bundle} storage - to be
     * retrieved when/if the activity instance is restored.
     *
     * @param outState
     *         Bundle in which to store the information
     * @param saveConfig
     *         true to save static configuration such as API and any proxy information
     * @param saveDynamic
     *         true to save dynamic state such as the authentication token, language and other headers
     */
    public void onSaveInstanceState(Bundle outState, boolean saveConfig, boolean saveDynamic) {
        if (!saveConfig && !saveDynamic) {
            return;
        }
        if (saveConfig) {
            // save client config in bundle
            Serializable config = new PLYRestClientConfig(client.getConfigApiSchema(), client
                    .getConfigApiHost(), client.getConfigApiPort(), client.getConfigApiVersion(), client
                    .getConfigApiKey(), client.isConfigProxyEnabled(), client.getConfigProxyHost(), client
                    .getConfigProxyPort());
            outState.putSerializable(STATE_CONFIG, config);
        }
        if (saveDynamic) {
            // save auth session data, locale and other headers in bundle
            outState.putString(STATE_USERNAME, client.getUsername());
            outState.putString(STATE_SESSION, client.getSession());
            outState.putString(STATE_TOKEN, client.getToken());
            outState.putString(STATE_USER_AGENT, client.getUserAgent());
            outState.putString(STATE_PREFERRED_LANGUAGE, language);
            outState.putStringArray(STATE_ADDITIONAL_LANGUAGES, client.getAdditionalLanguages());
            outState.putSerializable(STATE_ADDITIONAL_HEADERS, (Serializable) client.getAdditionalHeaders());
        }
    }

    /**
     * Call this in your activity's {@link Activity#onCreate} to restore the previously used PLYAndroid
     * instance (i.e. config, auth) from persistent SharedPreferences storage. This method will not do
     * anything if the configuration is also saved in the supplied {@code Bundle}, in favor of {@link
     * #onRestoreInstanceState} restoring the data from non-persistent storage.
     *
     * In addition make sure to re-set any handlers.
     *
     * @param context
     *         the context to retrieve shared preferences with
     * @param savedInstanceState
     *         Bundle to retrieve previously stored information from
     * @param restoreConfig
     *         true to restore static configuration such as API and any proxy information
     * @param restoreDynamic
     *         true to restore dynamic state such as the authentication token
     */
    public void onCreate(final Context context, Bundle savedInstanceState, final boolean restoreConfig,
            final boolean restoreDynamic) {
        stateLoaded = false;
        if ((!restoreConfig && !restoreDynamic) || (savedInstanceState != null && savedInstanceState
                .getSerializable(STATE_CONFIG) != null)) {
            stateLoaded = true;
            // state will be restored in onRestoreInstanceState
            return;
        }
        // retrieve from shared preferences (in the bg due to disk access, i.e. performance reasons)
        Runnable stateLoaderRunnable = new Runnable() {
            @Override
            public void run() {
                SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
                if (restoreConfig) {
                    // restore client config from shared preferences
                    String apiSchema = prefs.getString(STATE_API_SCHEMA, null);
                    if (apiSchema != null) {
                        String apiHost = prefs.getString(STATE_API_HOST, null);
                        int apiPort = prefs.getInt(STATE_API_PORT, 0);
                        String apiVersion = prefs.getString(STATE_API_VERSION, null);
                        String apiKey = prefs.getString(STATE_API_KEY, null);
                        boolean proxyEnabled = prefs.getBoolean(STATE_PROXY_ENABLED, false);
                        String proxyHost = prefs.getString(STATE_PROXY_HOST, null);
                        int proxyPort = prefs.getInt(STATE_PROXY_PORT, 0);
                        initRestClient(new PLYRestClientConfig(apiSchema, apiHost, apiPort, apiVersion,
                                apiKey, proxyEnabled, proxyHost, proxyPort));
                        setLanguage(language);
                    }
                }
                if (restoreDynamic) {
                    // restore auth session data from shared preferences
                    String token = prefs.getString(STATE_TOKEN, null);
                    if (token != null) {
                        client.setToken(token);
                        client.setUsername(prefs.getString(STATE_USERNAME, null));
                        client.setSession(prefs.getString(STATE_SESSION, null));
                    }
                }
                stateLoaded = true;
                stateLoader = null;
            }
        };
        stateLoader = new Thread(stateLoaderRunnable);
        stateLoader.start();
    }

    /**
     * Call this in your activity's {@link Activity#onRestoreInstanceState} to restore the previously used
     * PLYAndroid instance (i.e. config, auth, locale, other headers) from non-persistent {@code Bundle}
     * storage.
     *
     * In addition make sure to re-set any handlers.
     *
     * @param savedInstanceState
     *         Bundle to retrieve previously stored information from
     * @param restoreConfig
     *         true to restore static configuration such as API and any proxy information
     * @param restoreDynamic
     *         true to restore dynamic state such as the authentication token, language and other headers
     */
    public void onRestoreInstanceState(Bundle savedInstanceState, boolean restoreConfig, boolean
            restoreDynamic) {
        stateLoaded = false;
        if ((!restoreConfig && !restoreDynamic) || savedInstanceState == null) {
            stateLoaded = true;
            return;
        }
        // retrieve stored information from bundle
        if (restoreConfig) {
            // restore client config from bundle
            PLYRestClientConfig config = (PLYRestClientConfig) savedInstanceState.getSerializable
                    (STATE_CONFIG);
            if (config != null) {
                initRestClient(config);
            }
        }
        if (restoreDynamic) {
            // restore auth session data, locale and other headers from bundle
            String token = savedInstanceState.getString(STATE_TOKEN);
            if (token != null) {
                client.setToken(token);
                client.setUsername(savedInstanceState.getString(STATE_USERNAME));
                client.setSession(savedInstanceState.getString(STATE_SESSION));
                client.setUserAgent(savedInstanceState.getString(STATE_USER_AGENT));
                client.setAdditionalLanguages(savedInstanceState.getStringArray(STATE_ADDITIONAL_LANGUAGES));
                //noinspection unchecked
                client.setAdditionalHeaders((Map<String, String>) savedInstanceState.getSerializable
                        (STATE_ADDITIONAL_HEADERS));
            }
            String language = savedInstanceState.getString(STATE_PREFERRED_LANGUAGE);
            if (language != null) {
                this.language = language;
            }
        }
        setLanguage(language);
        stateLoaded = true;
    }

    /**
     * Contains a single method returning an object of generic type.
     *
     * Used by the Android SDK service classes to encapsulate calls to the synchronous Java SDK.
     *
     * @param <T>
     *         the type used for the return value of its single execute method
     */
    public interface Query<T> {

        /**
         * @return the result of the implementing class' query
         * @throws PLYHttpException
         *         on any HTTP status code indicating failure
         * @throws RestClientException
         *         on any client-side HTTP error
         */
        T execute() throws PLYHttpException, RestClientException;

    }

    /**
     * Wraps an exception that may be thrown by the REST client, offering convenience methods to determine the
     * type of failure and to query a HTTP status code if present.
     */
    public class QueryError {

        private final Exception exception;

        /**
         * Creates a new query error caused by the given exception.
         *
         * @param exception
         *         the exception wrapped by this query error
         */
        public QueryError(Exception exception) {
            this.exception = exception;
        }

        /**
         * @return true if a HTTP status code that indicated an error (4xx, 5xx) was received, false if a
         * client-side error occurred
         */
        public boolean isHttpStatusError() {
            return exception instanceof PLYHttpException;
        }

        /**
         * @return the HTTP status code if this error was due to an erronous HTTP status code (4xx, 5xx), -1
         * else
         */
        public int getHttpStatusCode() {
            if (!isHttpStatusError()) {
                return -1;
            }
            return ((PLYHttpException) exception).getHttpStatus();
        }

        /**
         * @return any messages and/or HTTP status code in the wrapped exception
         */
        public String getMessage() {
            StringBuilder sb = new StringBuilder();
            if (isHttpStatusError()) {
                PLYHttpException e = (PLYHttpException) exception;
                int httpStatus = e.getHttpStatus();
                if (httpStatus > 0) {
                    sb.append("HTTP Status Code ").append(e.getHttpStatus()).append("\n");
                }
                List<ErrorMessage> errorMessages = e.getErrors();
                if (errorMessages != null) {
                    for (ErrorMessage errorMessage : errorMessages) {
                        sb.append(errorMessage.getMessage()).append(" (").append(errorMessage.getCode())
                                .append(")\n");
                    }
                }
            } else {
                String message = exception.getMessage();
                if (message != null) {
                    sb.append(message);
                }
            }
            return sb.toString();
        }

        /**
         * Looks through the attached internal {@link PLYStatusCodes} error codes to find the specified error
         * code.
         *
         * @param errorCode
         *         the error code to look for
         * @return true if {@code errorCode} was found, false else
         */
        public boolean hasInternalErrorCode(int errorCode) {
            if (!isHttpStatusError()) {
                return false;
            }
            PLYHttpException e = (PLYHttpException) exception;
            List<ErrorMessage> errorMessages = e.getErrors();
            if (errorMessages != null) {
                for (ErrorMessage errorMessage : errorMessages) {
                    if (errorMessage.getCode() == errorCode) {
                        return true;
                    }
                }
            }
            return false;
        }

        /**
         * @return the exception that caused this error (of type PLYHttpException on HTTP status codes
         * indicating failure, RestClientException on client-side HTTP errors)
         */
        public Exception getException() {
            return exception;
        }

    }

    /**
     * Executes a {@link Query} to forward its result to the {@link PLYCompletion} methods.
     *
     * Implements {@link Callable} - designed to be used by another thread.
     *
     * @param <T>
     *         the type that is returned by the {@link Query}
     */
    private class PLYTask<T> implements Callable<T> {

        private final Query<T> query;

        private final PLYCompletion<T> completion;

        /**
         * Stores the parameters to be used during {@link #call()}.
         *
         * @param query
         *         the query to execute
         * @param completion
         *         actions to set on success or on error once the query has finished
         */
        public PLYTask(Query<T> query, PLYCompletion<T> completion) {
            this.query = query;
            this.completion = completion;
        }

        /**
         * Executes the query and forwards its result to {@link PLYCompletion#onSuccess (Object)} or to {@link
         * PLYCompletion#onError(QueryError)} on any exception.
         *
         * @return the result of the query
         */
        @Override
        public T call() {
            final T result;
            HttpHeaders headers;
            try {
                result = query.execute();
                headers = client.getLastHttpHeaders();
            } catch (RuntimeException e) {
                final QueryError queryError = new QueryError(e);
                if (!queryError.isHttpStatusError() || queryError.getHttpStatusCode() != PLYStatusCodes
                        .HTTP_STATUS_FORBIDDEN_CODE || queryListener == null || !queryListener.onFailedAuth
                        (query, completion, queryError)) {
                    completion.onError(queryError);
                    mainHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            completion.onPostError(queryError);
                        }
                    });
                }
                throw e;
            }
            completion.onSuccess(result);
            mainHandler.post(new Runnable() {
                @Override
                public void run() {
                    completion.onPostSuccess(result);
                }
            });
            checkResponseHeaders(headers);
            return result;
        }

        /**
         * Checks the specified response headers for those indicating user progress (i.e. advancement in
         * points or achievements) and, if found, calls the installed {@link PLYUserProgressListener}.
         *
         * @param headers
         *         the HTTP headers to check
         */
        private void checkResponseHeaders(@SuppressWarnings("TypeMayBeWeakened") HttpHeaders headers) {
            if (headers == null) {
                return;
            }
            if (userProgressListener == null) {
                return;
            }
            String pointsStr = headers.getFirst(PLYRestClient.HEADER_USER_POINTS);
            String pointsChangeStr = headers.getFirst(PLYRestClient.HEADER_USER_POINTS_CHANGE);
            if (pointsStr != null && pointsChangeStr != null) {
                try {
                    final long points = Long.valueOf(pointsStr);
                    final int pointsChange = Integer.valueOf(pointsChangeStr);
                    mainHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            userProgressListener.earnedPoints(points, pointsChange);
                        }
                    });
                } catch (NumberFormatException ignored) {
                }
            }
            String achievements = headers.getFirst(PLYRestClient.HEADER_USER_NEW_ACHIEVEMENTS);
            if (achievements != null) {
                try {
                    final List<String> keys = new ArrayList<>();
                    JSONArray jsonArray = new JSONArray(achievements);
                    int jsonArraySize = jsonArray.length();
                    for (int i = 0; i < jsonArraySize; i++) {
                        keys.add(jsonArray.getString(i));
                    }
                    mainHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            userProgressListener.earnedAchievements(keys);
                        }
                    });
                } catch (JSONException ignored) {
                }
            }
        }

    }

} public abstract class PLYCompletion<T> {

    /**
     * Actions to set after a successful query. Do not modify the UI in this method.
     *
     * @param result
     *         the return value of the query
     */
    public abstract void onSuccess(T result);

    /**
     * Actions to set after a failed query. Do not directly modify the UI in this method.
     *
     * @param error
     *         contains the exception that was generated as a result of the failed query, can be used to
     *         determine the type of failure
     */
    public abstract void onError(PLYAndroid.QueryError error);

    /**
     * UI changes to display after {@link #onSuccess}. Runs on the UI thread.
     *
     * @param result
     *         the return value of the query
     */
    @SuppressWarnings("NoopMethodInAbstractClass")
    public void onPostSuccess(T result) {
    }

    /**
     * UI changes to display after {@link #onError}. Runs on the UI thread.
     *
     * @param error
     *         contains the exception that was generated as a result of the failed query, can be used to
     *         determine the type of failure
     */
    @SuppressWarnings("NoopMethodInAbstractClass")
    public void onPostError(PLYAndroid.QueryError error) {
    }

    /**
     * @return true if an authorization failure should trigger a login prompt, false else
     */
    public boolean promptForLogin() {
        return true;
    }

} public class ImageService {

    /**
     * Deletes a specific product image. Only the owner or an admin can delete the image. If the user earns
     * points for this operation 'X-ProductLayer-User-Points' and 'X-ProductLayer-User-Points-Changed' will be
     * present in the response header.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param imageID
     *         The identifier of the image
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         metadata of any deleted images
     * @return a Future object to optionally wait for the {@code ProductImage[]} result or to cancel the query
     */
    public static Future<ProductImage[]> deleteProductImage(final PLYAndroid client, final String imageID,
            PLYCompletion<ProductImage[]> completion) {
        return client.submit(new PLYAndroid.Query<ProductImage[]>() {
            @Override
            public ProductImage[] execute() {
                return com.productlayer.rest.client.services.ImageService.deleteProductImage(client
                        .getRestClient(), imageID);
            }
        }, completion);
    }

    /**
     * Deletes the avatar image of a user.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param userID
     *         The identifier of the user
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         metadata of the deleted user avatar image
     * @return a Future object to optionally wait for the {@code UserAvatarImage} result or to cancel the
     * query
     */
    public static Future<UserAvatarImage> deleteUserAvatar(final PLYAndroid client, final String userID,
            PLYCompletion<UserAvatarImage> completion) {
        return client.submit(new PLYAndroid.Query<UserAvatarImage>() {
            @Override
            public UserAvatarImage execute() {
                return com.productlayer.rest.client.services.ImageService.deleteUserAvatar(client
                        .getRestClient(), userID);
            }
        }, completion);
    }

    /**
     * Downvotes a specific product image. If the user already up voted the image the up-vote will be
     * removed.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param imageID
     *         The identifier of the metadata object or the identifier of the file ({image_id}.jpg)
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The image
     *         metadata with the new vote score
     * @return a Future object to optionally wait for the {@code ProductImage} result or to cancel the query
     */
    public static Future<ProductImage> downVoteProductImage(final PLYAndroid client, final String imageID,
            PLYCompletion<ProductImage> completion) {
        return client.submit(new PLYAndroid.Query<ProductImage>() {
            @Override
            public ProductImage execute() {
                return com.productlayer.rest.client.services.ImageService.downVoteProductImage(client
                        .getRestClient(), imageID);
            }
        }, completion);
    }

    /**
     * Gets the default image (highest voted image) of a product. For some browsers you need to add the .jpg
     * file extension (/product/{gtin}/default_image.jpg) to the url.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param gtin
     *         The GTIN (barcode) of the product
     * @param maxWidth
     *         [Optional] The preferred maximum width
     * @param maxHeight
     *         [Optional] The preferred maximum height
     * @param crop
     *         [Optional] Whether the image should be cropped
     * @param quality
     *         [Optional] The quality of the image between 20 and 100
     * @return the URL to the requested data
     */
    public static String getDefaultProductImageForSizeURL(final PLYAndroid client, final String gtin, final
    Integer maxWidth, final Integer maxHeight, final Boolean crop, final Integer quality) {
        return com.productlayer.rest.client.services.ImageService.getDefaultProductImageForSizeURL(client
                .getRestClient(), gtin, maxWidth, maxHeight, crop, quality);
    }

    /**
     * Gets the metadata of the default image (highest voted image) of a specific product.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param gtin
     *         The GTIN (barcode) of the product
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The image
     *         metadata
     * @return a Future object to optionally wait for the {@code ProductImage} result or to cancel the query
     */
    public static Future<ProductImage> getDefaultProductImageMeta(final PLYAndroid client, final String
            gtin, PLYCompletion<ProductImage> completion) {
        return client.submit(new PLYAndroid.Query<ProductImage>() {
            @Override
            public ProductImage execute() {
                return com.productlayer.rest.client.services.ImageService.getDefaultProductImageMeta(client
                        .getRestClient(), gtin);
            }
        }, completion);
    }

    /**
     * Gets a specific image. For some browsers you need to add the .jpg file extension
     * (/image/{image_id}.jpg) to the url.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param imageID
     *         The identifier of the image
     * @param maxWidth
     *         [Optional] The preferred maximum width
     * @param maxHeight
     *         [Optional] The preferred maximum height
     * @param crop
     *         [Optional] Whether the image should be cropped
     * @param quality
     *         [Optional] The quality of the image between 20 and 100
     * @return the URL to the requested data
     */
    public static String getImageForSizeURL(final PLYAndroid client, final String imageID, final Integer
            maxWidth, final Integer maxHeight, final Boolean crop, final Integer quality) {
        return com.productlayer.rest.client.services.ImageService.getImageForSizeURL(client.getRestClient()
                , imageID, maxWidth, maxHeight, crop, quality);
    }

    /**
     * Gets a specific image meta information.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param imageID
     *         The identifier of the image
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The image
     *         metadata
     * @return a Future object to optionally wait for the {@code ProductImage} result or to cancel the query
     */
    public static Future<ProductImage> getImageMeta(final PLYAndroid client, final String imageID,
            PLYCompletion<ProductImage> completion) {
        return client.submit(new PLYAndroid.Query<ProductImage>() {
            @Override
            public ProductImage execute() {
                return com.productlayer.rest.client.services.ImageService.getImageMeta(client.getRestClient
                        (), imageID);
            }
        }, completion);
    }

    /**
     * Gets all image metadata of a specific product. Use this to get all image URLs of a product.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param gtin
     *         The GTIN (barcode) of the product
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: Any found
     *         product image metadata
     * @return a Future object to optionally wait for the {@code ProductImage[]} result or to cancel the query
     */
    public static Future<ProductImage[]> getProductImages(final PLYAndroid client, final String gtin,
            PLYCompletion<ProductImage[]> completion) {
        return client.submit(new PLYAndroid.Query<ProductImage[]>() {
            @Override
            public ProductImage[] execute() {
                return com.productlayer.rest.client.services.ImageService.getProductImages(client
                        .getRestClient(), gtin);
            }
        }, completion);
    }

    /**
     * Gets the avatar of a specific user.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param userID
     *         The identifier of the user
     * @param size
     *         [Optional] The size of the avatar image in pixel. The avatar image is always a square image and
     *         the maximum size is 512 pixel.
     * @return the URL to the requested data
     */
    public static String getUserAvatarURL(final PLYAndroid client, final String userID, final Integer size) {
        return com.productlayer.rest.client.services.ImageService.getUserAvatarURL(client.getRestClient(),
                userID, size);
    }

    /**
     * Sends a report about copyright infringements or any other problems with the image.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param imageID
     *         The identifier of the image
     * @param report
     *         The report
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         problem report object
     * @return a Future object to optionally wait for the {@code ProblemReport} result or to cancel the query
     */
    public static Future<ProblemReport> reportImage(final PLYAndroid client, final String imageID, final
    ProblemReport report, PLYCompletion<ProblemReport> completion) {
        return client.submit(new PLYAndroid.Query<ProblemReport>() {
            @Override
            public ProblemReport execute() {
                return com.productlayer.rest.client.services.ImageService.reportImage(client.getRestClient
                        (), imageID, report);
            }
        }, completion);
    }

    /**
     * Rotates the image clockwise.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param imageID
     *         The identifier of the image
     * @param degrees
     *         [Optional] The degrees to rotate the image clockwise
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The image
     *         metadata after the rotation
     * @return a Future object to optionally wait for the {@code ProductImage} result or to cancel the query
     */
    public static Future<ProductImage> rotateImage(final PLYAndroid client, final String imageID, final
    Integer degrees, PLYCompletion<ProductImage> completion) {
        return client.submit(new PLYAndroid.Query<ProductImage>() {
            @Override
            public ProductImage execute() {
                return com.productlayer.rest.client.services.ImageService.rotateImage(client.getRestClient
                        (), imageID, degrees);
            }
        }, completion);
    }

    /**
     * Upvotes a specific product image. If the user already down voted the image the down-vote will be
     * removed.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param imageID
     *         The identifier of the metadata object or the identifier of the file ({image_id}.jpg)
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The image
     *         metadata with the new vote score
     * @return a Future object to optionally wait for the {@code ProductImage} result or to cancel the query
     */
    public static Future<ProductImage> upVoteProductImage(final PLYAndroid client, final String imageID,
            PLYCompletion<ProductImage> completion) {
        return client.submit(new PLYAndroid.Query<ProductImage>() {
            @Override
            public ProductImage execute() {
                return com.productlayer.rest.client.services.ImageService.upVoteProductImage(client
                        .getRestClient(), imageID);
            }
        }, completion);
    }

    /**
     * Updates the avatar image of a user.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param userID
     *         The identifier of the user
     * @param filePath
     *         the path to the file to upload
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         metadata of the updated user avatar image
     * @return a Future object to optionally wait for the {@code UserAvatarImage} result or to cancel the
     * query
     */
    public static Future<UserAvatarImage> updateUserAvatar(final PLYAndroid client, final String userID,
            final String filePath, PLYCompletion<UserAvatarImage> completion) {
        return client.submit(new PLYAndroid.Query<UserAvatarImage>() {
            @Override
            public UserAvatarImage execute() {
                return com.productlayer.rest.client.services.ImageService.updateUserAvatar(client
                        .getRestClient(), userID, filePath);
            }
        }, completion);
    }

    /**
     * Uploads an image for an opine. If the user earns points for this operation 'X-ProductLayer-User-Points'
     * and 'X-ProductLayer-User-Points-Changed' will be present in the response header.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param opineID
     *         The identifier of the opine
     * @param filePath
     *         the path to the file to upload
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         metadata of the uploaded image
     * @return a Future object to optionally wait for the {@code ProductImage} result or to cancel the query
     */
    public static Future<ProductImage> uploadOpineImage(final PLYAndroid client, final String opineID,
            final String filePath, PLYCompletion<ProductImage> completion) {
        return client.submit(new PLYAndroid.Query<ProductImage>() {
            @Override
            public ProductImage execute() {
                return com.productlayer.rest.client.services.ImageService.uploadOpineImage(client
                        .getRestClient(), opineID, filePath);
            }
        }, completion);
    }

    /**
     * Uploads a product image. If the user earns points for this operation 'X-ProductLayer-User-Points' and
     * 'X-ProductLayer-User-Points-Changed' will be present in the response header.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param gtin
     *         The GTIN (barcode) of the product
     * @param filePath
     *         the path to the file to upload
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The image
     *         metadata
     * @return a Future object to optionally wait for the {@code ProductImage} result or to cancel the query
     */
    public static Future<ProductImage> uploadProductImage(final PLYAndroid client, final String gtin, final
    String filePath, PLYCompletion<ProductImage> completion) {
        return client.submit(new PLYAndroid.Query<ProductImage>() {
            @Override
            public ProductImage execute() {
                return com.productlayer.rest.client.services.ImageService.uploadProductImage(client
                        .getRestClient(), gtin, filePath);
            }
        }, completion);
    }
} public class ProductService {

    /**
     * Creates a new product. If the user earns points for this operation 'X-ProductLayer-User-Points' and
     * 'X-ProductLayer-User-Points-Changed' will be present in the response header.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param product
     *         The product
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The newly
     *         created product
     * @return a Future object to optionally wait for the {@code Product} result or to cancel the query
     */
    public static Future<Product> createProduct(final PLYAndroid client, final Product product,
            PLYCompletion<Product> completion) {
        return client.submit(new PLYAndroid.Query<Product>() {
            @Override
            public Product execute() {
                return com.productlayer.rest.client.services.ProductService.createProduct(client
                        .getRestClient(), product);
            }
        }, completion);
    }

    /**
     * Downvotes a specific product. By using the product ID instead of the GTIN it's possible to vote for a
     * specific localized product.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param productID
     *         The identifier of the product (identifies a specific localized product, e.g.: Apple iPhone 5S
     *         (en))
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         product with the new vote score
     * @return a Future object to optionally wait for the {@code Product} result or to cancel the query
     */
    public static Future<Product> downVoteProduct(final PLYAndroid client, final String productID,
            PLYCompletion<Product> completion) {
        return client.submit(new PLYAndroid.Query<Product>() {
            @Override
            public Product execute() {
                return com.productlayer.rest.client.services.ProductService.downVoteProduct(client
                        .getRestClient(), productID);
            }
        }, completion);
    }

    /**
     * Gets suggestions of brand owners of a GTIN.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param gtin
     *         The GTIN (barcode) of the product
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: Any
     *         suggested brand owners for a GTIN
     * @return a Future object to optionally wait for the {@code BrandOwner[]} result or to cancel the query
     */
    public static Future<BrandOwner[]> getBrandOwnerSuggestions(final PLYAndroid client, final String gtin,
            PLYCompletion<BrandOwner[]> completion) {
        return client.submit(new PLYAndroid.Query<BrandOwner[]>() {
            @Override
            public BrandOwner[] execute() {
                return com.productlayer.rest.client.services.ProductService.getBrandOwnerSuggestions(client
                        .getRestClient(), gtin);
            }
        }, completion);
    }

    /**
     * Gets known brand owner names.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: All brand
     *         owner names
     * @return a Future object to optionally wait for the {@code String[]} result or to cancel the query
     */
    public static Future<String[]> getBrandOwners(final PLYAndroid client, PLYCompletion<String[]>
            completion) {
        return client.submit(new PLYAndroid.Query<String[]>() {
            @Override
            public String[] execute() {
                return com.productlayer.rest.client.services.ProductService.getBrandOwners(client
                        .getRestClient());
            }
        }, completion);
    }

    /**
     * Gets known brand names.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: All brand
     *         names
     * @return a Future object to optionally wait for the {@code String[]} result or to cancel the query
     */
    public static Future<String[]> getBrands(final PLYAndroid client, PLYCompletion<String[]> completion) {
        return client.submit(new PLYAndroid.Query<String[]>() {
            @Override
            public String[] execute() {
                return com.productlayer.rest.client.services.ProductService.getBrands(client.getRestClient());
            }
        }, completion);
    }

    /**
     * Gets suggestions of categories of a GTIN.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param gtin
     *         The GTIN (barcode) of the product
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: Any
     *         suggested categories for a GTIN
     * @return a Future object to optionally wait for the {@code Category[]} result or to cancel the query
     */
    public static Future<Category[]> getCategorySuggestions(final PLYAndroid client, final String gtin,
            PLYCompletion<Category[]> completion) {
        return client.submit(new PLYAndroid.Query<Category[]>() {
            @Override
            public Category[] execute() {
                return com.productlayer.rest.client.services.ProductService.getCategorySuggestions(client
                        .getRestClient(), gtin);
            }
        }, completion);
    }

    /**
     * Gets the scoring history within a range from a product.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param from_date
     *         [Optional] Start date, format: yyyy-MM-dd HH:mm:ss
     * @param to_date
     *         [Optional] End date, format: yyyy-MM-dd HH:mm:ss
     * @param count
     *         [Optional] The amount of results to be returned, default: '200'
     * @param language
     *         [Optional] The preferred language (e.g.: 'en' or 'de'), default: 'en'
     * @param showOpines
     *         [Optional] Display opines, default: 'true'
     * @param showReviews
     *         [Optional] Display reviews, default: 'true'
     * @param showPictures
     *         [Optional] Display uploaded images, default: 'true'
     * @param showProducts
     *         [Optional] Display created/updated products, default: 'true'
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         hottest products contained within a ranking results object
     * @return a Future object to optionally wait for the {@code RankingResults<Product>} result or to cancel
     * the query
     */
    public static Future<RankingResults<Product>> getHottestProducts(final PLYAndroid client, final Date
            from_date, final Date to_date, final Integer count, final String language, final Boolean
            showOpines, final Boolean showReviews, final Boolean showPictures, final Boolean showProducts,
            PLYCompletion<RankingResults<Product>> completion) {
        return client.submit(new PLYAndroid.Query<RankingResults<Product>>() {
            @Override
            public RankingResults<Product> execute() {
                return com.productlayer.rest.client.services.ProductService.getHottestProducts(client
                        .getRestClient(), from_date, to_date, count, language, showOpines, showReviews,
                        showPictures, showProducts);
            }
        }, completion);
    }

    /**
     * Gets localized category keys.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param language
     *         [Optional] The preferred language (e.g.: 'en' or 'de'), default: 'en'
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: All
     *         category keys and as their value the translation to the
     * @return a Future object to optionally wait for the {@code Map<String, String>} result or to cancel the
     * query preferred language
     */
    public static Future<Map<String, String>> getLocalizedCategories(final PLYAndroid client, final String
            language, PLYCompletion<Map<String, String>> completion) {
        return client.submit(new PLYAndroid.Query<Map<String, String>>() {
            @Override
            public Map<String, String> execute() {
                return com.productlayer.rest.client.services.ProductService.getLocalizedCategories(client
                        .getRestClient(), language);
            }
        }, completion);
    }

    /**
     * Gets localized characteristics keys.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param language
     *         [Optional] The preferred language (e.g.: 'en' or 'de'), default: 'en'
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: All
     *         characteristics keys and as their value the translation to
     * @return a Future object to optionally wait for the {@code Map<String, String>} result or to cancel the
     * query the preferred language
     */
    public static Future<Map<String, String>> getLocalizedCharacteristics(final PLYAndroid client, final
    String language, PLYCompletion<Map<String, String>> completion) {
        return client.submit(new PLYAndroid.Query<Map<String, String>>() {
            @Override
            public Map<String, String> execute() {
                return com.productlayer.rest.client.services.ProductService.getLocalizedCharacteristics
                        (client.getRestClient(), language);
            }
        }, completion);
    }

    /**
     * Gets localized nutrition keys.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param language
     *         [Optional] The preferred language (e.g.: 'en' or 'de'), default: 'en'
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: All
     *         nutrition keys and as their value the translation to the
     * @return a Future object to optionally wait for the {@code Map<String, String>} result or to cancel the
     * query preferred language
     */
    public static Future<Map<String, String>> getLocalizedNutrition(final PLYAndroid client, final String
            language, PLYCompletion<Map<String, String>> completion) {
        return client.submit(new PLYAndroid.Query<Map<String, String>>() {
            @Override
            public Map<String, String> execute() {
                return com.productlayer.rest.client.services.ProductService.getLocalizedNutrition(client
                        .getRestClient(), language);
            }
        }, completion);
    }

    /**
     * Gets the overall product count or the count for a specific timeframe.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param from_date
     *         [Optional] Start date, format: yyyy-MM-dd HH:mm:ss
     * @param to_date
     *         [Optional] End date, format: yyyy-MM-dd HH:mm:ss
     * @param categoryKey
     *         [Optional] The category key starting with 'pl-prod-cat-', e.g.: pl-prod-cat-books
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         product count
     * @return a Future object to optionally wait for the {@code Count} result or to cancel the query
     */
    public static Future<Count> getProductCount(final PLYAndroid client, final Date from_date, final Date
            to_date, final String categoryKey, PLYCompletion<Count> completion) {
        return client.submit(new PLYAndroid.Query<Count>() {
            @Override
            public Count execute() {
                return com.productlayer.rest.client.services.ProductService.getProductCount(client
                        .getRestClient(), from_date, to_date, categoryKey);
            }
        }, completion);
    }

    /**
     * Gets a product by GTIN and language. If language=auto the best language match will be returned. Best
     * language match using preferred language header and predefined secondary languages.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param gtin
     *         The GTIN (barcode) of the product
     * @param language
     *         [Optional] The preferred language (e.g.: 'en' or 'de')
     * @param suggestions
     *         [Optional] Make product suggestions if search returns no results. Product suggestions are all
     *         returned products without a pl-id parameter. Default: false
     * @param fetchOnly
     *         [Optional] Fetch only specific keys
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         identified product
     * @return a Future object to optionally wait for the {@code Product} result or to cancel the query
     */
    public static Future<Product> getProductForGtin(final PLYAndroid client, final String gtin, final
    String language, final Boolean suggestions, final String fetchOnly, PLYCompletion<Product> completion) {
        return client.submit(new PLYAndroid.Query<Product>() {
            @Override
            public Product execute() {
                return com.productlayer.rest.client.services.ProductService.getProductForGtin(client
                        .getRestClient(), gtin, language, suggestions, fetchOnly);
            }
        }, completion);
    }

    /**
     * Gets a product by GTIN and language.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param gtin
     *         The GTIN (barcode) of the product
     * @param language
     *         The preferred language (e.g.: 'en' or 'de')
     * @param searchAlsoOtherLocales
     *         Whether to also try different locales if the one specified is not found
     * @param fetchOnly
     *         [Optional] Fetch only specific keys
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         identified product or null if not found
     * @return a Future object to optionally wait for the {@code Product} result or to cancel the query
     */
    public static Future<Product> getProductForGtinAndLocale(final PLYAndroid client, final String gtin,
            final String language, final boolean searchAlsoOtherLocales, final boolean suggestions, final
    String fetchOnly, PLYCompletion<Product> completion) {
        return client.submit(new PLYAndroid.Query<Product>() {
            @Override
            public Product execute() {
                return com.productlayer.rest.client.services.ProductService.getProductForGtinAndLocale
                        (client.getRestClient(), gtin, language, searchAlsoOtherLocales, suggestions,
                                fetchOnly);
            }
        }, completion);
    }

    /**
     * Gets the values of a specific key.<br> <br> e.g.: ?key=pl-brand-name&amp;language=en returns all brands
     * which have been entered for products with the locale en.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param key
     *         The key to query the values of.
     * @param language
     *         [Optional] The preferred language (e.g.: 'en' or 'de'), default: 'en'
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: All values
     *         of the specified key
     * @return a Future object to optionally wait for the {@code ValuesForKey} result or to cancel the query
     */
    public static Future<ValuesForKey> getValuesForKey(final PLYAndroid client, final String key, final
    String language, PLYCompletion<ValuesForKey> completion) {
        return client.submit(new PLYAndroid.Query<ValuesForKey>() {
            @Override
            public ValuesForKey execute() {
                return com.productlayer.rest.client.services.ProductService.getValuesForKey(client
                        .getRestClient(), key, language);
            }
        }, completion);
    }

    /**
     * Sends a report about copyright infringements or any other problems with the product.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param productID
     *         The identifier of the product (identifies a specific localized product, e.g.: Apple iPhone 5S
     *         (en))
     * @param report
     *         The report
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         problem report object
     * @return a Future object to optionally wait for the {@code ProblemReport} result or to cancel the query
     */
    public static Future<ProblemReport> reportProduct(final PLYAndroid client, final String productID,
            final ProblemReport report, PLYCompletion<ProblemReport> completion) {
        return client.submit(new PLYAndroid.Query<ProblemReport>() {
            @Override
            public ProblemReport execute() {
                return com.productlayer.rest.client.services.ProductService.reportProduct(client
                        .getRestClient(), productID, report);
            }
        }, completion);
    }

    /**
     * Searches for products. If no search parameters are present the first 50 products will be presented.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param query
     *         [Optional] The query may contain the name, GTIN or brand of the product. <b>ATTENTION: If the
     *         query is set all other url parameters will be ignored!</b>
     * @param page
     *         [Optional] The page to be displayed starting with 0 - if no page has been provided, the first
     *         page will be shown
     * @param recordsPerPage
     *         [Optional] The amount of items to be displayed per page, default: '200'
     * @param gtin
     *         [Optional] The GTIN (barcode) of the product
     * @param brand
     *         [Optional] The brand of the product
     * @param brandOwner
     *         [Optional] The brand owner of the product
     * @param language
     *         [Optional] The preferred language (e.g.: 'en' or 'de')
     * @param suggestions
     *         [Optional] Make product suggestions if search returns no results. Product suggestions are all
     *         returned products without a pl-id parameter. Default: false
     * @param fetchOnly
     *         [Optional] Fetch only specific keys
     * @param name
     *         [Optional] The name of the product or a substring of it.
     * @param categoryKey
     *         [Optional] The category key starting with 'pl-prod-cat-', e.g.: pl-prod-cat-books
     * @param order_by
     *         [Optional] Used to sort the result-set by one or more columns. The order by parameters are
     *         <strong>seperated by a semicolon</strong>. Also you need to provide a prefix <strong>asc for
     *         ascending</strong> or <strong>desc for descending order</strong><br> <br>
     *         <strong>Default:</strong> pl-prod-name_asc (Product names ascending)
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: Any
     *         products matching the specified criteria
     * @return a Future object to optionally wait for the {@code Product[]} result or to cancel the query
     */
    public static Future<Product[]> searchProducts(final PLYAndroid client, final String query, final
    Integer page, final Integer recordsPerPage, final String gtin, final String brand, final String
            brandOwner, final String language, final Boolean suggestions, final String fetchOnly, final
    String name, final String categoryKey, final String order_by, PLYCompletion<Product[]> completion) {
        return client.submit(new PLYAndroid.Query<Product[]>() {
            @Override
            public Product[] execute() {
                return com.productlayer.rest.client.services.ProductService.searchProducts(client
                        .getRestClient(), query, page, recordsPerPage, gtin, brand, brandOwner, language,
                        suggestions, fetchOnly, name, categoryKey, order_by);
            }
        }, completion);
    }

    /**
     * Searches for products by brand.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param brand
     *         The brand of the product
     * @param page
     *         [Optional] The page to be displayed starting with 0 - if no page has been provided, the first
     *         page will be shown
     * @param recordsPerPage
     *         [Optional] The amount of items to be displayed per page, default: '200'
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: Any
     *         products matching the brand
     * @return a Future object to optionally wait for the {@code Product[]} result or to cancel the query
     */
    public static Future<Product[]> searchProductsByBrand(final PLYAndroid client, final String brand,
            final Integer page, final Integer recordsPerPage, PLYCompletion<Product[]> completion) {
        return client.submit(new PLYAndroid.Query<Product[]>() {
            @Override
            public Product[] execute() {
                return com.productlayer.rest.client.services.ProductService.searchProductsByBrand(client
                        .getRestClient(), brand, page, recordsPerPage);
            }
        }, completion);
    }

    /**
     * Searches for products by brand owner.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param brandOwner
     *         The brand owner of the product
     * @param page
     *         [Optional] The page to be displayed starting with 0 - if no page has been provided, the first
     *         page will be shown
     * @param recordsPerPage
     *         [Optional] The amount of items to be displayed per page, default: '200'
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: Any
     *         products matching the brand owner
     * @return a Future object to optionally wait for the {@code Product[]} result or to cancel the query
     */
    public static Future<Product[]> searchProductsByBrandOwner(final PLYAndroid client, final String
            brandOwner, final Integer page, final Integer recordsPerPage, PLYCompletion<Product[]>
            completion) {
        return client.submit(new PLYAndroid.Query<Product[]>() {
            @Override
            public Product[] execute() {
                return com.productlayer.rest.client.services.ProductService.searchProductsByBrandOwner
                        (client.getRestClient(), brandOwner, page, recordsPerPage);
            }
        }, completion);
    }

    /**
     * Searches for a product by GTIN (more than one result may be returned). The GTIN is unique for a product
     * but a Product object will be returned per locale.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param gtin
     *         [Optional] The GTIN (barcode) of the product
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: Any
     *         products matching the GTIN
     * @return a Future object to optionally wait for the {@code Product[]} result or to cancel the query
     */
    public static Future<Product[]> searchProductsByGtin(final PLYAndroid client, final String gtin, final
    boolean suggestions, PLYCompletion<Product[]> completion) {
        return client.submit(new PLYAndroid.Query<Product[]>() {
            @Override
            public Product[] execute() {
                return com.productlayer.rest.client.services.ProductService.searchProductsByGtin(client
                        .getRestClient(), gtin, suggestions);
            }
        }, completion);
    }

    /**
     * Searches for products by using the query string.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param query
     *         The query may contain the name, GTIN or brand of the product
     * @param categoryKey
     *         [Optional] The category key starting with 'pl-prod-cat-', e.g.: pl-prod-cat-books
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: Any
     *         products matching the specified criteria
     * @return a Future object to optionally wait for the {@code Product[]} result or to cancel the query
     */
    public static Future<Product[]> searchProductsByQuery(final PLYAndroid client, final String query,
            final String categoryKey, final boolean suggestions, PLYCompletion<Product[]> completion) {
        return client.submit(new PLYAndroid.Query<Product[]>() {
            @Override
            public Product[] execute() {
                return com.productlayer.rest.client.services.ProductService.searchProductsByQuery(client
                        .getRestClient(), query, categoryKey, suggestions);
            }
        }, completion);
    }

    /**
     * Upvotes a specific product. By using the product ID instead of the GTIN it's possible to vote for a
     * specific localized product.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param productID
     *         The identifier of the product (identifies a specific localized product, e.g.: Apple iPhone 5S
     *         (en))
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         product with the new vote score
     * @return a Future object to optionally wait for the {@code Product} result or to cancel the query
     */
    public static Future<Product> upVoteProduct(final PLYAndroid client, final String productID,
            PLYCompletion<Product> completion) {
        return client.submit(new PLYAndroid.Query<Product>() {
            @Override
            public Product execute() {
                return com.productlayer.rest.client.services.ProductService.upVoteProduct(client
                        .getRestClient(), productID);
            }
        }, completion);
    }

    /**
     * Updates a specific product. If the user earns points for this operation 'X-ProductLayer-User-Points'
     * and 'X-ProductLayer-User-Points-Changed' will be present in the response header.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param product
     *         The product
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         updated product
     * @return a Future object to optionally wait for the {@code Product} result or to cancel the query
     */
    public static Future<Product> updateProduct(final PLYAndroid client, final Product product,
            PLYCompletion<Product> completion) {
        return client.submit(new PLYAndroid.Query<Product>() {
            @Override
            public Product execute() {
                return com.productlayer.rest.client.services.ProductService.updateProduct(client
                        .getRestClient(), product);
            }
        }, completion);
    }

    /**
     * Updates a specific product. If the user earns points for this operation 'X-ProductLayer-User-Points'
     * and 'X-ProductLayer-User-Points-Changed' will be present in the response header.
     *
     * @param client
     *         the PLYAndroid SDK client configured to handle communications with the ProductLayer API server
     * @param gtin
     *         The GTIN (barcode) of the product
     * @param product
     *         The product
     * @param completion
     *         dealing with any errors or successful completion of the query, handling the results: The
     *         updated product
     * @return a Future object to optionally wait for the {@code Product} result or to cancel the query
     */
    public static Future<Product> updateProduct(final PLYAndroid client, final String gtin, final Product
            product, PLYCompletion<Product> completion) {
        return client.submit(new PLYAndroid.Query<Product>() {
            @Override
            public Product execute() {
                return com.productlayer.rest.client.services.ProductService.updateProduct(client
                        .getRestClient(), gtin, product);
            }
        }, completion);
    }
}
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.support.design.widget.Snackbar;
import android.support.design.widget.TabLayout;
import android.support.v4.view.ViewPager;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import com.productlayer.android.common.R;
import com.productlayer.android.common.adapter.NamedFragmentPagerAdapter;
import com.productlayer.android.common.adapter.TimelineAdapter;
import com.productlayer.android.common.global.LoadingIndicator;
import com.productlayer.android.common.handler.AppBarHandler;
import com.productlayer.android.common.handler.DataChangeListener;
import com.productlayer.android.common.handler.FloatingActionButtonHandler;
import com.productlayer.android.common.handler.HasAppBarHandler;
import com.productlayer.android.common.handler.HasFloatingActionButtonHandler;
import com.productlayer.android.common.handler.HasNavigationHandler;
import com.productlayer.android.common.handler.HasPLYAndroidHolder;
import com.productlayer.android.common.handler.HasTimelineSettingsHandler;
import com.productlayer.android.common.handler.HasUserHandler;
import com.productlayer.android.common.handler.NavigationHandler;
import com.productlayer.android.common.handler.PLYAndroidHolder;
import com.productlayer.android.common.handler.TimelineSettingsHandler;
import com.productlayer.android.common.handler.UserHandler;
import com.productlayer.android.common.util.ColorUtil;
import com.productlayer.android.common.util.MetricsUtil;
import com.productlayer.android.common.util.PhotoUtil;
import com.productlayer.android.common.util.SnackbarUtil;
import com.productlayer.android.common.util.SystemBarsUtil;
import com.productlayer.android.common.util.ThemeUtil;
import com.productlayer.android.common.view.LikeView;
import com.productlayer.android.sdk.PLYAndroid;
import com.productlayer.android.sdk.PLYCompletion;
import com.productlayer.android.sdk.services.ImageService;
import com.productlayer.android.sdk.services.ProductService;
import com.productlayer.core.beans.Product;
import com.productlayer.core.beans.ProductImage;
import com.productlayer.core.beans.User;
import com.squareup.picasso.MemoryPolicy;
import com.squareup.picasso.NetworkPolicy;
import com.squareup.picasso.Picasso;
/*
 * Copyright (c) 2015, ProductLayer GmbH All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * - Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package com.productlayer.android.common.fragment;



/**
 * Displays the screen including a product's details, its timeline of opinions, a large preview photo as part
 * of the toolbar, and possibilities for interaction.
 *
 * Requires the activity to have {@link AppBarHandler}, {@link FloatingActionButtonHandler}, {@link
 * TimelineSettingsHandler}, {@link NavigationHandler}, {@link UserHandler}, {@link PLYAndroidHolder} - and
 * {@link MetricsUtil} to be set up.
 */
// TODO register callback to UserHandler to react to user changes, ie to reset the like view and timeline
public class ProductFragment extends NamedFragment {

    public static final String NAME = "Product";

    public static final int TAB_INDEX_DEFAULT = -1;
    public static final int TAB_INDEX_TIMELINE = 0;
    public static final int TAB_INDEX_DETAILS = 1;

    private static final int QUALITY_PRODUCT_IMAGE = 85;

    private static final String KEY_PRODUCT = "product";
    private static final String STATE_PRODUCT = "product";

    private AppBarHandler appBarHandler;
    private FloatingActionButtonHandler fabHandler;
    private TimelineSettingsHandler timelineSettingsHandler;
    private NavigationHandler navigationHandler;
    private UserHandler userHandler;

    private PLYAndroid client;