package com.andrewshu.android.reddit.comments; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.io.IOException; import java.io.InputStream; import java.util.Collection; import java.util.LinkedList; import org.apache.http.Header; import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpGet; import org.codehaus.jackson.JsonParseException; import org.codehaus.jackson.map.ObjectMapper; import android.os.AsyncTask; import android.text.Html; import android.text.Spanned; import android.util.Log; import android.view.Window; import android.widget.Toast; import com.andrewshu.android.reddit.comments.ProcessCommentsTask.DeferredCommentProcessing; import com.andrewshu.android.reddit.common.CacheInfo; import com.andrewshu.android.reddit.common.Common; import com.andrewshu.android.reddit.common.Constants; import com.andrewshu.android.reddit.common.ProgressInputStream; import com.andrewshu.android.reddit.common.util.Assert; import com.andrewshu.android.reddit.common.util.StringUtils; import com.andrewshu.android.reddit.common.util.Util; import com.andrewshu.android.reddit.markdown.Markdown; import com.andrewshu.android.reddit.settings.RedditSettings; import com.andrewshu.android.reddit.things.Listing; import com.andrewshu.android.reddit.things.ListingData; import com.andrewshu.android.reddit.things.ThingInfo; import com.andrewshu.android.reddit.things.ThingListing; import com.andrewshu.android.reddit.threads.ShowThumbnailsTask; import com.andrewshu.android.reddit.threads.ShowThumbnailsTask.ThumbnailLoadAction; /** * Task takes in a subreddit name string and thread id, downloads its data, parses * out the comments, and communicates them back to the UI as they are read. * * Requires the following navigation variables to be set: * mSettings.subreddit * mSettings.threadId * mMoreChildrenId (can be "") * mSortByUrl */ public class DownloadCommentsTask extends AsyncTask<Integer, Long, Boolean> implements PropertyChangeListener { private static final String TAG = "CommentsListActivity.DownloadCommentsTask"; private final ObjectMapper mObjectMapper = Common.getObjectMapper(); private final Markdown markdown = new Markdown(); private static AsyncTask<?, ?, ?> mCurrentDownloadCommentsTask = null; private static final Object mCurrentDownloadCommentsTaskLock = new Object(); private ShowThumbnailsTask mCurrentShowThumbnailsTask = null; private final Object mCurrentShowThumbnailsTaskLock = new Object(); private ProcessCommentsTask mProcessCommentsTask; private CommentsListActivity mActivity; private String mSubreddit; private String mThreadId; private String mThreadTitle; private RedditSettings mSettings; private HttpClient mClient; // offset of the first comment being loaded; 0 if it includes OP private int mPositionOffset = 0; private int mIndentation = 0; private String mMoreChildrenId = ""; private ThingInfo mOpThingInfo = null; // Progress bar private long mContentLength = 0; private String mJumpToCommentId = ""; private int mJumpToCommentFoundIndex = -1; private int mJumpToCommentContext = 0; /** * List holding the comments to be appended at the end. * Used when loading an entire thread. */ private final LinkedList<ThingInfo> mDeferredAppendList = new LinkedList<ThingInfo>(); /** * List holding the comments to be inserted at mPositionOffset; the existing comment there will be removed. * Used for "load more comments" links. */ private final LinkedList<ThingInfo> mDeferredReplacementList = new LinkedList<ThingInfo>(); /** * Default constructor to do normal comments page */ public DownloadCommentsTask( CommentsListActivity activity, String subreddit, String threadId, RedditSettings settings, HttpClient client ) { this.mActivity = activity; this.mSubreddit = subreddit; this.mThreadId = threadId; this.mSettings = settings; this.mClient = client; this.mProcessCommentsTask = new ProcessCommentsTask(mActivity); } /** * "load more comments" starting at this position * @param moreChildrenId The reddit thing-id of the "more" children comment * @param morePosition Position in local list to insert * @param indentation The indentation level of the child. */ public DownloadCommentsTask prepareLoadMoreComments(String moreChildrenId, int morePosition, int indentation) { mMoreChildrenId = moreChildrenId; mPositionOffset = morePosition; mIndentation = indentation; return this; } public DownloadCommentsTask prepareLoadAndJumpToComment(String commentId, int context) { mJumpToCommentId = commentId; mJumpToCommentContext = context; return this; } // XXX: maxComments is unused for now public Boolean doInBackground(Integer... maxComments) { HttpEntity entity = null; try { StringBuilder sb = new StringBuilder(Constants.REDDIT_BASE_URL); if (mSubreddit != null) { sb.append("/r/").append(mSubreddit.trim()); } sb.append("/comments/") .append(mThreadId) .append("/z/").append(mMoreChildrenId).append("/.json?") .append(mSettings.getCommentsSortByUrl()).append("&"); if (mJumpToCommentContext != 0) sb.append("context=").append(mJumpToCommentContext).append("&"); String url = sb.toString(); InputStream in = null; boolean currentlyUsingCache = false; if (Constants.USE_COMMENTS_CACHE) { try { if (CacheInfo.checkFreshThreadCache(mActivity.getApplicationContext()) && url.equals(CacheInfo.getCachedThreadUrl(mActivity.getApplicationContext()))) { in = mActivity.openFileInput(Constants.FILENAME_THREAD_CACHE); mContentLength = mActivity.getFileStreamPath(Constants.FILENAME_THREAD_CACHE).length(); currentlyUsingCache = true; if (Constants.LOGGING) Log.d(TAG, "Using cached thread JSON, length=" + mContentLength); } } catch (Exception cacheEx) { if (Constants.LOGGING) Log.w(TAG, "skip cache", cacheEx); } } // If we couldn't use the cache, then do HTTP request if (!currentlyUsingCache) { HttpGet request = new HttpGet(url); HttpResponse response = mClient.execute(request); // Read the header to get Content-Length since entity.getContentLength() returns -1 Header contentLengthHeader = response.getFirstHeader("Content-Length"); if (contentLengthHeader != null) { mContentLength = Long.valueOf(contentLengthHeader.getValue()); if (Constants.LOGGING) Log.d(TAG, "Content length: "+mContentLength); } else { mContentLength = -1; if (Constants.LOGGING) Log.d(TAG, "Content length: UNAVAILABLE"); } entity = response.getEntity(); in = entity.getContent(); if (Constants.USE_COMMENTS_CACHE) { in = CacheInfo.writeThenRead(mActivity.getApplicationContext(), in, Constants.FILENAME_THREAD_CACHE); try { CacheInfo.setCachedThreadUrl(mActivity.getApplicationContext(), url); } catch (IOException e) { if (Constants.LOGGING) Log.e(TAG, "error on setCachedThreadId", e); } } } // setup a special InputStream to report progress ProgressInputStream pin = new ProgressInputStream(in, mContentLength); pin.addPropertyChangeListener(this); parseCommentsJSON(pin); if (Constants.LOGGING) Log.d(TAG, "parseCommentsJSON completed"); pin.close(); in.close(); return true; } catch (Exception e) { if (Constants.LOGGING) Log.e(TAG, "DownloadCommentsTask", e); } finally { if (entity != null) { try { entity.consumeContent(); } catch (Exception e2) { if (Constants.LOGGING) Log.e(TAG, "entity.consumeContent()", e2); } } } return false; } private void replaceCommentsAtPositionUI(final Collection<ThingInfo> comments, final int position) { mActivity.mCommentsList.remove(position); mActivity.mCommentsList.addAll(position, comments); mActivity.mCommentsAdapter.notifyDataSetChanged(); } /** * defer insertion of comment for adding at end of entire comments list */ private void deferCommentAppend(ThingInfo comment) { mDeferredAppendList.add(comment); } /** * defer insertion of comment for "more" case */ private void deferCommentReplacement(ThingInfo comment) { mDeferredReplacementList.add(comment); } /** * tell if inserting entire thread, versus loading "more comments" */ private boolean isInsertingEntireThread() { return mPositionOffset == 0; } private void disableLoadingScreenKeepProgress() { mActivity.runOnUiThread(new Runnable() { @Override public void run() { mActivity.resetUI(mActivity.mCommentsAdapter); } }); } private void parseCommentsJSON( InputStream in ) throws IOException, JsonParseException { int insertedCommentIndex; String genericListingError = "Not a comments listing"; try { Listing[] listings = mObjectMapper.readValue(in, Listing[].class); // listings[0] is a thread Listing for the OP. // process same as a thread listing more or less Assert.assertEquals(Constants.JSON_LISTING, listings[0].getKind(), genericListingError); // Save modhash, ignore "after" and "before" which are meaningless in this context (and probably null) ListingData threadListingData = listings[0].getData(); if (StringUtils.isEmpty(threadListingData.getModhash())) mSettings.setModhash(null); else mSettings.setModhash(threadListingData.getModhash()); if (Constants.LOGGING) Log.d(TAG, "Successfully got OP listing[0]: modhash "+mSettings.getModhash()); ThingListing threadThingListing = threadListingData.getChildren()[0]; Assert.assertEquals(Constants.THREAD_KIND, threadThingListing.getKind(), genericListingError); if (isInsertingEntireThread()) { parseOP(threadThingListing.getData()); insertedCommentIndex = 0; // we just inserted the OP into position 0 // at this point we've started displaying comments, so disable the loading screen disableLoadingScreenKeepProgress(); } else { insertedCommentIndex = mPositionOffset - 1; // -1 because we +1 for the first comment } // listings[1] is a comment Listing for the comments // Go through the children and get the ThingInfos ListingData commentListingData = listings[1].getData(); for (ThingListing commentThingListing : commentListingData.getChildren()) { // insert the comment and its replies, prefix traversal order insertedCommentIndex = insertNestedComment(commentThingListing, 0, insertedCommentIndex + 1); } mProcessCommentsTask.mergeLowPriorityListToMainList(); } catch (Exception ex) { if (Constants.LOGGING) Log.e(TAG, "parseCommentsJSON", ex); } } private void parseOP(final ThingInfo data) { data.setIndent(0); data.setClicked(Common.isClicked(mActivity, data.getUrl())); mActivity.runOnUiThread(new Runnable() { @Override public void run() { mActivity.mCommentsList.add(0, data); } }); if (data.isIs_self() && data.getSelftext_html() != null) { // HTML to Spanned String unescapedHtmlSelftext = Html.fromHtml(data.getSelftext_html()).toString(); Spanned selftext = Html.fromHtml(Util.convertHtmlTags(unescapedHtmlSelftext)); // remove last 2 newline characters if (selftext.length() > 2) data.setSpannedSelftext(selftext.subSequence(0, selftext.length()-2)); else data.setSpannedSelftext(""); // Get URLs from markdown markdown.getURLs(data.getSelftext(), data.getUrls()); } // We might not have a title if we've intercepted a plain link to a thread. mThreadTitle = data.getTitle(); mActivity.setThreadTitle(mThreadTitle); mSubreddit = data.getSubreddit(); mThreadId = data.getId(); mOpThingInfo = data; } /** * Recursive method to insert comment tree into the mCommentsList, * with proper list order and indentation */ int insertNestedComment(ThingListing commentThingListing, int indentLevel, int insertedCommentIndex) { ThingInfo ci = commentThingListing.getData(); // Add comment to deferred append/replace list if (isInsertingEntireThread()) deferCommentAppend(ci); else deferCommentReplacement(ci); // Keep track of jump target if (isHasJumpTarget()) { if (!isFoundJumpTargetComment() && mJumpToCommentId.equals(ci.getId())) processJumpTarget(ci, insertedCommentIndex); } if (isHasJumpTarget()) { // if we have found the jump target, then we did the messy stuff already. just append to main processing list. if (isFoundJumpTargetComment()) { mProcessCommentsTask.addDeferred(new DeferredCommentProcessing(ci, insertedCommentIndex)); } // try to handle the context search, if we want context else if (mJumpToCommentContext > 0) { // any comment could be in the context; we don't know yet. so append to the high-priority "context" list mProcessCommentsTask.addDeferredHighPriority(new DeferredCommentProcessing(ci, insertedCommentIndex)); // we push overflow onto the low priority list, since overflow will end up above the jump target, off the top of the screen. // TODO don't use LinkedList.size() mProcessCommentsTask.moveHighPriorityOverflowToLowPriority(mJumpToCommentContext); } // if no context search, then push comments to low priority list until we find the jump target comment else { mProcessCommentsTask.addDeferredLowPriority(new DeferredCommentProcessing(ci, insertedCommentIndex)); } } // if there is no jump target, there's just a single deferred-processing list to worry about. else { mProcessCommentsTask.addDeferred(new DeferredCommentProcessing(ci, insertedCommentIndex)); } // Formatting that applies to all items, both real comments and "more" entries ci.setIndent(mIndentation + indentLevel); // Handle "more" entry if (Constants.MORE_KIND.equals(commentThingListing.getKind())) { ci.setLoadMoreCommentsPlaceholder(true); if (Constants.LOGGING) Log.v(TAG, "new more position at " + (insertedCommentIndex)); return insertedCommentIndex; } // Regular comment // Skip things that are not comments, which shouldn't happen if (!Constants.COMMENT_KIND.equals(commentThingListing.getKind())) { if (Constants.LOGGING) Log.e(TAG, "comment whose kind is \""+commentThingListing.getKind()+"\" (expected "+Constants.COMMENT_KIND+")"); return insertedCommentIndex; } // handle the replies Listing repliesListing = ci.getReplies(); if (repliesListing == null) return insertedCommentIndex; ListingData repliesListingData = repliesListing.getData(); if (repliesListingData == null) return insertedCommentIndex; ThingListing[] replyThingListings = repliesListingData.getChildren(); if (replyThingListings == null) return insertedCommentIndex; for (ThingListing replyThingListing : replyThingListings) { insertedCommentIndex = insertNestedComment(replyThingListing, indentLevel + 1, insertedCommentIndex + 1); } return insertedCommentIndex; } private boolean isHasJumpTarget() { return ! StringUtils.isEmpty(mJumpToCommentId); } private boolean isFoundJumpTargetComment() { return mJumpToCommentFoundIndex != -1; } private void processJumpTarget(ThingInfo comment, int commentIndex) { mJumpToCommentFoundIndex = (commentIndex - mJumpToCommentContext) > 0 ? (commentIndex - mJumpToCommentContext) : 0; mProcessCommentsTask.mergeHighPriorityListToMainList(); } /** * Call from UI Thread */ private void insertCommentsUI() { mActivity.mCommentsList.addAll(mDeferredAppendList); mActivity.mCommentsAdapter.notifyDataSetChanged(); } /** * Process the slow steps and refresh each new comment */ private void processDeferredComments() { mProcessCommentsTask.execute(); } private void showOPThumbnail() { if (mOpThingInfo != null) { synchronized (mCurrentShowThumbnailsTaskLock) { if (mCurrentShowThumbnailsTask != null) mCurrentShowThumbnailsTask.cancel(true); mCurrentShowThumbnailsTask = new ShowThumbnailsTask(mActivity, mClient, null); } mCurrentShowThumbnailsTask.execute(new ThumbnailLoadAction(mOpThingInfo, null, 0)); } } void cleanupDeferred() { mDeferredAppendList.clear(); mDeferredReplacementList.clear(); } @Override public void onPreExecute() { if (mThreadId == null) { if (Constants.LOGGING) Log.e(TAG, "mSettings.threadId == null"); this.cancel(true); return; } synchronized (mCurrentDownloadCommentsTaskLock) { if (mCurrentDownloadCommentsTask != null) { this.cancel(true); return; } mCurrentDownloadCommentsTask = this; } if (isInsertingEntireThread()) { if (mActivity.mCommentsAdapter != null) mActivity.mCommentsAdapter.clear(); else mActivity.resetUI(null); // Do loading screen when loading new thread; otherwise when "loading more comments" don't show it mActivity.enableLoadingScreen(); } if (mContentLength == -1) mActivity.getWindow().setFeatureInt(Window.FEATURE_PROGRESS, Window.PROGRESS_INDETERMINATE_ON); if (mThreadTitle != null) mActivity.setTitle(mThreadTitle + " : " + mSubreddit); } @Override public void onPostExecute(Boolean success) { if (isInsertingEntireThread()) { insertCommentsUI(); if (isFoundJumpTargetComment()) mActivity.getListView().setSelection(mJumpToCommentFoundIndex); } else if (!mDeferredReplacementList.isEmpty()) { replaceCommentsAtPositionUI(mDeferredReplacementList, mPositionOffset); } // have to wait till onPostExecute to do this, to ensure they've been inserted by UI thread processDeferredComments(); if (Common.shouldLoadThumbnails(mActivity, mSettings)) showOPThumbnail(); // label the OP's comments with [S] mActivity.markSubmitterComments(); if (mContentLength == -1) mActivity.getWindow().setFeatureInt(Window.FEATURE_PROGRESS, Window.PROGRESS_INDETERMINATE_OFF); else mActivity.getWindow().setFeatureInt(Window.FEATURE_PROGRESS, Window.PROGRESS_END); if (success) { // We should clear any replies the user was composing. mActivity.setShouldClearReply(true); // Set title in android titlebar if (mThreadTitle != null) mActivity.setTitle(mThreadTitle + " : " + mSubreddit); } else { if (!isCancelled()) { Common.showErrorToast("Error downloading comments. Please try again.", Toast.LENGTH_LONG, mActivity); mActivity.resetUI(null); } } synchronized (mCurrentDownloadCommentsTaskLock) { mCurrentDownloadCommentsTask = null; } } @Override public void onProgressUpdate(Long... progress) { if (mContentLength == -1) mActivity.getWindow().setFeatureInt(Window.FEATURE_PROGRESS, Window.PROGRESS_INDETERMINATE_ON); else mActivity.getWindow().setFeatureInt(Window.FEATURE_PROGRESS, progress[0].intValue() * (Window.PROGRESS_END-1) / (int) mContentLength); } @Override public void propertyChange(PropertyChangeEvent event) { publishProgress((Long) event.getNewValue()); } }