diff options
Diffstat (limited to 'WordPress/src/main/java/org/wordpress/android/ui/reader/actions')
5 files changed, 1275 insertions, 0 deletions
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderActions.java new file mode 100644 index 000000000..5dd01edef --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderActions.java @@ -0,0 +1,90 @@ +package org.wordpress.android.ui.reader.actions; + +import org.wordpress.android.models.ReaderBlog; +import org.wordpress.android.models.ReaderComment; + +/** + * classes in this package serve as a middleman between local data and server data - used by + * reader activities/fragments/adapters that wish to perform actions on posts, blogs & topics, + * or wish to get the latest data from the server. + * + * methods in this package which change state (like, follow, etc.) are generally optimistic + * and work like this: + * + * 1. caller asks method to send a network request which changes state + * 2. method changes state in local data and returns to caller *before* network request completes + * 3. caller can access local state change without waiting for the network request + * 4. if the network request fails, the method restores the previous state of the local data + * 5. if caller passes a listener, it can be alerted to the actual success/failure of the request + * + * note that all methods MUST be called from the UI thread in order to guarantee that listeners + * are alerted on the UI thread + */ +public class ReaderActions { + + private ReaderActions() { + throw new AssertionError(); + } + + /* + * listener when a specific action is performed (liking a post, etc.) + */ + public interface ActionListener { + void onActionResult(boolean succeeded); + } + + /* + * helper routine for telling an action listener the call succeeded or failed w/o having to null check + */ + public static void callActionListener(ActionListener actionListener, boolean succeeded) { + if (actionListener != null) { + actionListener.onActionResult(succeeded); + } + } + + /* + * listener when the failure status code is required + */ + public interface OnRequestListener { + void onSuccess(); + void onFailure(int statusCode); + } + + /* + * listener when submitting a comment + */ + public interface CommentActionListener { + void onActionResult(boolean succeeded, ReaderComment newComment); + } + + /* + * result when updating data (getting latest posts or comments for a post, etc.) + */ + public enum UpdateResult { + HAS_NEW, // new posts/comments/etc. have been retrieved + CHANGED, // no new posts/comments, but existing ones have changed + UNCHANGED, // no new or changed posts/comments + FAILED; // request failed + public boolean isNewOrChanged() { + return (this == HAS_NEW || this == CHANGED); + } + } + public interface UpdateResultListener { + void onUpdateResult(UpdateResult result); + } + + /* + * used by adapters to notify when more data should be loaded + */ + public interface DataRequestedListener { + void onRequestData(); + } + + /* + * used by blog preview when requesting latest info about a blog + */ + public interface UpdateBlogInfoListener { + void onResult(ReaderBlog blogInfo); + } + +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderBlogActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderBlogActions.java new file mode 100644 index 000000000..6398b1e07 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderBlogActions.java @@ -0,0 +1,477 @@ +package org.wordpress.android.ui.reader.actions; + +import android.text.TextUtils; + +import com.android.volley.Request; +import com.android.volley.Response; +import com.android.volley.VolleyError; +import com.android.volley.toolbox.StringRequest; +import com.wordpress.rest.RestRequest; + +import org.json.JSONObject; +import org.wordpress.android.WordPress; +import org.wordpress.android.analytics.AnalyticsTracker; +import org.wordpress.android.datasets.ReaderBlogTable; +import org.wordpress.android.datasets.ReaderPostTable; +import org.wordpress.android.models.ReaderBlog; +import org.wordpress.android.models.ReaderPost; +import org.wordpress.android.models.ReaderPostList; +import org.wordpress.android.ui.reader.actions.ReaderActions.ActionListener; +import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateBlogInfoListener; +import org.wordpress.android.util.AnalyticsUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.UrlUtils; +import org.wordpress.android.util.VolleyUtils; + +import java.net.HttpURLConnection; + +public class ReaderBlogActions { + + public static class BlockedBlogResult { + public long blogId; + public ReaderPostList deletedPosts; + public boolean wasFollowing; + } + + private static String jsonToString(JSONObject json) { + return (json != null ? json.toString() : ""); + } + + public static boolean followBlogById(final long blogId, + final boolean isAskingToFollow, + final ActionListener actionListener) { + if (blogId == 0) { + if (actionListener != null) { + actionListener.onActionResult(false); + } + return false; + } + + ReaderBlogTable.setIsFollowedBlogId(blogId, isAskingToFollow); + ReaderPostTable.setFollowStatusForPostsInBlog(blogId, isAskingToFollow); + + if (isAskingToFollow) { + AnalyticsUtils.trackWithBlogDetails(AnalyticsTracker.Stat.READER_BLOG_FOLLOWED, blogId); + } else { + AnalyticsUtils.trackWithBlogDetails(AnalyticsTracker.Stat.READER_BLOG_UNFOLLOWED, blogId); + } + + final String actionName = (isAskingToFollow ? "follow" : "unfollow"); + final String path = "sites/" + blogId + "/follows/" + (isAskingToFollow ? "new" : "mine/delete"); + + com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + boolean success = isFollowActionSuccessful(jsonObject, isAskingToFollow); + if (success) { + AppLog.d(T.READER, "blog " + actionName + " succeeded"); + } else { + AppLog.w(T.READER, "blog " + actionName + " failed - " + jsonToString(jsonObject) + " - " + path); + localRevertFollowBlogId(blogId, isAskingToFollow); + } + if (actionListener != null) { + actionListener.onActionResult(success); + } + } + }; + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + AppLog.w(T.READER, "blog " + actionName + " failed with error"); + AppLog.e(T.READER, volleyError); + localRevertFollowBlogId(blogId, isAskingToFollow); + if (actionListener != null) { + actionListener.onActionResult(false); + } + } + }; + WordPress.getRestClientUtilsV1_1().post(path, listener, errorListener); + + return true; + } + + public static boolean followFeedById(final long feedId, + final boolean isAskingToFollow, + final ActionListener actionListener) { + ReaderBlog blogInfo = ReaderBlogTable.getFeedInfo(feedId); + if (blogInfo != null) { + return internalFollowFeed(blogInfo.feedId, blogInfo.getFeedUrl(), isAskingToFollow, actionListener); + } + + updateFeedInfo(feedId, null, new UpdateBlogInfoListener() { + @Override + public void onResult(ReaderBlog blogInfo) { + if (blogInfo != null) { + internalFollowFeed( + blogInfo.feedId, + blogInfo.getFeedUrl(), + isAskingToFollow, + actionListener); + } else if (actionListener != null) { + actionListener.onActionResult(false); + } + } + }); + + return true; + } + + public static void followFeedByUrl(final String feedUrl, + final ActionListener actionListener) { + if (TextUtils.isEmpty(feedUrl)) { + ReaderActions.callActionListener(actionListener, false); + return; + } + + // use existing blog info if we can + ReaderBlog blogInfo = ReaderBlogTable.getFeedInfo(ReaderBlogTable.getFeedIdFromUrl(feedUrl)); + if (blogInfo != null) { + internalFollowFeed(blogInfo.feedId, blogInfo.getFeedUrl(), true, actionListener); + return; + } + + // otherwise, look it up via the endpoint + updateFeedInfo(0, feedUrl, new UpdateBlogInfoListener() { + @Override + public void onResult(ReaderBlog blogInfo) { + // note we attempt to follow even when the look up fails (blogInfo = null) because that + // endpoint doesn't perform feed discovery, whereas the endpoint to follow a feed does + long feedIdToFollow = blogInfo != null ? blogInfo.feedId : 0; + String feedUrlToFollow = (blogInfo != null && blogInfo.hasFeedUrl()) ? blogInfo.getFeedUrl() : feedUrl; + internalFollowFeed( + feedIdToFollow, + feedUrlToFollow, + true, + actionListener); + } + }); + } + + private static boolean internalFollowFeed( + final long feedId, + final String feedUrl, + final boolean isAskingToFollow, + final ActionListener actionListener) + { + // feedUrl is required + if (TextUtils.isEmpty(feedUrl)) { + if (actionListener != null) { + actionListener.onActionResult(false); + } + return false; + } + + if (feedId != 0) { + ReaderBlogTable.setIsFollowedFeedId(feedId, isAskingToFollow); + ReaderPostTable.setFollowStatusForPostsInFeed(feedId, isAskingToFollow); + } + + if (isAskingToFollow) { + AnalyticsTracker.track(AnalyticsTracker.Stat.READER_BLOG_FOLLOWED); + } else { + AnalyticsTracker.track(AnalyticsTracker.Stat.READER_BLOG_UNFOLLOWED); + } + + final String actionName = (isAskingToFollow ? "follow" : "unfollow"); + final String path = "read/following/mine/" + + (isAskingToFollow ? "new" : "delete") + + "?url=" + UrlUtils.urlEncode(feedUrl); + + com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + boolean success = isFollowActionSuccessful(jsonObject, isAskingToFollow); + if (success) { + AppLog.d(T.READER, "feed " + actionName + " succeeded"); + } else { + AppLog.w(T.READER, "feed " + actionName + " failed - " + jsonToString(jsonObject) + " - " + path); + localRevertFollowFeedId(feedId, isAskingToFollow); + } + if (actionListener != null) { + actionListener.onActionResult(success); + } + } + }; + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + AppLog.w(T.READER, "feed " + actionName + " failed with error"); + AppLog.e(T.READER, volleyError); + localRevertFollowFeedId(feedId, isAskingToFollow); + if (actionListener != null) { + actionListener.onActionResult(false); + } + } + }; + WordPress.getRestClientUtilsV1_1().post(path, listener, errorListener); + + return true; + } + + /* + * helper routine when following a blog from a post view + */ + public static boolean followBlogForPost(ReaderPost post, + boolean isAskingToFollow, + ActionListener actionListener) { + if (post == null) { + AppLog.w(T.READER, "follow action performed with null post"); + if (actionListener != null) { + actionListener.onActionResult(false); + } + return false; + } + if (post.feedId != 0) { + return followFeedById(post.feedId, isAskingToFollow, actionListener); + } else { + return followBlogById(post.blogId, isAskingToFollow, actionListener); + } + } + + /* + * called when a follow/unfollow fails, restores local data to previous state + */ + private static void localRevertFollowBlogId(long blogId, boolean isAskingToFollow) { + ReaderBlogTable.setIsFollowedBlogId(blogId, !isAskingToFollow); + ReaderPostTable.setFollowStatusForPostsInBlog(blogId, !isAskingToFollow); + } + private static void localRevertFollowFeedId(long feedId, boolean isAskingToFollow) { + ReaderBlogTable.setIsFollowedFeedId(feedId, !isAskingToFollow); + ReaderPostTable.setFollowStatusForPostsInFeed(feedId, !isAskingToFollow); + } + + /* + * returns whether a follow/unfollow was successful based on the response to: + * read/follows/new + * read/follows/delete + * site/$site/follows/new + * site/$site/follows/mine/delete + */ + private static boolean isFollowActionSuccessful(JSONObject json, boolean isAskingToFollow) { + if (json == null) { + return false; + } + + boolean isSubscribed; + if (json.has("subscribed")) { + // read/follows/ + isSubscribed = json.optBoolean("subscribed", false); + } else { + // site/$site/follows/ + isSubscribed = json.has("is_following") && json.optBoolean("is_following", false); + } + return (isSubscribed == isAskingToFollow); + } + + /* + * request info about a specific blog + */ + public static void updateBlogInfo(long blogId, + final String blogUrl, + final UpdateBlogInfoListener infoListener) { + // must pass either a valid id or url + final boolean hasBlogId = (blogId != 0); + final boolean hasBlogUrl = !TextUtils.isEmpty(blogUrl); + if (!hasBlogId && !hasBlogUrl) { + AppLog.w(T.READER, "cannot get blog info without either id or url"); + if (infoListener != null) { + infoListener.onResult(null); + } + return; + } + + RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + handleUpdateBlogInfoResponse(jsonObject, infoListener); + } + }; + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + // authentication error may indicate that API access has been disabled for this blog + int statusCode = VolleyUtils.statusCodeFromVolleyError(volleyError); + boolean isAuthErr = (statusCode == HttpURLConnection.HTTP_FORBIDDEN); + // if we failed to get the blog info using the id and this isn't an authentication + // error, try again using just the domain + if (!isAuthErr && hasBlogId && hasBlogUrl) { + AppLog.w(T.READER, "failed to get blog info by id, retrying with url"); + updateBlogInfo(0, blogUrl, infoListener); + } else { + AppLog.e(T.READER, volleyError); + if (infoListener != null) { + infoListener.onResult(null); + } + } + } + }; + + if (hasBlogId) { + WordPress.getRestClientUtilsV1_1().get("read/sites/" + blogId, listener, errorListener); + } else { + WordPress.getRestClientUtilsV1_1().get("read/sites/" + UrlUtils.urlEncode(UrlUtils.getHost(blogUrl)), listener, errorListener); + } + } + public static void updateFeedInfo(long feedId, String feedUrl, final UpdateBlogInfoListener infoListener) { + RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + handleUpdateBlogInfoResponse(jsonObject, infoListener); + } + }; + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + AppLog.e(T.READER, volleyError); + if (infoListener != null) { + infoListener.onResult(null); + } + } + }; + String path; + if (feedId != 0) { + path = "read/feed/" + feedId; + } else { + path = "read/feed/" + UrlUtils.urlEncode(feedUrl); + } + WordPress.getRestClientUtilsV1_1().get(path, listener, errorListener); + } + private static void handleUpdateBlogInfoResponse(JSONObject jsonObject, UpdateBlogInfoListener infoListener) { + if (jsonObject == null) { + if (infoListener != null) { + infoListener.onResult(null); + } + return; + } + + ReaderBlog blogInfo = ReaderBlog.fromJson(jsonObject); + ReaderBlogTable.addOrUpdateBlog(blogInfo); + + if (infoListener != null) { + infoListener.onResult(blogInfo); + } + } + + /* + * tests whether the passed url can be reached - does NOT use authentication, and does not + * account for 404 replacement pages used by ISPs such as Charter + */ + public static void checkUrlReachable(final String blogUrl, + final ReaderActions.OnRequestListener requestListener) { + // listener is required + if (requestListener == null) return; + + Response.Listener<String> listener = new Response.Listener<String>() { + @Override + public void onResponse(String response) { + requestListener.onSuccess(); + } + }; + Response.ErrorListener errorListener = new Response.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + AppLog.e(T.READER, volleyError); + int statusCode; + // check specifically for auth failure class rather than relying on status code + // since a redirect to an unauthorized url may return a 301 rather than a 401 + if (volleyError instanceof com.android.volley.AuthFailureError) { + statusCode = 401; + } else { + statusCode = VolleyUtils.statusCodeFromVolleyError(volleyError); + } + // Volley treats a 301 redirect as a failure here, we should treat it as + // success since it means the blog url is reachable + if (statusCode == 301) { + requestListener.onSuccess(); + } else { + requestListener.onFailure(statusCode); + } + } + }; + + // TODO: this should be a HEAD request, but even though Volley supposedly supports HEAD + // using it results in "java.lang.IllegalStateException: Unknown method type" + StringRequest request = new StringRequest( + Request.Method.GET, + blogUrl, + listener, + errorListener); + WordPress.requestQueue.add(request); + } + + /* + * block a blog - result includes the list of posts that were deleted by the block so they + * can be restored if the user undoes the block + */ + public static BlockedBlogResult blockBlogFromReader(final long blogId, final ActionListener actionListener) { + final BlockedBlogResult blockResult = new BlockedBlogResult(); + blockResult.blogId = blogId; + blockResult.deletedPosts = ReaderPostTable.getPostsInBlog(blogId, 0, false); + blockResult.wasFollowing = ReaderBlogTable.isFollowedBlog(blogId); + + ReaderPostTable.deletePostsInBlog(blogId); + ReaderBlogTable.setIsFollowedBlogId(blogId, false); + + com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + if (actionListener != null) { + actionListener.onActionResult(true); + } + } + }; + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + AppLog.e(T.READER, volleyError); + ReaderPostTable.addOrUpdatePosts(null, blockResult.deletedPosts); + if (blockResult.wasFollowing) { + ReaderBlogTable.setIsFollowedBlogId(blogId, true); + } + if (actionListener != null) { + actionListener.onActionResult(false); + } + } + }; + + AppLog.i(T.READER, "blocking blog " + blogId); + String path = "me/block/sites/" + Long.toString(blogId) + "/new"; + WordPress.getRestClientUtilsV1_1().post(path, listener, errorListener); + + return blockResult; + } + + public static void undoBlockBlogFromReader(final BlockedBlogResult blockResult) { + if (blockResult == null) { + return; + } + if (blockResult.deletedPosts != null) { + ReaderPostTable.addOrUpdatePosts(null, blockResult.deletedPosts); + } + + com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + boolean success = (jsonObject != null && jsonObject.optBoolean("success")); + // re-follow the blog if it was being followed prior to the block + if (success && blockResult.wasFollowing) { + followBlogById(blockResult.blogId, true, null); + } else if (!success) { + AppLog.w(T.READER, "failed to unblock blog " + blockResult.blogId); + } + + } + }; + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + AppLog.e(T.READER, volleyError); + } + }; + + AppLog.i(T.READER, "unblocking blog " + blockResult.blogId); + String path = "me/block/sites/" + Long.toString(blockResult.blogId) + "/delete"; + WordPress.getRestClientUtilsV1_1().post(path, listener, errorListener); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderCommentActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderCommentActions.java new file mode 100644 index 000000000..1674102ba --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderCommentActions.java @@ -0,0 +1,181 @@ +package org.wordpress.android.ui.reader.actions; + +import android.text.TextUtils; + +import com.android.volley.VolleyError; +import com.wordpress.rest.RestRequest; + +import org.json.JSONObject; +import org.wordpress.android.WordPress; +import org.wordpress.android.datasets.ReaderCommentTable; +import org.wordpress.android.datasets.ReaderLikeTable; +import org.wordpress.android.datasets.ReaderUserTable; +import org.wordpress.android.models.ReaderComment; +import org.wordpress.android.models.ReaderPost; +import org.wordpress.android.models.ReaderUser; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.DateTimeUtils; +import org.wordpress.android.util.JSONUtils; +import org.wordpress.android.util.VolleyUtils; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +public class ReaderCommentActions { + /* + * used by post detail to generate a temporary "fake" comment id (see below) + */ + public static long generateFakeCommentId() { + return System.currentTimeMillis(); + } + + /* + * add the passed comment text to the passed post - caller must pass a unique "fake" comment id + * to give the comment that's generated locally + */ + public static ReaderComment submitPostComment(final ReaderPost post, + final long fakeCommentId, + final String commentText, + final long replyToCommentId, + final ReaderActions.CommentActionListener actionListener) { + if (post == null || TextUtils.isEmpty(commentText)) { + return null; + } + + // determine which page this new comment should be assigned to + final int pageNumber; + if (replyToCommentId != 0) { + pageNumber = ReaderCommentTable.getPageNumberForComment(post.blogId, post.postId, replyToCommentId); + } else { + pageNumber = ReaderCommentTable.getLastPageNumberForPost(post.blogId, post.postId); + } + + // create a "fake" comment that's added to the db so it can be shown right away - will be + // replaced with actual comment if it succeeds to be posted, or deleted if comment fails + // to be posted + ReaderComment newComment = new ReaderComment(); + newComment.commentId = fakeCommentId; + newComment.postId = post.postId; + newComment.blogId = post.blogId; + newComment.parentId = replyToCommentId; + newComment.pageNumber = pageNumber; + newComment.setText(commentText); + + Date dtPublished = DateTimeUtils.nowUTC(); + newComment.setPublished(DateTimeUtils.iso8601FromDate(dtPublished)); + newComment.timestamp = dtPublished.getTime(); + + ReaderUser currentUser = ReaderUserTable.getCurrentUser(); + if (currentUser != null) { + newComment.setAuthorAvatar(currentUser.getAvatarUrl()); + newComment.setAuthorName(currentUser.getDisplayName()); + } + + ReaderCommentTable.addOrUpdateComment(newComment); + + // different endpoint depending on whether the new comment is a reply to another comment + final String path; + if (replyToCommentId == 0) { + path = "sites/" + post.blogId + "/posts/" + post.postId + "/replies/new"; + } else { + path = "sites/" + post.blogId + "/comments/" + Long.toString(replyToCommentId) + "/replies/new"; + } + + Map<String, String> params = new HashMap<>(); + params.put("content", commentText); + + RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + ReaderCommentTable.deleteComment(post, fakeCommentId); + AppLog.i(T.READER, "comment succeeded"); + ReaderComment newComment = ReaderComment.fromJson(jsonObject, post.blogId); + newComment.pageNumber = pageNumber; + ReaderCommentTable.addOrUpdateComment(newComment); + if (actionListener != null) { + actionListener.onActionResult(true, newComment); + } + } + }; + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + ReaderCommentTable.deleteComment(post, fakeCommentId); + AppLog.w(T.READER, "comment failed"); + AppLog.e(T.READER, volleyError); + if (actionListener != null) { + actionListener.onActionResult(false, null); + } + } + }; + + AppLog.i(T.READER, "submitting comment"); + WordPress.getRestClientUtilsV1_1().post(path, params, null, listener, errorListener); + + return newComment; + } + + /* + * like or unlike the passed comment + */ + public static boolean performLikeAction(final ReaderComment comment, boolean isAskingToLike) { + if (comment == null) { + return false; + } + + // make sure like status is changing + boolean isCurrentlyLiked = ReaderCommentTable.isCommentLikedByCurrentUser(comment); + if (isCurrentlyLiked == isAskingToLike) { + AppLog.w(T.READER, "comment like unchanged"); + return false; + } + + // update like status and like count in local db + int newNumLikes = (isAskingToLike ? comment.numLikes + 1 : comment.numLikes - 1); + ReaderCommentTable.setLikesForComment(comment, newNumLikes, isAskingToLike); + ReaderLikeTable.setCurrentUserLikesComment(comment, isAskingToLike); + + // sites/$site/comments/$comment_ID/likes/new + final String actionName = isAskingToLike ? "like" : "unlike"; + String path = "sites/" + comment.blogId + "/comments/" + comment.commentId + "/likes/"; + if (isAskingToLike) { + path += "new"; + } else { + path += "mine/delete"; + } + + RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + boolean success = (jsonObject != null && JSONUtils.getBool(jsonObject, "success")); + if (success) { + AppLog.d(T.READER, String.format("comment %s succeeded", actionName)); + } else { + AppLog.w(T.READER, String.format("comment %s failed", actionName)); + ReaderCommentTable.setLikesForComment(comment, comment.numLikes, comment.isLikedByCurrentUser); + ReaderLikeTable.setCurrentUserLikesComment(comment, comment.isLikedByCurrentUser); + } + } + }; + + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + String error = VolleyUtils.errStringFromVolleyError(volleyError); + if (TextUtils.isEmpty(error)) { + AppLog.w(T.READER, String.format("comment %s failed", actionName)); + } else { + AppLog.w(T.READER, String.format("comment %s failed (%s)", actionName, error)); + } + AppLog.e(T.READER, volleyError); + ReaderCommentTable.setLikesForComment(comment, comment.numLikes, comment.isLikedByCurrentUser); + ReaderLikeTable.setCurrentUserLikesComment(comment, comment.isLikedByCurrentUser); + } + }; + + WordPress.getRestClientUtilsV1_1().post(path, listener, errorListener); + return true; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActions.java new file mode 100644 index 000000000..fa772de60 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActions.java @@ -0,0 +1,359 @@ +package org.wordpress.android.ui.reader.actions; + +import android.os.Handler; +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import com.android.volley.AuthFailureError; +import com.android.volley.Request; +import com.android.volley.Response; +import com.android.volley.VolleyError; +import com.android.volley.toolbox.StringRequest; +import com.wordpress.rest.RestRequest; + +import org.json.JSONObject; +import org.wordpress.android.WordPress; +import org.wordpress.android.datasets.ReaderLikeTable; +import org.wordpress.android.datasets.ReaderPostTable; +import org.wordpress.android.datasets.ReaderUserTable; +import org.wordpress.android.models.ReaderPost; +import org.wordpress.android.models.ReaderPostList; +import org.wordpress.android.models.ReaderUserIdList; +import org.wordpress.android.models.ReaderUserList; +import org.wordpress.android.ui.reader.ReaderEvents; +import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateResult; +import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateResultListener; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.JSONUtils; +import org.wordpress.android.util.UrlUtils; +import org.wordpress.android.util.VolleyUtils; + +import java.util.HashMap; +import java.util.Map; +import java.util.Random; + +import de.greenrobot.event.EventBus; + +public class ReaderPostActions { + + private static final String TRACKING_REFERRER = "https://wordpress.com/"; + private static final Random mRandom = new Random(); + + private ReaderPostActions() { + throw new AssertionError(); + } + + /** + * like/unlike the passed post + */ + public static boolean performLikeAction(final ReaderPost post, + final boolean isAskingToLike) { + // do nothing if post's like state is same as passed + boolean isCurrentlyLiked = ReaderPostTable.isPostLikedByCurrentUser(post); + if (isCurrentlyLiked == isAskingToLike) { + AppLog.w(T.READER, "post like unchanged"); + return false; + } + + // update like status and like count in local db + int numCurrentLikes = ReaderPostTable.getNumLikesForPost(post.blogId, post.postId); + int newNumLikes = (isAskingToLike ? numCurrentLikes + 1 : numCurrentLikes - 1); + if (newNumLikes < 0) { + newNumLikes = 0; + } + ReaderPostTable.setLikesForPost(post, newNumLikes, isAskingToLike); + ReaderLikeTable.setCurrentUserLikesPost(post, isAskingToLike); + + final String actionName = isAskingToLike ? "like" : "unlike"; + String path = "sites/" + post.blogId + "/posts/" + post.postId + "/likes/"; + if (isAskingToLike) { + path += "new"; + } else { + path += "mine/delete"; + } + + com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + AppLog.d(T.READER, String.format("post %s succeeded", actionName)); + } + }; + + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + String error = VolleyUtils.errStringFromVolleyError(volleyError); + if (TextUtils.isEmpty(error)) { + AppLog.w(T.READER, String.format("post %s failed", actionName)); + } else { + AppLog.w(T.READER, String.format("post %s failed (%s)", actionName, error)); + } + AppLog.e(T.READER, volleyError); + ReaderPostTable.setLikesForPost(post, post.numLikes, post.isLikedByCurrentUser); + ReaderLikeTable.setCurrentUserLikesPost(post, post.isLikedByCurrentUser); + } + }; + + WordPress.getRestClientUtilsV1_1().post(path, listener, errorListener); + return true; + } + + /* + * get the latest version of this post - note that the post is only considered changed if the + * like/comment count has changed, or if the current user's like/follow status has changed + */ + public static void updatePost(final ReaderPost localPost, + final UpdateResultListener resultListener) { + String path = "read/sites/" + localPost.blogId + "/posts/" + localPost.postId + "/?meta=site,likes"; + + com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + handleUpdatePostResponse(localPost, jsonObject, resultListener); + } + }; + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + AppLog.e(T.READER, volleyError); + if (resultListener != null) { + resultListener.onUpdateResult(UpdateResult.FAILED); + } + } + }; + AppLog.d(T.READER, "updating post"); + WordPress.getRestClientUtilsV1_2().get(path, null, null, listener, errorListener); + } + + private static void handleUpdatePostResponse(final ReaderPost localPost, + final JSONObject jsonObject, + final UpdateResultListener resultListener) { + if (jsonObject == null) { + if (resultListener != null) { + resultListener.onUpdateResult(UpdateResult.FAILED); + } + return; + } + + final Handler handler = new Handler(); + + new Thread() { + @Override + public void run() { + ReaderPost serverPost = ReaderPost.fromJson(jsonObject); + + // TODO: this temporary fix was added 25-Apr-2016 as a workaround for the fact that + // the read/sites/{blogId}/posts/{postId} endpoint doesn't contain the feedId or + // feedItemId of the post. because of this, we need to copy them from the local post + // before calling isSamePost (since the difference in those IDs causes it to return false) + if (serverPost.feedId == 0 && localPost.feedId != 0) { + serverPost.feedId = localPost.feedId; + } + + if (serverPost.feedItemId == 0 && localPost.feedItemId != 0) { + serverPost.feedItemId = localPost.feedItemId; + } + + boolean hasChanges = !serverPost.isSamePost(localPost); + + if (hasChanges) { + AppLog.d(T.READER, "post updated"); + // copy changes over to the local post - this is done instead of simply overwriting + // the local post with the server post because the server post was retrieved using + // the read/sites/$siteId/posts/$postId endpoint which is missing some information + // https://github.com/wordpress-mobile/WordPress-Android/issues/3164 + localPost.numReplies = serverPost.numReplies; + localPost.numLikes = serverPost.numLikes; + localPost.isFollowedByCurrentUser = serverPost.isFollowedByCurrentUser; + localPost.isLikedByCurrentUser = serverPost.isLikedByCurrentUser; + localPost.isCommentsOpen = serverPost.isCommentsOpen; + localPost.setTitle(serverPost.getTitle()); + localPost.setText(serverPost.getText()); + ReaderPostTable.addOrUpdatePost(localPost); + } + + // always update liking users regardless of whether changes were detected - this + // ensures that the liking avatars are immediately available to post detail + if (handlePostLikes(serverPost, jsonObject)) { + hasChanges = true; + } + + if (resultListener != null) { + final UpdateResult result = (hasChanges ? UpdateResult.CHANGED : UpdateResult.UNCHANGED); + handler.post(new Runnable() { + public void run() { + resultListener.onUpdateResult(result); + } + }); + } + } + }.start(); + } + + /* + * updates local liking users based on the "likes" meta section of the post's json - requires + * using the /sites/ endpoint with ?meta=likes - returns true if likes have changed + */ + private static boolean handlePostLikes(final ReaderPost post, JSONObject jsonPost) { + if (post == null || jsonPost == null) { + return false; + } + + JSONObject jsonLikes = JSONUtils.getJSONChild(jsonPost, "meta/data/likes"); + if (jsonLikes == null) { + return false; + } + + ReaderUserList likingUsers = ReaderUserList.fromJsonLikes(jsonLikes); + ReaderUserIdList likingUserIds = likingUsers.getUserIds(); + + ReaderUserIdList existingIds = ReaderLikeTable.getLikesForPost(post); + if (likingUserIds.isSameList(existingIds)) { + return false; + } + + ReaderUserTable.addOrUpdateUsers(likingUsers); + ReaderLikeTable.setLikesForPost(post, likingUserIds); + return true; + } + + /** + * similar to updatePost, but used when post doesn't already exist in local db + **/ + public static void requestPost(final long blogId, + final long postId, + final ReaderActions.OnRequestListener requestListener) { + String path = "read/sites/" + blogId + "/posts/" + postId + "/?meta=site,likes"; + + com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + ReaderPost post = ReaderPost.fromJson(jsonObject); + ReaderPostTable.addOrUpdatePost(post); + handlePostLikes(post, jsonObject); + if (requestListener != null) { + requestListener.onSuccess(); + } + } + }; + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + AppLog.e(T.READER, volleyError); + if (requestListener != null) { + int statusCode = 0; + // first try to get the error code from the JSON response, example: + // {"code":403,"headers":[{"name":"Content-Type","value":"application\/json"}], + // "body":{"error":"unauthorized","message":"User cannot access this private blog."}} + JSONObject jsonObject = VolleyUtils.volleyErrorToJSON(volleyError); + if (jsonObject != null && jsonObject.has("code")) { + statusCode = jsonObject.optInt("code"); + } + if (statusCode == 0) { + statusCode = VolleyUtils.statusCodeFromVolleyError(volleyError); + } + requestListener.onFailure(statusCode); + } + } + }; + AppLog.d(T.READER, "requesting post"); + WordPress.getRestClientUtilsV1_2().get(path, null, null, listener, errorListener); + } + + private static String getTrackingPixelForPost(@NonNull ReaderPost post) { + return "https://pixel.wp.com/g.gif?v=wpcom&reader=1" + + "&blog=" + post.blogId + + "&post=" + post.postId + + "&host=" + UrlUtils.urlEncode(UrlUtils.getHost(post.getBlogUrl())) + + "&ref=" + UrlUtils.urlEncode(TRACKING_REFERRER) + + "&t=" + mRandom.nextInt(); + } + + public static void bumpPageViewForPost(long blogId, long postId) { + bumpPageViewForPost(ReaderPostTable.getPost(blogId, postId, true)); + } + public static void bumpPageViewForPost(ReaderPost post) { + if (post == null) { + return; + } + + // don't bump stats for posts in blogs the current user is an admin of, unless + // this is a private post since we count views for private posts from admins + if (!post.isPrivate && WordPress.wpDB.isCurrentUserAdminOfRemoteBlogId(post.blogId)) { + AppLog.d(T.READER, "skipped bump page view - user is admin"); + return; + } + + Response.Listener<String> listener = new Response.Listener<String>() { + @Override + public void onResponse(String response) { + AppLog.d(T.READER, "bump page view succeeded"); + } + }; + Response.ErrorListener errorListener = new Response.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + AppLog.e(T.READER, volleyError); + AppLog.w(T.READER, "bump page view failed"); + } + }; + + Request request = new StringRequest( + Request.Method.GET, + getTrackingPixelForPost(post), + listener, + errorListener) { + @Override + public Map<String, String> getHeaders() throws AuthFailureError { + // call will fail without correct refer(r)er + Map<String, String> headers = new HashMap<>(); + headers.put("Referer", TRACKING_REFERRER); + return headers; + } + }; + + WordPress.requestQueue.add(request); + } + + /* + * request posts related to the passed one + */ + public static void requestRelatedPosts(final ReaderPost sourcePost) { + if (sourcePost == null) return; + + RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + handleRelatedPostsResponse(sourcePost, jsonObject); + } + }; + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + AppLog.w(T.READER, "updateRelatedPosts failed"); + AppLog.e(T.READER, volleyError); + + } + }; + + String path = "/read/site/" + sourcePost.blogId + "/post/" + sourcePost.postId + "/related"; + WordPress.getRestClientUtilsV1_2().get(path, null, null, listener, errorListener); + } + + private static void handleRelatedPostsResponse(final ReaderPost sourcePost, final JSONObject jsonObject) { + if (jsonObject == null) return; + + new Thread() { + @Override + public void run() { + ReaderPostList relatedPosts = ReaderPostList.fromJson(jsonObject); + if (relatedPosts != null && relatedPosts.size() > 0) { + ReaderPostTable.addOrUpdatePosts(null, relatedPosts); + EventBus.getDefault().post(new ReaderEvents.RelatedPostsUpdated(sourcePost, relatedPosts)); + } + } + }.start(); + + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderTagActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderTagActions.java new file mode 100644 index 000000000..6732c49fa --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderTagActions.java @@ -0,0 +1,168 @@ +package org.wordpress.android.ui.reader.actions; + +import com.android.volley.VolleyError; +import com.wordpress.rest.RestRequest; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.wordpress.android.WordPress; +import org.wordpress.android.datasets.ReaderTagTable; +import org.wordpress.android.models.ReaderTag; +import org.wordpress.android.models.ReaderTagList; +import org.wordpress.android.models.ReaderTagType; +import org.wordpress.android.ui.reader.ReaderConstants; +import org.wordpress.android.ui.reader.utils.ReaderUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.JSONUtils; +import org.wordpress.android.util.VolleyUtils; + +public class ReaderTagActions { + private ReaderTagActions() { + throw new AssertionError(); + } + + public static boolean deleteTag(final ReaderTag tag, + final ReaderActions.ActionListener actionListener) { + if (tag == null) { + ReaderActions.callActionListener(actionListener, false); + return false; + } + + final String tagNameForApi = ReaderUtils.sanitizeWithDashes(tag.getTagSlug()); + final String path = "read/tags/" + tagNameForApi + "/mine/delete"; + + com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + AppLog.i(T.READER, "delete tag succeeded"); + ReaderActions.callActionListener(actionListener, true); + } + }; + + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + // treat it as a success if the error says the user isn't following the deleted tag + String error = VolleyUtils.errStringFromVolleyError(volleyError); + if (error.equals("not_subscribed")) { + AppLog.w(T.READER, "delete tag succeeded with error " + error); + ReaderActions.callActionListener(actionListener, true); + return; + } + + AppLog.w(T.READER, " delete tag failed"); + AppLog.e(T.READER, volleyError); + + // add back original tag + ReaderTagTable.addOrUpdateTag(tag); + + ReaderActions.callActionListener(actionListener, false); + } + }; + + ReaderTagTable.deleteTag(tag); + WordPress.getRestClientUtilsV1_1().post(path, listener, errorListener); + + return true; + } + + public static boolean addTag(final ReaderTag tag, + final ReaderActions.ActionListener actionListener) { + if (tag == null) { + ReaderActions.callActionListener(actionListener, false); + return false; + } + + final String tagNameForApi = ReaderUtils.sanitizeWithDashes(tag.getTagSlug()); + final String path = "read/tags/" + tagNameForApi + "/mine/new"; + String endpoint = "/read/tags/" + tagNameForApi + "/posts"; + + ReaderTag newTag = new ReaderTag( + tag.getTagSlug(), + tag.getTagDisplayName(), + tag.getTagTitle(), + endpoint, + ReaderTagType.FOLLOWED); + + com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + AppLog.i(T.READER, "add tag succeeded"); + // the response will contain the list of the user's followed tags + ReaderTagList tags = parseFollowedTags(jsonObject); + ReaderTagTable.replaceFollowedTags(tags); + ReaderActions.callActionListener(actionListener, true); + } + }; + + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + // treat is as a success if we're adding a tag and the error says the user is + // already following it + String error = VolleyUtils.errStringFromVolleyError(volleyError); + if (error.equals("already_subscribed")) { + AppLog.w(T.READER, "add tag succeeded with error " + error); + ReaderActions.callActionListener(actionListener, true); + return; + } + + AppLog.w(T.READER, "add tag failed"); + AppLog.e(T.READER, volleyError); + + // revert on failure + ReaderTagTable.deleteTag(tag); + + ReaderActions.callActionListener(actionListener, false); + } + }; + + ReaderTagTable.addOrUpdateTag(newTag); + WordPress.getRestClientUtilsV1_1().post(path, listener, errorListener); + + return true; + } + + /* + * return the user's followed tags from the response to read/tags/{tag}/mine/new + */ + /* + { + "added_tag": "84776", + "subscribed": true, + "tags": [ + { + "display_name": "fitness", + "ID": "5189", + "slug": "fitness", + "title": "Fitness", + "URL": "https://public-api.wordpress.com/rest/v1.1/read/tags/fitness/posts" + }, + ... + } + */ + private static ReaderTagList parseFollowedTags(JSONObject jsonObject) { + if (jsonObject == null) { + return null; + } + + JSONArray jsonTags = jsonObject.optJSONArray(ReaderConstants.JSON_TAG_TAGS_ARRAY); + if (jsonTags == null || jsonTags.length() == 0) { + return null; + } + + ReaderTagList tags = new ReaderTagList(); + for (int i=0; i < jsonTags.length(); i++) { + JSONObject jsonThisTag = jsonTags.optJSONObject(i); + String tagTitle = JSONUtils.getStringDecoded(jsonThisTag, ReaderConstants.JSON_TAG_TITLE); + String tagDisplayName = JSONUtils.getStringDecoded(jsonThisTag, ReaderConstants.JSON_TAG_DISPLAY_NAME); + String tagSlug = JSONUtils.getStringDecoded(jsonThisTag, ReaderConstants.JSON_TAG_SLUG); + String endpoint = JSONUtils.getString(jsonThisTag, ReaderConstants.JSON_TAG_URL); + tags.add(new ReaderTag(tagSlug, tagDisplayName, tagTitle, endpoint, ReaderTagType.FOLLOWED)); + } + + return tags; + } + +} |