diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/NotificationsTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/NotificationsTable.java index d7113e563bb6..b125497096dd 100644 --- a/WordPress/src/main/java/org/wordpress/android/datasets/NotificationsTable.java +++ b/WordPress/src/main/java/org/wordpress/android/datasets/NotificationsTable.java @@ -77,7 +77,7 @@ public static ArrayList getLatestNotes(int limit) { } private static boolean putNote(Note note, boolean checkBeforeInsert) { - String rawNote = prepareNote(note.getId(), note.getJSON().toString()); + String rawNote = prepareNote(note.getId(), note.getJson().toString()); ContentValues values = new ContentValues(); values.put("type", note.getRawType()); diff --git a/WordPress/src/main/java/org/wordpress/android/models/Note.java b/WordPress/src/main/java/org/wordpress/android/models/Note.java deleted file mode 100644 index c7e106d8467c..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/models/Note.java +++ /dev/null @@ -1,664 +0,0 @@ -/** - * Note represents a single WordPress.com notification - */ -package org.wordpress.android.models; - -import android.text.Spannable; -import android.text.TextUtils; -import android.util.Base64; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.wordpress.android.fluxc.model.CommentModel; -import org.wordpress.android.fluxc.model.CommentStatus; -import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper; -import org.wordpress.android.util.AppLog; -import org.wordpress.android.util.AppLog.T; -import org.wordpress.android.util.DateTimeUtils; -import org.wordpress.android.util.DateUtils; -import org.wordpress.android.util.JSONUtils; -import org.wordpress.android.util.StringUtils; - -import java.io.UnsupportedEncodingException; -import java.util.ArrayList; -import java.util.Date; -import java.util.EnumSet; -import java.util.List; -import java.util.zip.DataFormatException; -import java.util.zip.Inflater; - -public class Note { - private static final String TAG = "NoteModel"; - - // Maximum character length for a comment preview - private static final int MAX_COMMENT_PREVIEW_LENGTH = 200; - - // Note types - public static final String NOTE_FOLLOW_TYPE = "follow"; - public static final String NOTE_LIKE_TYPE = "like"; - public static final String NOTE_COMMENT_TYPE = "comment"; - public static final String NOTE_MATCHER_TYPE = "automattcher"; - public static final String NOTE_COMMENT_LIKE_TYPE = "comment_like"; - public static final String NOTE_REBLOG_TYPE = "reblog"; - public static final String NOTE_NEW_POST_TYPE = "new_post"; - public static final String NOTE_VIEW_MILESTONE = "view_milestone"; - public static final String NOTE_UNKNOWN_TYPE = "unknown"; - - // JSON action keys - private static final String ACTION_KEY_REPLY = "replyto-comment"; - private static final String ACTION_KEY_APPROVE = "approve-comment"; - private static final String ACTION_KEY_SPAM = "spam-comment"; - private static final String ACTION_KEY_LIKE_COMMENT = "like-comment"; - private static final String ACTION_KEY_LIKE_POST = "like-post"; - - private JSONObject mActions; - private JSONObject mNoteJSON; - private final String mKey; - - private final Object mSyncLock = new Object(); - private String mLocalStatus; - - public enum EnabledActions { - ACTION_REPLY, - ACTION_APPROVE, - ACTION_UNAPPROVE, - ACTION_SPAM, - ACTION_LIKE_COMMENT, - ACTION_LIKE_POST, - } - - public enum NoteTimeGroup { - GROUP_TODAY, - GROUP_YESTERDAY, - GROUP_OLDER_TWO_DAYS, - GROUP_OLDER_WEEK, - GROUP_OLDER_MONTH - } - - public Note(String key, JSONObject noteJSON) { - mKey = key; - mNoteJSON = noteJSON; - } - - public Note(JSONObject noteJSON) { - mNoteJSON = noteJSON; - mKey = mNoteJSON.optString("id", ""); - } - - public JSONObject getJSON() { - return mNoteJSON != null ? mNoteJSON : new JSONObject(); - } - - public String getId() { - return mKey; - } - - @NonNull - public String getRawType() { - return queryJSON("type", NOTE_UNKNOWN_TYPE); - } - - @NonNull - private Boolean isTypeRaw(@NonNull String rawType) { - return getRawType().equals(rawType); - } - - @NonNull - public Boolean isCommentType() { - synchronized (mSyncLock) { - return (isAutomattcherType() && JSONUtils.queryJSON(mNoteJSON, "meta.ids.comment", -1) != -1) - || isTypeRaw(NOTE_COMMENT_TYPE); - } - } - - @NonNull - public Boolean isAutomattcherType() { - return isTypeRaw(NOTE_MATCHER_TYPE); - } - - @NonNull - public Boolean isNewPostType() { - return isTypeRaw(NOTE_NEW_POST_TYPE); - } - - @NonNull - public Boolean isFollowType() { - return isTypeRaw(NOTE_FOLLOW_TYPE); - } - - @NonNull - public Boolean isLikeType() { - return isPostLikeType() || isCommentLikeType(); - } - - @NonNull - public Boolean isPostLikeType() { - return isTypeRaw(NOTE_LIKE_TYPE); - } - - @NonNull - public Boolean isCommentLikeType() { - return isTypeRaw(NOTE_COMMENT_LIKE_TYPE); - } - - @NonNull - public Boolean isReblogType() { - return isTypeRaw(NOTE_REBLOG_TYPE); - } - - @NonNull - public Boolean isViewMilestoneType() { - return isTypeRaw(NOTE_VIEW_MILESTONE); - } - - @NonNull - public Boolean isCommentReplyType() { - return isCommentType() && getParentCommentId() > 0; - } - - // Returns true if the user has replied to this comment note - @NonNull - public Boolean isCommentWithUserReply() { - return isCommentType() && !TextUtils.isEmpty(getCommentSubjectNoticon()); - } - - @NonNull - public Boolean isUserList() { - return isLikeType() || isFollowType() || isReblogType(); - } - - /* - * does user have permission to moderate/reply/spam this comment? - */ - public boolean canModerate() { - EnumSet enabledActions = getEnabledCommentActions(); - return enabledActions != null && (enabledActions.contains(EnabledActions.ACTION_APPROVE) || enabledActions - .contains(EnabledActions.ACTION_UNAPPROVE)); - } - - public boolean canMarkAsSpam() { - EnumSet enabledActions = getEnabledCommentActions(); - return (enabledActions != null && enabledActions.contains(EnabledActions.ACTION_SPAM)); - } - - public boolean canReply() { - EnumSet enabledActions = getEnabledCommentActions(); - return (enabledActions != null && enabledActions.contains(EnabledActions.ACTION_REPLY)); - } - - public boolean canTrash() { - return canModerate(); - } - - public boolean canLikeComment() { - EnumSet enabledActions = getEnabledCommentActions(); - return (enabledActions != null && enabledActions.contains(EnabledActions.ACTION_LIKE_COMMENT)); - } - - public boolean canLikePost() { - EnumSet enabledActions = getEnabledPostActions(); - return (enabledActions != null && enabledActions.contains(EnabledActions.ACTION_LIKE_POST)); - } - - public String getLocalStatus() { - return StringUtils.notNullStr(mLocalStatus); - } - - public void setLocalStatus(String localStatus) { - mLocalStatus = localStatus; - } - - public JSONObject getSubject() { - try { - synchronized (mSyncLock) { - JSONArray subjectArray = mNoteJSON.getJSONArray("subject"); - if (subjectArray.length() > 0) { - return subjectArray.getJSONObject(0); - } - } - } catch (JSONException e) { - return null; - } - - return null; - } - - public Spannable getFormattedSubject(NotificationsUtilsWrapper notificationsUtilsWrapper) { - return notificationsUtilsWrapper.getSpannableContentForRanges(getSubject()); - } - - public String getTitle() { - return queryJSON("title", ""); - } - - public String getIconURL() { - return queryJSON("icon", ""); - } - - public @Nullable List getIconURLs() { - synchronized (mSyncLock) { - JSONArray bodyArray = mNoteJSON.optJSONArray("body"); - if (bodyArray != null && bodyArray.length() > 0) { - ArrayList iconUrls = new ArrayList<>(); - for (int i = 0; i < bodyArray.length(); i++) { - String iconUrl = JSONUtils.queryJSON(bodyArray, "body[" + i + "].media[0].url", ""); - if (iconUrl != null && !iconUrl.isEmpty()) { - iconUrls.add(iconUrl); - } - } - return iconUrls; - } - } - return null; - } - - public String getCommentSubject() { - synchronized (mSyncLock) { - JSONArray subjectArray = mNoteJSON.optJSONArray("subject"); - if (subjectArray != null) { - String commentSubject = JSONUtils.queryJSON(subjectArray, "subject[1].text", ""); - - // Trim down the comment preview if the comment text is too large. - if (commentSubject != null && commentSubject.length() > MAX_COMMENT_PREVIEW_LENGTH) { - commentSubject = commentSubject.substring(0, MAX_COMMENT_PREVIEW_LENGTH - 1); - } - - return commentSubject; - } - } - - return ""; - } - - public String getCommentSubjectNoticon() { - JSONArray subjectRanges = queryJSON("subject[0].ranges", new JSONArray()); - if (subjectRanges != null) { - for (int i = 0; i < subjectRanges.length(); i++) { - try { - JSONObject rangeItem = subjectRanges.getJSONObject(i); - if (rangeItem.has("type") && rangeItem.optString("type").equals("noticon")) { - return rangeItem.optString("value", ""); - } - } catch (JSONException e) { - return ""; - } - } - } - - return ""; - } - - public long getCommentReplyId() { - return queryJSON("meta.ids.reply_comment", 0); - } - - /** - * Compare note timestamp to now and return a time grouping - */ - @NonNull - public static NoteTimeGroup getTimeGroupForTimestamp(long timestamp) { - Date today = new Date(); - Date then = new Date(timestamp * 1000); - - if (then.compareTo(DateUtils.addMonths(today, -1)) < 0) { - return NoteTimeGroup.GROUP_OLDER_MONTH; - } else if (then.compareTo(DateUtils.addWeeks(today, -1)) < 0) { - return NoteTimeGroup.GROUP_OLDER_WEEK; - } else if (then.compareTo(DateUtils.addDays(today, -2)) < 0 - || DateUtils.isSameDay(DateUtils.addDays(today, -2), then)) { - return NoteTimeGroup.GROUP_OLDER_TWO_DAYS; - } else if (DateUtils.isSameDay(DateUtils.addDays(today, -1), then)) { - return NoteTimeGroup.GROUP_YESTERDAY; - } else { - return NoteTimeGroup.GROUP_TODAY; - } - } - - /** - * The inverse of isRead - */ - public Boolean isUnread() { - return !isRead(); - } - - private Boolean isRead() { - return queryJSON("read", 0) == 1; - } - - public void setRead() { - try { - mNoteJSON.putOpt("read", 1); - } catch (JSONException e) { - AppLog.e(AppLog.T.NOTIFS, "Failed to set 'read' property", e); - } - } - - /** - * Get the timestamp provided by the API for the note - */ - public long getTimestamp() { - return DateTimeUtils.timestampFromIso8601(getTimestampString()); - } - - public String getTimestampString() { - return queryJSON("timestamp", ""); - } - - public JSONArray getBody() { - try { - synchronized (mSyncLock) { - return mNoteJSON.getJSONArray("body"); - } - } catch (JSONException e) { - return new JSONArray(); - } - } - - // returns character code for notification font - public String getNoticonCharacter() { - return queryJSON("noticon", ""); - } - - @NonNull - private JSONObject getCommentActions() { - if (mActions == null) { - // Find comment block that matches the root note comment id - long commentId = getCommentId(); - JSONArray bodyArray = getBody(); - for (int i = 0; i < bodyArray.length(); i++) { - try { - JSONObject bodyItem = bodyArray.getJSONObject(i); - if (bodyItem.has("type") && bodyItem.optString("type").equals("comment") - && commentId == JSONUtils.queryJSON(bodyItem, "meta.ids.comment", 0)) { - mActions = JSONUtils.queryJSON(bodyItem, "actions", new JSONObject()); - break; - } - } catch (JSONException e) { - break; - } - } - - if (mActions == null) { - mActions = new JSONObject(); - } - } - - return mActions; - } - - @NonNull - private JSONObject getPostActions() { - if (mActions == null) { - JSONArray bodyArray = getBody(); - for (int i = 0; i < bodyArray.length(); i++) { - try { - JSONObject bodyItem = bodyArray.getJSONObject(i); - if (bodyItem.has("type") && bodyItem.optString("type").equals("post") - && getPostId() == JSONUtils.queryJSON(bodyItem, "meta.ids.post", 0)) { - mActions = JSONUtils.queryJSON(bodyItem, "actions", new JSONObject()); - break; - } - } catch (JSONException e) { - break; - } - } - - if (mActions == null) { - mActions = new JSONObject(); - } - } - - return mActions; - } - - /** - * returns the actions allowed on this note, assumes it's a comment notification - */ - @NonNull - public EnumSet getEnabledCommentActions() { - return getEnabledActions(getCommentActions()); - } - - /** - * returns the actions allowed on this note, assumes it's a post notification - */ - @NonNull - public EnumSet getEnabledPostActions() { - return getEnabledActions(getPostActions()); - } - - @NonNull - private EnumSet getEnabledActions(@NonNull final JSONObject jsonActions) { - EnumSet actions = EnumSet.noneOf(EnabledActions.class); - if (jsonActions.length() == 0) { - return actions; - } - - if (jsonActions.has(ACTION_KEY_REPLY)) { - actions.add(EnabledActions.ACTION_REPLY); - } - if (jsonActions.has(ACTION_KEY_APPROVE) && jsonActions.optBoolean(ACTION_KEY_APPROVE, false)) { - actions.add(EnabledActions.ACTION_UNAPPROVE); - } - if (jsonActions.has(ACTION_KEY_APPROVE) && !jsonActions.optBoolean(ACTION_KEY_APPROVE, false)) { - actions.add(EnabledActions.ACTION_APPROVE); - } - if (jsonActions.has(ACTION_KEY_SPAM)) { - actions.add(EnabledActions.ACTION_SPAM); - } - if (jsonActions.has(ACTION_KEY_LIKE_COMMENT)) { - actions.add(EnabledActions.ACTION_LIKE_COMMENT); - } - if (jsonActions.has(ACTION_KEY_LIKE_POST)) { - actions.add(EnabledActions.ACTION_LIKE_POST); - } - return actions; - } - - public int getSiteId() { - return queryJSON("meta.ids.site", 0); - } - - public int getPostId() { - return queryJSON("meta.ids.post", 0); - } - - public long getCommentId() { - return queryJSON("meta.ids.comment", 0); - } - - public long getParentCommentId() { - return queryJSON("meta.ids.parent_comment", 0); - } - - /** - * Rudimentary system for pulling an item out of a JSON object hierarchy - */ - @NonNull - private U queryJSON(@Nullable String query, @NonNull U defaultObject) { - synchronized (mSyncLock) { - if (mNoteJSON == null) { - return defaultObject; - } - return JSONUtils.queryJSON(mNoteJSON, query, defaultObject); - } - } - - /** - * Constructs a new Comment object based off of data in a Note - */ - @NonNull - public CommentModel buildComment() { - CommentModel comment = new CommentModel(); - comment.setRemotePostId(getPostId()); - comment.setRemoteCommentId(getCommentId()); - comment.setAuthorName(getCommentAuthorName()); - comment.setDatePublished(DateTimeUtils.iso8601FromTimestamp(getTimestamp())); - comment.setContent(getCommentText()); - comment.setStatus(getCommentStatus().toString()); - comment.setAuthorUrl(getCommentAuthorUrl()); - comment.setPostTitle(getTitle()); // unavailable in note model - comment.setAuthorEmail(""); // unavailable in note model - comment.setAuthorProfileImageUrl(getIconURL()); - comment.setILike(hasLikedComment()); - return comment; - } - - @NonNull - public String getCommentAuthorName() { - JSONArray bodyArray = getBody(); - - for (int i = 0; i < bodyArray.length(); i++) { - try { - JSONObject bodyItem = bodyArray.getJSONObject(i); - if (bodyItem.has("type") && bodyItem.optString("type").equals("user")) { - return bodyItem.optString("text"); - } - } catch (JSONException e) { - return ""; - } - } - - return ""; - } - - private String getCommentText() { - return queryJSON("body[last].text", ""); - } - - private String getCommentAuthorUrl() { - JSONArray bodyArray = getBody(); - - for (int i = 0; i < bodyArray.length(); i++) { - try { - JSONObject bodyItem = bodyArray.getJSONObject(i); - if (bodyItem.has("type") && bodyItem.optString("type").equals("user")) { - return JSONUtils.queryJSON(bodyItem, "meta.links.home", ""); - } - } catch (JSONException e) { - return ""; - } - } - - return ""; - } - - public CommentStatus getCommentStatus() { - EnumSet enabledActions = getEnabledCommentActions(); - - if (enabledActions.contains(EnabledActions.ACTION_UNAPPROVE)) { - return CommentStatus.APPROVED; - } else if (enabledActions.contains(EnabledActions.ACTION_APPROVE)) { - return CommentStatus.UNAPPROVED; - } - - return CommentStatus.ALL; - } - - public boolean hasLikedComment() { - JSONObject jsonActions = getCommentActions(); - return !(jsonActions == null || jsonActions.length() == 0) && jsonActions.optBoolean(ACTION_KEY_LIKE_COMMENT); - } - - public boolean hasLikedPost() { - JSONObject jsonActions = getPostActions(); - return !(jsonActions == null || jsonActions.length() == 0) && jsonActions.optBoolean(ACTION_KEY_LIKE_POST); - } - - public void setLikedComment(boolean liked) { - JSONObject jsonActions = getCommentActions(); - if (jsonActions != null) { - try { - jsonActions.put(ACTION_KEY_LIKE_COMMENT, liked); - } catch (JSONException e) { - AppLog.e(T.NOTIFS, "Failed to set 'like' property for the note", e); - } - } - } - - public void setLikedPost(boolean liked) { - JSONObject jsonActions = getPostActions(); - if (jsonActions != null) { - try { - jsonActions.put(ACTION_KEY_LIKE_POST, liked); - } catch (JSONException e) { - AppLog.e(T.NOTIFS, "Failed to set 'like' property for the note", e); - } - } - } - - public String getUrl() { - return queryJSON("url", ""); - } - - public JSONArray getHeader() { - synchronized (mSyncLock) { - return mNoteJSON.optJSONArray("header"); - } - } - - // this method is used to compare two Notes: as it's potentially a very processing intensive operation, - // we're only comparing the note id, timestamp, and raw JSON length, which is accurate enough for - // the purpose of checking if the local Note is any different from a remote note. - public boolean equalsTimeAndLength(Note note) { - if (note == null) { - return false; - } - - if (this.getTimestampString().equalsIgnoreCase(note.getTimestampString()) - && this.getJSON().length() == note.getJSON().length()) { - return true; - } - return false; - } - - public static synchronized Note buildFromBase64EncodedData(String noteId, String base64FullNoteData) { - Note note = null; - - if (base64FullNoteData == null) { - return null; - } - - byte[] b64DecodedPayload = Base64.decode(base64FullNoteData, Base64.DEFAULT); - - // Decompress the payload - Inflater decompresser = new Inflater(); - decompresser.setInput(b64DecodedPayload, 0, b64DecodedPayload.length); - byte[] result = new byte[4096]; // max length an Android PN payload can have - int resultLength = 0; - try { - resultLength = decompresser.inflate(result); - decompresser.end(); - } catch (DataFormatException e) { - AppLog.e(AppLog.T.NOTIFS, "Can't decompress the PN BlockListPayload. It could be > 4K", e); - } - - String out = null; - try { - out = new String(result, 0, resultLength, "UTF8"); - } catch (UnsupportedEncodingException e) { - AppLog.e(AppLog.T.NOTIFS, "Notification data contains non UTF8 characters.", e); - } - - if (out != null) { - try { - JSONObject jsonObject = new JSONObject(out); - if (jsonObject.has("notes")) { - JSONArray jsonArray = jsonObject.getJSONArray("notes"); - if (jsonArray != null && jsonArray.length() == 1) { - jsonObject = jsonArray.getJSONObject(0); - } - } - note = new Note(noteId, jsonObject); - } catch (JSONException e) { - AppLog.e(AppLog.T.NOTIFS, "Can't parse the Note JSON received in the PN", e); - } - } - - return note; - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/models/Note.kt b/WordPress/src/main/java/org/wordpress/android/models/Note.kt new file mode 100644 index 000000000000..b1d74a2ad481 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/models/Note.kt @@ -0,0 +1,441 @@ +/** + * Note represents a single WordPress.com notification + */ +package org.wordpress.android.models + +import android.text.Spannable +import android.text.SpannableString +import android.text.TextUtils +import android.util.Base64 +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import org.wordpress.android.fluxc.model.CommentModel +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.DateTimeUtils +import org.wordpress.android.util.DateUtils.addDays +import org.wordpress.android.util.DateUtils.addMonths +import org.wordpress.android.util.DateUtils.addWeeks +import org.wordpress.android.util.DateUtils.isSameDay +import org.wordpress.android.util.JSONUtils +import org.wordpress.android.util.StringUtils +import java.io.UnsupportedEncodingException +import java.util.Date +import java.util.EnumSet +import java.util.zip.DataFormatException +import java.util.zip.Inflater + +class Note { + val id: String + var localStatus: String? = null + get() = StringUtils.notNullStr(field) + + private var mNoteJSON: JSONObject? = null + + constructor(key: String, noteJSON: JSONObject?) { + id = key + mNoteJSON = noteJSON + } + + constructor(noteJSON: JSONObject?) { + mNoteJSON = noteJSON + id = mNoteJSON?.optString("id", "") ?: "" + } + + enum class EnabledActions { + ACTION_REPLY, + ACTION_APPROVE, + ACTION_UNAPPROVE, + ACTION_SPAM, + ACTION_LIKE_COMMENT, + ACTION_LIKE_POST + } + + enum class NoteTimeGroup { + GROUP_TODAY, + GROUP_YESTERDAY, + GROUP_OLDER_TWO_DAYS, + GROUP_OLDER_WEEK, + GROUP_OLDER_MONTH + } + + /** + * Immutable lazily initialised properties from the note JSON + */ + + val siteId: Int by lazy { queryJSON("meta.ids.site", 0) } + val postId: Int by lazy { queryJSON("meta.ids.post", 0) } + val rawType: String by lazy { queryJSON("type", NOTE_UNKNOWN_TYPE) } + val commentId: Long by lazy { queryJSON("meta.ids.comment", 0).toLong() } + val parentCommentId: Long by lazy { queryJSON("meta.ids.parent_comment", 0).toLong() } + val url: String by lazy { queryJSON("url", "") } + val header: JSONArray? by lazy { mNoteJSON?.optJSONArray("header") } + val commentReplyId: Long by lazy { queryJSON("meta.ids.reply_comment", 0).toLong() } + val title: String by lazy { queryJSON("title", "") } + val iconURL: String by lazy { queryJSON("icon", "") } + val enabledCommentActions: EnumSet by lazy { getEnabledActions(commentActions) } + private val enabledPostActions: EnumSet by lazy { getEnabledActions(postActions) } + private val timestampString: String by lazy { queryJSON("timestamp", "") } + private val commentText: String by lazy { queryJSON("body[last].text", "") } + private val commentActions: JSONObject by lazy { getActions(commentId, "comment") } + private val postActions: JSONObject by lazy { getActions(postId.toLong(), "post") } + + val body: JSONArray by lazy { + runCatching { + mNoteJSON?.getJSONArray("body") ?: JSONArray() + }.getOrElse { + JSONArray() + } + } + + val subject: JSONObject? by lazy { + runCatching { + val subjectArray = mNoteJSON?.getJSONArray("subject") + if (subjectArray != null && subjectArray.length() > 0) { + subjectArray.getJSONObject(0) + } else null + }.getOrElse { + null + } + } + + val iconURLs: List? by lazy { + val bodyArray = mNoteJSON?.optJSONArray("body") + if (bodyArray != null && bodyArray.length() > 0) { + val iconUrls = ArrayList() + for (i in 0 until bodyArray.length()) { + val iconUrl = JSONUtils.queryJSON(bodyArray, "body[$i].media[0].url", "") + if (iconUrl != null && iconUrl.isNotEmpty()) { + iconUrls.add(iconUrl) + } + } + return@lazy iconUrls + } + null + } + + val commentSubject: String? by lazy { + val subjectArray = mNoteJSON?.optJSONArray("subject") + if (subjectArray != null) { + var commentSubject = JSONUtils.queryJSON(subjectArray, "subject[1].text", "") + + // Trim down the comment preview if the comment text is too large. + if (commentSubject != null && commentSubject.length > MAX_COMMENT_PREVIEW_LENGTH) { + commentSubject = commentSubject.substring(0, MAX_COMMENT_PREVIEW_LENGTH - 1) + } + return@lazy commentSubject + } + "" + } + + val commentSubjectNoticon: String by lazy { + with(queryJSON("subject[0].ranges", JSONArray())) { + for (i in 0 until length()) { + runCatching { + val rangeItem = getJSONObject(i) + if (rangeItem.has("type") && rangeItem.optString("type") == "noticon") { + return@lazy rangeItem.optString("value", "") + } + }.getOrElse { + return@lazy "" + } + } + } + "" + } + + val commentAuthorName: String by lazy { + val bodyArray = body + for (i in 0 until bodyArray.length()) { + runCatching { + val bodyItem = bodyArray.getJSONObject(i) + if (bodyItem.has("type") && bodyItem.optString("type") == "user") { + return@lazy bodyItem.optString("text") + } + }.getOrElse { + return@lazy "" + } + } + "" + } + + val isCommentType: Boolean by lazy { + isTypeRaw(NOTE_COMMENT_TYPE) || + isAutomattcherType && JSONUtils.queryJSON(mNoteJSON, "meta.ids.comment", -1) != -1 + } + + private val commentAuthorUrl: String by lazy { + val bodyArray = body + for (i in 0 until bodyArray.length()) { + runCatching { + val bodyItem = bodyArray.getJSONObject(i) + if (bodyItem.has("type") && bodyItem.optString("type") == "user") { + return@lazy JSONUtils.queryJSON(bodyItem, "meta.links.home", "") + } + }.getOrElse { + return@lazy "" + } + } + "" + } + + /** + * Computed properties + */ + val json: JSONObject + get() = mNoteJSON ?: JSONObject() + val isAutomattcherType: Boolean + get() = isTypeRaw(NOTE_MATCHER_TYPE) + val isNewPostType: Boolean + get() = isTypeRaw(NOTE_NEW_POST_TYPE) + val isFollowType: Boolean + get() = isTypeRaw(NOTE_FOLLOW_TYPE) + val isLikeType: Boolean + get() = isPostLikeType || isCommentLikeType + val isPostLikeType: Boolean + get() = isTypeRaw(NOTE_LIKE_TYPE) + val isCommentLikeType: Boolean + get() = isTypeRaw(NOTE_COMMENT_LIKE_TYPE) + val isReblogType: Boolean + get() = isTypeRaw(NOTE_REBLOG_TYPE) + val isViewMilestoneType: Boolean + get() = isTypeRaw(NOTE_VIEW_MILESTONE) + val isCommentReplyType: Boolean + get() = isCommentType && parentCommentId > 0 + val isCommentWithUserReply: Boolean + // Returns true if the user has replied to this comment note + get() = isCommentType && !TextUtils.isEmpty(commentSubjectNoticon) + val isUserList: Boolean + get() = isLikeType || isFollowType || isReblogType + val isUnread: Boolean // Parsing every time since it may change + get() = queryJSON("read", 0) != 1 + val timestamp: Long + get() = DateTimeUtils.timestampFromIso8601(timestampString) + val commentStatus: CommentStatus + get() = if (enabledCommentActions.contains(EnabledActions.ACTION_UNAPPROVE)) { + CommentStatus.APPROVED + } else if (enabledCommentActions.contains(EnabledActions.ACTION_APPROVE)) { + CommentStatus.UNAPPROVED + } else { + CommentStatus.ALL + } + + /** + * Setters + */ + + fun setRead() { + try { + mNoteJSON?.putOpt("read", 1) + } catch (e: JSONException) { + AppLog.e(AppLog.T.NOTIFS, "Failed to set 'read' property", e) + } + } + + fun setLikedComment(liked: Boolean) { + try { + commentActions.put(ACTION_KEY_LIKE_COMMENT, liked) + } catch (e: JSONException) { + AppLog.e(AppLog.T.NOTIFS, "Failed to set 'like' property for the note", e) + } + } + + fun setLikedPost(liked: Boolean) { + try { + postActions.put(ACTION_KEY_LIKE_POST, liked) + } catch (e: JSONException) { + AppLog.e(AppLog.T.NOTIFS, "Failed to set 'like' property for the note", e) + } + } + + /** + * Helper methods + */ + + fun canModerate() = enabledCommentActions.contains(EnabledActions.ACTION_APPROVE) || + enabledCommentActions.contains(EnabledActions.ACTION_UNAPPROVE) + + fun canReply() = enabledCommentActions.contains(EnabledActions.ACTION_REPLY) + + fun canLikeComment() = enabledCommentActions.contains(EnabledActions.ACTION_LIKE_COMMENT) + + fun canLikePost() = enabledPostActions.contains(EnabledActions.ACTION_LIKE_POST) + + fun getFormattedSubject(notificationsUtilsWrapper: NotificationsUtilsWrapper): Spannable { + return subject?.let { notificationsUtilsWrapper.getSpannableContentForRanges(it) } ?: SpannableString("") + } + + fun hasLikedComment() = commentActions.length() > 0 && commentActions.optBoolean(ACTION_KEY_LIKE_COMMENT) + + fun hasLikedPost() = postActions.length() > 0 && postActions.optBoolean(ACTION_KEY_LIKE_POST) + + /** + * Compares two notes to see if they are the same: as it's potentially a very processing intensive operation, + * we're only comparing the note id, timestamp, and raw JSON length, which is accurate enough for the purpose of + * checking if the local Note is any different from a remote note. + */ + fun equalsTimeAndLength(note: Note?) = note != null && + (timestampString.equals(note.timestampString, ignoreCase = true) && + json.length() == note.json.length()) + + /** + * Constructs a new Comment object based off of data in a Note + */ + fun buildComment(): CommentModel { + val comment = CommentModel() + comment.remotePostId = postId.toLong() + comment.remoteCommentId = commentId + comment.authorName = commentAuthorName + comment.datePublished = DateTimeUtils.iso8601FromTimestamp(timestamp) + comment.content = commentText + comment.status = commentStatus.toString() + comment.authorUrl = commentAuthorUrl + comment.postTitle = title // unavailable in note model + comment.authorEmail = "" // unavailable in note model + comment.authorProfileImageUrl = iconURL + comment.iLike = hasLikedComment() + return comment + } + + private fun isTypeRaw(rawType: String) = this.rawType == rawType + + /** + * Rudimentary system for pulling an item out of a JSON object hierarchy + */ + private fun queryJSON(query: String?, defaultObject: U): U = + if (mNoteJSON == null) defaultObject + else JSONUtils.queryJSON(mNoteJSON, query, defaultObject) + + /** + * Get the actions for a given comment or post + * @param itemId The comment or post id + * @param type The type of the item: `post` or `comment` + */ + private fun getActions(itemId: Long, type: String): JSONObject { + var actions: JSONObject? = null + var foundOrError = false + var i = 0 + while (!foundOrError && i < body.length()) { + val bodyItem = runCatching { body.getJSONObject(i) }.getOrNull() + if (bodyItem?.has("type") == true && bodyItem.optString("type") == type && + itemId == JSONUtils.queryJSON(bodyItem, "meta.ids.$type", 0).toLong()) { + actions = JSONUtils.queryJSON(bodyItem, "actions", JSONObject()) + foundOrError = true + } + i++ + } + return actions ?: JSONObject() + } + + private fun getEnabledActions(jsonActions: JSONObject): EnumSet { + val actions = EnumSet.noneOf(EnabledActions::class.java) + if (jsonActions.length() == 0) return actions + + if (jsonActions.has(ACTION_KEY_REPLY)) { + actions.add(EnabledActions.ACTION_REPLY) + } + if (jsonActions.has(ACTION_KEY_APPROVE) && jsonActions.optBoolean(ACTION_KEY_APPROVE, false)) { + actions.add(EnabledActions.ACTION_UNAPPROVE) + } + if (jsonActions.has(ACTION_KEY_APPROVE) && !jsonActions.optBoolean(ACTION_KEY_APPROVE, false)) { + actions.add(EnabledActions.ACTION_APPROVE) + } + if (jsonActions.has(ACTION_KEY_SPAM)) { + actions.add(EnabledActions.ACTION_SPAM) + } + if (jsonActions.has(ACTION_KEY_LIKE_COMMENT)) { + actions.add(EnabledActions.ACTION_LIKE_COMMENT) + } + if (jsonActions.has(ACTION_KEY_LIKE_POST)) { + actions.add(EnabledActions.ACTION_LIKE_POST) + } + return actions + } + + companion object { + private const val MAX_COMMENT_PREVIEW_LENGTH = 200 // maximum character length for a comment preview + private const val MAX_PN_LENGTH = 4096 // max length an Android PN payload can have + + // Note types + const val NOTE_FOLLOW_TYPE = "follow" + const val NOTE_LIKE_TYPE = "like" + const val NOTE_COMMENT_TYPE = "comment" + const val NOTE_MATCHER_TYPE = "automattcher" + const val NOTE_COMMENT_LIKE_TYPE = "comment_like" + const val NOTE_REBLOG_TYPE = "reblog" + const val NOTE_NEW_POST_TYPE = "new_post" + const val NOTE_VIEW_MILESTONE = "view_milestone" + const val NOTE_UNKNOWN_TYPE = "unknown" + + // JSON action keys + private const val ACTION_KEY_REPLY = "replyto-comment" + private const val ACTION_KEY_APPROVE = "approve-comment" + private const val ACTION_KEY_SPAM = "spam-comment" + private const val ACTION_KEY_LIKE_COMMENT = "like-comment" + private const val ACTION_KEY_LIKE_POST = "like-post" + + // Time constants + private const val LAST_MONTH = -1 + private const val LAST_WEEK = -1 + private const val LAST_TWO_DAYS = -2 + private const val SINCE_YESTERDAY = -1 + private const val MILLISECOND = 1000 + + /** + * Compare note timestamp to now and return a time grouping + */ + fun getTimeGroupForTimestamp(timestamp: Long): NoteTimeGroup { + val today = Date() + val then = Date(timestamp * MILLISECOND) + return when { + then < addMonths(today, LAST_MONTH) -> NoteTimeGroup.GROUP_OLDER_MONTH + then < addWeeks(today, LAST_WEEK) -> NoteTimeGroup.GROUP_OLDER_WEEK + then < addDays(today, LAST_TWO_DAYS) || + isSameDay(addDays(today, LAST_TWO_DAYS), then) -> NoteTimeGroup.GROUP_OLDER_TWO_DAYS + isSameDay(addDays(today, SINCE_YESTERDAY), then) -> NoteTimeGroup.GROUP_YESTERDAY + else -> NoteTimeGroup.GROUP_TODAY + } + } + + @JvmStatic + @Synchronized + fun buildFromBase64EncodedData(noteId: String, base64FullNoteData: String?): Note? { + if (base64FullNoteData == null) return null + val b64DecodedPayload = Base64.decode(base64FullNoteData, Base64.DEFAULT) + + // Decompress the payload + val decompresser = Inflater() + decompresser.setInput(b64DecodedPayload, 0, b64DecodedPayload.size) + val result = ByteArray(MAX_PN_LENGTH) + val resultLength = try { + val length = decompresser.inflate(result) + decompresser.end() + length + } catch (e: DataFormatException) { + AppLog.e(AppLog.T.NOTIFS, "Can't decompress the PN BlockListPayload. It could be > 4K", e) + 0 + } + val out: String? = try { + String(result, 0, resultLength, charset("UTF8")) + } catch (e: UnsupportedEncodingException) { + AppLog.e(AppLog.T.NOTIFS, "Notification data contains non UTF8 characters.", e) + null + } + return out?.runCatching { + var jsonObject = JSONObject(out) + if (jsonObject.has("notes")) { + val jsonArray: JSONArray? = jsonObject.getJSONArray("notes") + if (jsonArray != null && jsonArray.length() == 1) { + jsonObject = jsonArray.getJSONObject(0) + } + } + Note(noteId, jsonObject) + }?.getOrElse { + AppLog.e(AppLog.T.NOTIFS, "Can't parse the Note JSON received in the PN") + null + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt index f1f5dff7eb96..be5af3881410 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt @@ -470,7 +470,7 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { } var pingbackUrl: String? = null val isPingback = isPingback(note) - if (bodyArray != null && bodyArray.length() > 0) { + if (bodyArray.length() > 0) { pingbackUrl = addNotesBlock(note, noteList, bodyArray, isPingback) } if (isPingback) { @@ -489,7 +489,7 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { private fun isPingback(note: Note): Boolean { var hasRangeOfTypeSite = false var hasRangeOfTypePost = false - val rangesArray = note.subject.optJSONArray("ranges") + val rangesArray = note.subject?.optJSONArray("ranges") if (rangesArray != null) { for (i in 0 until rangesArray.length()) { val rangeObject = rangesArray.optJSONObject(i) ?: continue