au.id.tmm.anewreader.model.Model.java Source code

Java tutorial

Introduction

Here is the source code for au.id.tmm.anewreader.model.Model.java

Source

/*******************************************************************************
 * This file is part of A New Reader
 * Copyright (C) 2013 Timothy McCarthy
 *
 * A New Reader is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * A New Reader is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with A New Reader.  If not, see <http://www.gnu.org/licenses/>.
 ******************************************************************************/

package au.id.tmm.anewreader.model;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import au.id.tmm.anewreader.model.net.ReaderServiceRequestHelper;

/**
 * Class handling the retrieval and construction of Items and Feeds.
 * <p/>
 * In terms of design, this class leaves much to be desired. It is too large, with too many
 * responsibilities. In the medium term, the Model class will be extracted out as an interface, with
 * concrete instantiations available for different purposes (eg one for online retrieval of Items
 * and Feeds, and another for offline storage). Instantiation of Feeds and Items should be delegated
 * to dedicated factories, their products accessible through the Model interface.
 */
class Model {

    private Map<String, Subscription> subscriptions = new HashMap<String, Subscription>();
    private Map<String, Category> categories = new HashMap<String, Category>();
    private ReadingList readingList;

    private ItemCache itemCache = new ItemCache();

    private Account parentAccount;

    public Model(Account parentAccount) {
        this.parentAccount = parentAccount;
    }

    protected Account getParentAccount() {
        return parentAccount;
    }

    /**
     * Get the subscriptions associated with this model.
     */
    public SortedSet<Subscription> getSubscriptions() throws IOException {
        final String SUBSCRIPTIONS_URL = this.parentAccount.getReaderService().getBaseUrl()
                + "/reader/api/0/subscription/list?output=json";

        this.subscriptions.clear();

        ReaderServiceRequestHelper requestHelper = new ReaderServiceRequestHelper(
                this.parentAccount.getAuthHelper());

        try {

            JSONObject jsonResponse;

            jsonResponse = new JSONObject(requestHelper.performGetRequest(SUBSCRIPTIONS_URL));

            JSONArray responseArray = jsonResponse.getJSONArray("subscriptions");

            for (int i = 0; i < responseArray.length(); i++) {
                Subscription newSubscription = this.getSubscriptionFromJson(responseArray.getJSONObject(i),
                        this.parentAccount);
                this.subscriptions.put(newSubscription.getId(), newSubscription);
            }

        } catch (JSONException e) {
            throw new ApiParseException(e);
        }

        return new TreeSet<Subscription>(this.subscriptions.values());

    }

    /**
     * Get the categories associated with this model.
     */
    public SortedSet<Category> getCategories() throws IOException {

        final String CATEGORIES_BASE_URL = this.parentAccount.getReaderService().getBaseUrl()
                + "/reader/api/0/tag/list?output=json";

        ReaderServiceRequestHelper requestHelper = new ReaderServiceRequestHelper(
                this.parentAccount.getAuthHelper());

        this.categories.clear();

        try {
            JSONArray categoriesArray = new JSONObject(requestHelper.performGetRequest(CATEGORIES_BASE_URL))
                    .getJSONArray("tags");

            for (int i = 0; i < categoriesArray.length(); i++) {
                Category newCategory = this.getCategoryFromJson(categoriesArray.getJSONObject(i),
                        this.parentAccount);
                this.categories.put(newCategory.getId(), newCategory);
            }
        } catch (JSONException e) {
            throw new ApiParseException(e);
        }

        return new TreeSet<Category>(this.categories.values());

    }

