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 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 abstract class EdgeScrollListener extends RecyclerView.OnScrollListener {

    private static final long NANOS_BETWEEN_RETRIEVALS = 1000000000;

    private StaggeredGridLayoutManager gridLayoutManager;
    private int gridColumns;

    private int scrollX = 0;
    private int scrollY = 0;
    private int scrollYLastFling = 0;
    private long lastRetrievalNanoTime = 0;

    /**
     * Creates a new EdgeScrollListener.
     *
     * @param gridLayoutManager
     *         the layout manager to question for item visibility when determining whether top/bottom is hit
     * @param gridColumns
     *         the amount of columns in the grid to use for determining whether top/bottom is closed in on
     */
    public EdgeScrollListener(StaggeredGridLayoutManager gridLayoutManager, int gridColumns) {
        this.gridLayoutManager = gridLayoutManager;
        this.gridColumns = gridColumns;
    }

    /**
     * Runs when closing in on the top edge.
     */
    public abstract void onHitTop();

    /**
     * Runs when closing in on the bottom edge.
     */
    public abstract void onHitBottom();

    /**
     * Runs when scrolling up.
     */
    public abstract void onScrollUp();

    /**
     * Runs when scrolling down.
     */
    public abstract void onScrollDown();

    @Override
    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
        super.onScrollStateChanged(recyclerView, newState);
        //Log.v("RView.OnScrollStateCh", "Scroll state changed to " + newState + " at coordinate " +
        //        scrollY);
        if (newState == RecyclerView.SCROLL_STATE_IDLE && scrollY <= 0) {
            // scrollY will be too low and may become negative after navigating away and pressing
            // back due to vars being reset and scrollToPosition not triggering the OnScrollListener
            if (scrollYLastFling == scrollY) {
                // no change in scrollY since last fling -> at top
                if (checkRetrievalTime()) {
                    onHitTop();
                }
            }
            scrollYLastFling = scrollY;
        }
    }

    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        super.onScrolled(recyclerView, dx, dy);
        scrollX += dx;
        scrollY += dy;
        //Log.v("RecyclerView.OnScrolled", "Current vertical scroll position: " + scrollY);
        if (dy < 0) {
            // retrieve more recent items on scrolling up if close to the top
            int[] firstVisibleItemPositions = gridLayoutManager.findFirstVisibleItemPositions(null);
            if (firstVisibleItemPositions == null || firstVisibleItemPositions.length == 0 ||
                    firstVisibleItemPositions[0] == RecyclerView.NO_POSITION ||
                    firstVisibleItemPositions[0] < gridColumns) {
                //Log.v("RecyclerView.OnScrolled", "Nearing the top, scrolling up by " + -dy + "px");
                if (checkRetrievalTime()) {
                    onHitTop();
                }
            }
            onScrollUp();
        } else if (dy > 0) {
            // retrieve less recent items on scrolling down if close to the bottom
            int itemCount = gridLayoutManager.getItemCount();
            int[] lastVisibleItemPositions = gridLayoutManager.findLastVisibleItemPositions(null);
            if (lastVisibleItemPositions == null || lastVisibleItemPositions.length == 0 ||
                    lastVisibleItemPositions[0] == RecyclerView.NO_POSITION ||
                    lastVisibleItemPositions[0] > itemCount - gridColumns * 4) {
                //Log.v("RecyclerView.OnScrolled", "Nearing the bottom, scrolling down by " + dy +
                // "px");
                if (checkRetrievalTime()) {
                    onHitBottom();
                }
            }
            onScrollDown();
        }
    }

    /**
     * @return true if the last timeline retrieval was more than {@code NANOS_BETWEEN_RETRIEVALS} nanoseconds
     * ago, false else
     */
    private boolean checkRetrievalTime() {
        long currentNanoTime = System.nanoTime();
        if (currentNanoTime > lastRetrievalNanoTime + NANOS_BETWEEN_RETRIEVALS) {
            lastRetrievalNanoTime = currentNanoTime;
            return true;
        }
        return false;
    }

} public interface HasAppBarHandler {

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

} 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 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 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) {
                }
            }
        }

    }

}
import android.app.Activity;
import android.content.Context;
import android.content.res.Configuration;
import android.os.Bundle;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.StaggeredGridLayoutManager;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.productlayer.android.common.R;
import com.productlayer.android.common.adapter.TimelineAdapter;
import com.productlayer.android.common.handler.AppBarHandler;
import com.productlayer.android.common.handler.EdgeScrollListener;
import com.productlayer.android.common.handler.HasAppBarHandler;
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.MetricsUtil;
import com.productlayer.android.sdk.PLYAndroid;
/*
 * 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;



/**
 * Abstract class to display a timeline feed in a RecyclerView with a staggered grid layout. The type of items
 * to be retrieved can be set by extending classes.
 *
 * Requires the activity to implement {@link HasNavigationHandler}, {@link HasAppBarHandler}, {@link
 * HasUserHandler}, {@link HasTimelineSettingsHandler}, {@link HasPLYAndroidHolder} - and {@link MetricsUtil}
 * to be set up. Calling {@link #setUnusedHeightPx} is advised to be done before or in {@link #onCreateView}.
 */
public abstract class TimelineFragment extends NamedFragment {

    // TODO make configurable by extending classes and set loaded entries based on phone cpu/memory
    private static final int LOAD_ITEMS = 20;
    private static final int MIN_WIDTH_DP_PER_COL = 250;

    private static final int AVATAR_SIZE_DP = 48;

    protected TimelineSettingsHandler timelineSettingsHandler;
    protected UserHandler userHandler;
    private AppBarHandler appBarHandler;

    private RecyclerView feedView;

    private TimelineAdapter feedAdapter;

    private int unusedHeightPx;

    /**
     * Implement to configure the type of timeline feed items to retrieve.
     *
     * @return the call for the initial retrieval of feed items
     */
    protected abstract TimelineAdapter.Retrieval setupRetrieval();

    /**
     * Implement to do any app bar setup.
     *
     * @param appBarHandler
     *         the app bar handler passed to this fragment
     * @param layout
     *         the layout used by this fragment
     */
    protected abstract void setupAppBar(AppBarHandler appBarHandler, View layout);

    /**
     * @return the identifier for this type of timeline
     */
    protected abstract String getSettingsTag();

    /**
     * @return the default display settings for this type of timeline
     */
    protected abstract int getDefaultSettings();

    // FRAGMENT LIFECYCLE - START //

    @Override
    public void onAttach(Context context) {
        super.onAttach(context);
        Activity activity = getActivity();
        try {
            appBarHandler = ((HasAppBarHandler) activity).getAppBarHandler();
        } catch (ClassCastException e) {
            throw new ClassCastException(activity + " must implement HasAppBarHandler");
        }
        NavigationHandler navigationHandler;
        try {
            navigationHandler = ((HasNavigationHandler) activity).getNavigationHandler();
        } catch (ClassCastException e) {
            throw new ClassCastException(activity + " must implement HasNavigationHandler");
        }
        try {
            userHandler = ((HasUserHandler) activity).getUserHandler();