    /**
     * Retrieve items according to the given parameters. If readStatus is null, both read and unread
     * items will be returned.
     * <p/>
     * Note the retrieval of only read items is not currently supported. The api doesn't allow this
     * directly, and it is not currently available to the end user classes. Requesting only read
     * items results in an UnsupportedOperationException being thrown.
     */
    public ListWithContinuation<Item> getItems(Feed feed, ReadStatus readStatus, int numItemsLimit, Date olderThan,
            Continuation continuation) throws IOException {
        try {

            if (readStatus == ReadStatus.UNREAD) {

                ListWithContinuation<String> itemIds = this.getItemIdsFromApi(feed, true, numItemsLimit, olderThan,
                        continuation);
                return new ListWithContinuation<Item>(this.getItemsFromIds(itemIds.getList(), ReadStatus.UNREAD),
                        itemIds.getContinuation());

            } else if (readStatus == ReadStatus.READ) {

                // This is made very difficult by the API, and is a functionality used by the app,
                // so we just leave as unsupported.
                throw new UnsupportedOperationException();

            } else {

                ListWithContinuation<String> allItemIds = this.getItemIdsFromApi(feed, false, numItemsLimit,
                        olderThan, continuation);
                ListWithContinuation<String> unreadItemIds = this.getItemIdsFromApi(feed, true, numItemsLimit,
                        olderThan, continuation);
                List<Item> returnedItems = this.getItemsFromIds(allItemIds.getList());

                for (Item currentItem : returnedItems) {
                    currentItem
                            .setReadStatus(unreadItemIds.getList().contains(currentItem.getId()) ? ReadStatus.UNREAD
                                    : ReadStatus.READ);
                }

                return new ListWithContinuation<Item>(returnedItems, allItemIds.getContinuation());

            }
        } catch (JSONException e) {
            throw new ApiParseException(e);
        }

    }

    private List<Item> getItemsFromIds(List<String> itemIds) throws IOException {
        return this.getItemsFromIds(itemIds, null);
    }

    /**
     * For a list of item ids, returns the corresponding Item objects. These are retrieved from the
     * ItemCache if possible, otherwise they are retrieved from the api.
     */
    private List<Item> getItemsFromIds(List<String> itemIds, ReadStatus readStatus) throws IOException {

        // Essentially the process here is to identify which items are not available through the
        // ItemCache, and to then retrieve these items in-bulk from the api.

        // Create a list of the same size as the list of item ids in which we will place the
        // Item objects.
        List<Item> returnedItems = new ArrayList<Item>(Collections.nCopies(itemIds.size(), (Item) null));

        // A map holding the ids of uncached items and their indexes in the returnedItems list.
        Map<String, Integer> idsAndIndexesOfUncachedItems = new HashMap<String, Integer>();

        for (int i = 0; i < itemIds.size(); i++) {
            Item cachedItemForThisId = this.itemCache.get(itemIds.get(i));

            if (cachedItemForThisId == null) {
                idsAndIndexesOfUncachedItems.put(itemIds.get(i), i);
            } else {
                cachedItemForThisId.setReadStatus(readStatus);
                returnedItems.add(i, cachedItemForThisId);
            }
        }

        List<Item> itemsNotFoundInCache = this
                .getItemsFromApi(new ArrayList<String>(idsAndIndexesOfUncachedItems.keySet()), readStatus);

        for (Item currentItem : itemsNotFoundInCache) {
            returnedItems.set(idsAndIndexesOfUncachedItems.get(currentItem.getId()), currentItem);
        }

        return returnedItems;
    }

    /**
     * Construct a Subscription object from a JSON object retrieved from the api.
     */
    private Subscription getSubscriptionFromJson(JSONObject subscriptionJsonObject, Account parentAccount)
            throws JSONException {
        String id = this.extractSubscriptionId(subscriptionJsonObject.getString("id"));

        String title = subscriptionJsonObject.getString("title");
        String url = subscriptionJsonObject.getString("url");
        String htmlUrl = subscriptionJsonObject.getString("htmlUrl");
        String iconUrl = subscriptionJsonObject.getString("iconUrl");

        Set<Category> categories = new TreeSet<Category>();

        JSONArray categoriesArray = subscriptionJsonObject.getJSONArray("categories");

        for (int i = 0; i < categoriesArray.length(); i++) {
            categories.add(this.getCategoryFromJson(categoriesArray.getJSONObject(i), parentAccount));
        }

        // TODO the unread count is set to 0 since unread counts are not currently supported
        return new Subscription(id, title, categories, url, htmlUrl, iconUrl, 0, this);
    }

    /**
     * Extract a subscription's id from the id field in a subscription JSON object retrieved from
     * the api.
     */
    private String extractSubscriptionId(String idFromJson) {
        Pattern idPattern = Pattern.compile("^feed/(.*)$");
        Matcher matcher = idPattern.matcher(idFromJson);
        if (matcher.find()) {
            return matcher.group(1);
        } else {
            throw new ApiParseException();
        }
    }

    /**
     * Construct a Category object from a JSON object retrieved from the api.
     */
    private Category getCategoryFromJson(JSONObject categoryJsonObject, Account parentAccount)
            throws JSONException {
        return this.getCategoryFromId(categoryJsonObject.getString("id"), parentAccount);
    }

    /**
     * Constructs a Category object from a category id retrieved from the api.
     */
    private Category getCategoryFromId(String id, Account parentAccount) {
        Pattern labelPattern = Pattern.compile("^user/-/label/(.*)$");
        Matcher matcher = labelPattern.matcher(id);
        if (matcher.find()) {
            // TODO the unread count is set to 0 since unread counts are not currently supported
            return new Category(id, matcher.group(1), 0, this);
        } else {
            throw new ApiParseException();
        }
    }

    /**
     * Constructs an Item object from a JSON object retrieved from the api.
     */
    private Item getItemFromJson(JSONObject itemJsonObject, ReadStatus readStatus, Account parentAccount)
            throws JSONException, IOException {
        Pattern idPattern = Pattern.compile("^tag:google\\.com,2005:reader/item/(.*)$");
        Matcher matcher = idPattern.matcher(itemJsonObject.getString("id"));
        String id;
        if (matcher.find()) {
            id = matcher.group(1);
        } else {
            throw new ApiParseException();
        }

        String title = itemJsonObject.getString("title");
        String canonicalLink = itemJsonObject.getJSONArray("canonical").getJSONObject(0).getString("href");
        String alternateLink = itemJsonObject.getJSONArray("alternate").getJSONObject(0).getString("href");

        Date publishedTimestamp = new Date(Long.parseLong(itemJsonObject.getString("published")));
        Date updatedTimestamp = new Date(Long.parseLong(itemJsonObject.getString("updated")));
        Date crawlTimestamp = new Date(Long.parseLong(itemJsonObject.getString("crawlTimeMsec")));

        String summary = itemJsonObject.getJSONObject("summary").getString("content");
        String author = itemJsonObject.getString("author");

        Set<Category> categories = new TreeSet<Category>();

        JSONArray categoriesArray = itemJsonObject.getJSONArray("categories");

        for (int i = 0; i < categoriesArray.length(); i++) {
            if (categoriesArray.getString(i).matches("\\^user/-/label/(.*)$")) {

                String currentId = categoriesArray.getString(i);

                if (this.categories.containsKey(currentId)) {
                    categories.add(this.categories.get(currentId));
                } else {
                    // The category associated with the item is not in our categories array. This
                    // problem should be fixed when this class is refactored as mentioned above.
                    // For the moment, we simply throw a RuntimeException.
                    throw new RuntimeException();
                }
            }
        }

        JSONObject originJsonObject = itemJsonObject.getJSONObject("origin");

        Subscription parentSubscription;
        String streamId = this.extractSubscriptionId(originJsonObject.getString("streamId"));
        if (this.subscriptions.containsKey(streamId)) {
            parentSubscription = this.subscriptions.get(streamId);
        } else {
            this.getSubscriptions();
            if (this.subscriptions.containsKey(streamId)) {
                parentSubscription = this.subscriptions.get(streamId);
            } else {
                // The subscription associated with the item is not in our subscriptions array. This
                // problem should be fixed when this class is refactored as mentioned above.
                // For the moment, we simply throw a RuntimeException.
                throw new RuntimeException();
            }

        }

        return new Item(id, title, canonicalLink, alternateLink, publishedTimestamp, updatedTimestamp,
                crawlTimestamp, summary, author, categories, parentSubscription, readStatus);
    }

    /**
     * Retrieves the item ids for the given parameters. These ids can then be used to construct
     * corresponding Item objects.
     */
    private ListWithContinuation<String> getItemIdsFromApi(Feed feed, boolean onlyUnread, int numItemsLimit,
            Date olderThan, Continuation continuation) throws IOException, JSONException {
        final String BASE_ITEMS_URL = this.parentAccount.getReaderService().getBaseUrl()
                + "/reader/api/0/stream/items/ids?output=json";
        final String READ_ITEMS_STREAM = "user/-/state/com.google/read";

        String itemListUrl = BASE_ITEMS_URL + "&s=" + feed.getEncodedFeedAddress()
                + (onlyUnread ? "&xt=" + READ_ITEMS_STREAM : "") + "&n=" + String.valueOf(numItemsLimit) + "&r=d"
                + (olderThan != null ? "&ot=" + Long.toString(olderThan.getTime()) : "")
                + (continuation != null ? "&c=" + Long.toString(continuation.getCode()) : "");

        ReaderServiceRequestHelper requestHelper = new ReaderServiceRequestHelper(
                this.parentAccount.getAuthHelper());

        JSONObject itemsResponse;
        itemsResponse = new JSONObject(requestHelper.performGetRequest(itemListUrl));

        JSONArray itemsResponseArray = itemsResponse.getJSONArray("itemRefs");

        List<String> returnedIds = new ArrayList<String>(itemsResponseArray.length());

        for (int i = 0; i < itemsResponseArray.length(); i++) {
            returnedIds.add(itemsResponseArray.getJSONObject(i).getString("id"));
        }

        Continuation returnedContinuation = null;
        if (itemsResponse.has("continuation")) {
            returnedContinuation = new Continuation(itemsResponse.getLong("continuation"), feed);
        }

        return new ListWithContinuation<String>(returnedIds, returnedContinuation);
    }

    /**
     * Constructs a list of Item objects for the given list of ids from the api.
     * @param ids
     * @param readStatus
     * @return
     * @throws IOException
     */
    private List<Item> getItemsFromApi(List<String> ids, ReadStatus readStatus) throws IOException {

        if (ids == null || ids.isEmpty()) {
            // Return empty list of items
            return new ArrayList<Item>();
        }

        final String ITEMS_BASE_URL = this.parentAccount.getReaderService().getBaseUrl()
                + "/reader/api/0/stream/items/contents?output=json";

        StringBuilder itemsUrl = new StringBuilder(ITEMS_BASE_URL);

        for (String currentId : ids) {
            itemsUrl.append("&i=").append(currentId);
        }

        ReaderServiceRequestHelper requestHelper = new ReaderServiceRequestHelper(
                this.parentAccount.getAuthHelper());

        JSONObject itemsResponse;

        try {

            itemsResponse = new JSONObject(requestHelper.performGetRequest(itemsUrl.toString()));

            JSONArray itemsArray = itemsResponse.getJSONArray("items");

            List<Item> returnedItems = new ArrayList<Item>(itemsArray.length());

            for (int i = 0; i < itemsArray.length(); i++) {
                returnedItems
                        .add(this.getItemFromJson(itemsArray.getJSONObject(i), readStatus, this.parentAccount));
            }

            return returnedItems;

        } catch (JSONException e) {
            throw new ApiParseException(e);
        }

    }

    private List<Item> getItemsFromApi(List<String> ids) throws IOException {
        return this.getItemsFromApi(ids, null);
    }

    public ReadingList getReadingList(String title) {
        // TODO the unread count is set to 0 since unread counts are not currently supported
        if (this.readingList == null) {
            this.readingList = new ReadingList(this, title, 0);
        } else {
            this.readingList.setTitle(title);
        }
        return this.readingList;
    }
}