package org.wordpress.android.push; import android.app.Service; import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.support.v4.app.RemoteInput; import android.text.TextUtils; import com.android.volley.VolleyError; import com.wordpress.rest.RestRequest; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.wordpress.android.R; import org.wordpress.android.WordPress; import org.wordpress.android.analytics.AnalyticsTracker; import org.wordpress.android.fluxc.Dispatcher; import org.wordpress.android.fluxc.action.CommentAction; import org.wordpress.android.fluxc.generated.CommentActionBuilder; import org.wordpress.android.fluxc.model.CommentModel; import org.wordpress.android.fluxc.model.CommentStatus; import org.wordpress.android.fluxc.model.SiteModel; import org.wordpress.android.fluxc.store.CommentStore; import org.wordpress.android.fluxc.store.CommentStore.OnCommentChanged; import org.wordpress.android.fluxc.store.CommentStore.RemoteCommentPayload; import org.wordpress.android.fluxc.store.CommentStore.RemoteCreateCommentPayload; import org.wordpress.android.fluxc.store.CommentStore.RemoteLikeCommentPayload; import org.wordpress.android.fluxc.store.SiteStore; import org.wordpress.android.models.Note; import org.wordpress.android.ui.main.WPMainActivity; import org.wordpress.android.ui.notifications.NotificationsListFragment; import org.wordpress.android.ui.notifications.receivers.NotificationsPendingDraftsReceiver; import org.wordpress.android.ui.notifications.utils.NotificationsActions; import org.wordpress.android.ui.notifications.utils.NotificationsUtils; import org.wordpress.android.ui.notifications.utils.PendingDraftsNotificationsUtils; import org.wordpress.android.util.AppLog; import org.wordpress.android.util.AppLog.T; import java.util.HashMap; import javax.inject.Inject; /** * service which makes it possible to process Notifications quick actions in the background, * such as: * - like * - reply-to-comment * - approve * - 2fa approve & ignore * - pending draft notification ignore & dismissal */ public class NotificationsProcessingService extends Service { public static final String ARG_ACTION_TYPE = "action_type"; public static final String ARG_ACTION_LIKE = "action_like"; public static final String ARG_ACTION_REPLY = "action_reply"; public static final String ARG_ACTION_APPROVE = "action_approve"; public static final String ARG_ACTION_AUTH_APPROVE = "action_auth_aprove"; public static final String ARG_ACTION_AUTH_IGNORE = "action_auth_ignore"; public static final String ARG_ACTION_DRAFT_PENDING_DISMISS = "action_draft_pending_dismiss"; public static final String ARG_ACTION_DRAFT_PENDING_IGNORE = "action_draft_pending_ignore"; public static final String ARG_ACTION_REPLY_TEXT = "action_reply_text"; public static final String ARG_NOTE_ID = "note_id"; //bundle and push ID, as they are held in the system dashboard public static final String ARG_NOTE_BUNDLE = "note_bundle"; private QuickActionProcessor mQuickActionProcessor; @Inject Dispatcher mDispatcher; @Inject SiteStore mSiteStore; @Inject CommentStore mCommentStore; /* * Use this if you want the service to handle a background note Like. * */ public static void startServiceForLike(Context context, String noteId) { Intent intent = new Intent(context, NotificationsProcessingService.class); intent.putExtra(ARG_ACTION_TYPE, ARG_ACTION_LIKE); intent.putExtra(ARG_NOTE_ID, noteId); context.startService(intent); } /* * Use this if you want the service to handle a background note Approve. * */ public static void startServiceForApprove(Context context, String noteId) { Intent intent = new Intent(context, NotificationsProcessingService.class); intent.putExtra(ARG_ACTION_TYPE, ARG_ACTION_APPROVE); intent.putExtra(ARG_NOTE_ID, noteId); context.startService(intent); } /* * Use this if you want the service to handle a background note reply. * */ public static void startServiceForReply(Context context, String noteId, String replyToComment) { Intent intent = new Intent(context, NotificationsProcessingService.class); intent.putExtra(ARG_ACTION_TYPE, ARG_ACTION_REPLY); intent.putExtra(ARG_NOTE_ID, noteId); intent.putExtra(ARG_ACTION_REPLY_TEXT, replyToComment); context.startService(intent); } public static void stopService(Context context) { if (context == null) return; Intent intent = new Intent(context, NotificationsProcessingService.class); context.stopService(intent); } @Override public IBinder onBind(Intent intent) { return null; } @Override public void onCreate() { super.onCreate(); ((WordPress) getApplication()).component().inject(this); mDispatcher.register(this); AppLog.i(AppLog.T.NOTIFS, "notifications action processing service > created"); } @Override public void onDestroy() { AppLog.i(AppLog.T.NOTIFS, "notifications action processing service > destroyed"); mDispatcher.unregister(this); super.onDestroy(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { // Offload to a separate thread. mQuickActionProcessor = new QuickActionProcessor(this, intent, startId); new Thread(new Runnable() { public void run() { mQuickActionProcessor.process(); } }).start(); return START_NOT_STICKY; } private class QuickActionProcessor { private String mNoteId; private String mReplyText; private String mActionType; private int mPushId; private Note mNote; private final int mTaskId; private final Context mContext; private final Intent mIntent; public QuickActionProcessor(Context ctx, Intent intent, int taskId) { mContext = ctx; mIntent = intent; mTaskId = taskId; } public void process() { getDataFromIntent(); //now handle each action if (mActionType != null) { // check special cases for authorization push if (mActionType.equals(ARG_ACTION_AUTH_IGNORE)) { //dismiss notifs NativeNotificationsUtils.dismissNotification( GCMMessageService.ACTIONS_RESULT_NOTIFICATION_ID, mContext); NativeNotificationsUtils.dismissNotification( GCMMessageService.AUTH_PUSH_NOTIFICATION_ID, mContext); NativeNotificationsUtils.dismissNotification( GCMMessageService.ACTIONS_PROGRESS_NOTIFICATION_ID, mContext); GCMMessageService.removeNotification(GCMMessageService.AUTH_PUSH_NOTIFICATION_ID); AnalyticsTracker.track(AnalyticsTracker.Stat.PUSH_AUTHENTICATION_IGNORED); return; } // check special cases for pending draft notifications - ignore if (mActionType.equals(ARG_ACTION_DRAFT_PENDING_IGNORE)) { //dismiss notif int postId = mIntent.getIntExtra(NotificationsPendingDraftsReceiver.POST_ID_EXTRA, 0); if (postId != 0) { NativeNotificationsUtils.dismissNotification( PendingDraftsNotificationsUtils.makePendingDraftNotificationId(postId), mContext ); } AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_PENDING_DRAFTS_IGNORED); return; } // check special cases for pending draft notifications - dismiss if (mActionType.equals(ARG_ACTION_DRAFT_PENDING_DISMISS)) { AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_PENDING_DRAFTS_DISMISSED); return; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && mActionType.equals(ARG_ACTION_REPLY)) { //we don't need showing the infinite progress bar in case of REPLY on Android N, //because we've got inline-reply there with its own spinner to show progress // no op } else { NativeNotificationsUtils.showIntermediateMessageToUser( getProcessingTitleForAction(mActionType), mContext); } //if we still don't have a Note, go get it from the REST API if (mNote == null) { RestRequest.Listener listener = new RestRequest.Listener() { @Override public void onResponse(JSONObject response) { if (response != null && !response.optBoolean("success")) { //build the Note object here buildNoteFromJSONObject(response); performRequestedAction(); } } }; RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { @Override public void onErrorResponse(VolleyError error) { requestFailed(mActionType); } }; getNoteFromNoteId(mNoteId, listener, errorListener); } else { //we have a Note! just go ahead and perform the requested action performRequestedAction(); } } else { requestFailed(null); } } private void getNoteFromBundleIfExists() { if (mIntent.hasExtra(ARG_NOTE_BUNDLE)) { Bundle payload = mIntent.getBundleExtra(ARG_NOTE_BUNDLE); mNote = NotificationsUtils.buildNoteObjectFromBundle(payload); } } private void getDataFromIntent() { // get all needed data from intent mNoteId = mIntent.getStringExtra(ARG_NOTE_ID); mActionType = mIntent.getStringExtra(ARG_ACTION_TYPE); //default value for push notification ID is likely GROUP_NOTIFICATION_ID for the only // notif in active notifs map (there is only one notif if quick actions are available) mPushId = GCMMessageService.GROUP_NOTIFICATION_ID; if (mIntent.hasExtra(ARG_ACTION_REPLY_TEXT)) { mReplyText = mIntent.getStringExtra(ARG_ACTION_REPLY_TEXT); } if (TextUtils.isEmpty(mReplyText)) { //if voice reply is enabled in a wearable, it will come through the remoteInput //extra EXTRA_VOICE_OR_INLINE_REPLY //same thing with direct-reply in Android 7 Bundle remoteInput = RemoteInput.getResultsFromIntent(mIntent); if (remoteInput != null) { obtainReplyTextFromRemoteInputBundle(remoteInput); } } //we probably have the note in the PN payload and such it's passed in the intent extras // bundle. If we have it, no need to go fetch it through REST API. getNoteFromBundleIfExists(); } private String getProcessingTitleForAction(String actionType) { if (actionType != null) { switch (actionType) { case ARG_ACTION_LIKE: return getString(R.string.comment_q_action_liking); case ARG_ACTION_APPROVE: return getString(R.string.comment_q_action_approving); case ARG_ACTION_REPLY: return getString(R.string.comment_q_action_replying); default: //default, generic "processing" return getString(R.string.comment_q_action_processing); } } else { //default, generic "processing" return getString(R.string.comment_q_action_processing); } } private void obtainReplyTextFromRemoteInputBundle(Bundle bundle) { CharSequence replyText = bundle.getCharSequence(GCMMessageService.EXTRA_VOICE_OR_INLINE_REPLY); if (replyText != null) { mReplyText = replyText.toString(); } } private void buildNoteFromJSONObject(JSONObject jsonObject) { try { if (jsonObject.has("notes")) { JSONArray jsonArray = jsonObject.getJSONArray("notes"); if (jsonArray != null && jsonArray.length() == 1) { jsonObject = jsonArray.getJSONObject(0); } } mNote = new Note(mNoteId, jsonObject); } catch (JSONException e) { AppLog.e(AppLog.T.NOTIFS, e.getMessage()); } } private void performRequestedAction(){ /*********************************************************/ /* possible actions are Comment REPLY, APPROVE, and LIKE */ /*********************************************************/ if (mNote != null) { if (mActionType != null) { switch (mActionType) { case ARG_ACTION_LIKE: likeComment(); break; case ARG_ACTION_APPROVE: approveComment(); break; case ARG_ACTION_REPLY: replyToComment(); break; default: //no op requestFailed(null); break; } } else { // add other actions here //no op requestFailed(null); } } else { requestFailed(null); } } /* * called when action has been completed successfully */ private void requestCompleted(String actionType) { String successMessage = null; if (actionType != null) { if (actionType.equals(ARG_ACTION_LIKE)) { successMessage = getString(R.string.comment_liked); } else if (actionType.equals(ARG_ACTION_APPROVE)) { successMessage = getString(R.string.comment_moderated_approved); } else if (actionType.equals(ARG_ACTION_REPLY)) { successMessage = getString(R.string.note_reply_successful); } } else { //show generic success message here successMessage = getString(R.string.comment_q_action_done_generic); } NotificationsActions.markNoteAsRead(mNote); //dismiss any other pending result notification NativeNotificationsUtils.dismissNotification( GCMMessageService.ACTIONS_RESULT_NOTIFICATION_ID, mContext); //update notification indicating the operation succeeded NativeNotificationsUtils.showFinalMessageToUser(successMessage, GCMMessageService.ACTIONS_PROGRESS_NOTIFICATION_ID, mContext); //remove the original notification from the system bar GCMMessageService.removeNotificationWithNoteIdFromSystemBar(mContext, mNoteId); //after 3 seconds, dismiss the notification that indicated success Handler handler = new Handler(getMainLooper()); handler.postDelayed(new Runnable() { public void run() { NativeNotificationsUtils.dismissNotification( GCMMessageService.ACTIONS_PROGRESS_NOTIFICATION_ID, mContext); }}, 3000); // show the success message for 3 seconds, then dismiss stopSelf(mTaskId); } /* * called when action failed */ private void requestFailed(String actionType) { String errorMessage = null; if (actionType != null) { if (actionType.equals(ARG_ACTION_LIKE)) { errorMessage = getString(R.string.error_notif_q_action_like); } else if (actionType.equals(ARG_ACTION_APPROVE)) { errorMessage = getString(R.string.error_notif_q_action_approve); } else if (actionType.equals(ARG_ACTION_REPLY)) { errorMessage = getString(R.string.error_notif_q_action_reply); } } else { //show generic error here errorMessage = getString(R.string.error_generic); } resetOriginalNotification(); NativeNotificationsUtils.dismissNotification( GCMMessageService.ACTIONS_PROGRESS_NOTIFICATION_ID, mContext); NativeNotificationsUtils.showFinalMessageToUser(errorMessage, GCMMessageService.ACTIONS_RESULT_NOTIFICATION_ID, mContext); //after 3 seconds, dismiss the error message notification Handler handler = new Handler(getMainLooper()); handler.postDelayed(new Runnable() { public void run() { //remove the error notification from the system bar NativeNotificationsUtils.dismissNotification( GCMMessageService.ACTIONS_RESULT_NOTIFICATION_ID, mContext); }}, 3000); // show the success message for 3 seconds, then dismiss stopSelf(mTaskId); } private void requestFailedWithMessage(String errorMessage, boolean autoDismiss) { if (errorMessage == null) { //show generic error here errorMessage = getString(R.string.error_generic); } resetOriginalNotification(); NativeNotificationsUtils.showFinalMessageToUser(errorMessage, GCMMessageService.ACTIONS_RESULT_NOTIFICATION_ID, mContext); if (autoDismiss) { //after 3 seconds, dismiss the error message notification Handler handler = new Handler(getMainLooper()); handler.postDelayed(new Runnable() { public void run() { //remove the error notification from the system bar NativeNotificationsUtils.dismissNotification( GCMMessageService.ACTIONS_RESULT_NOTIFICATION_ID, mContext); }}, 3000); // show the success message for 3 seconds, then dismiss } stopSelf(mTaskId); } private void getNoteFromNoteId(String noteId, RestRequest.Listener listener, RestRequest.ErrorListener errorListener) { if (noteId == null) return; HashMap<String, String> params = new HashMap<>(); WordPress.getRestClientUtils().getNotification(params, noteId, listener, errorListener ); } // Like or unlike a comment via the REST API private void likeComment() { if (mNote == null) { requestFailed(ARG_ACTION_LIKE); return; } // Bump analytics AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_QUICK_ACTIONS_LIKED); SiteModel site = mSiteStore.getSiteBySiteId(mNote.getSiteId()); if (site != null) { mDispatcher.dispatch(CommentActionBuilder.newLikeCommentAction( new RemoteLikeCommentPayload(site, mNote.getCommentId(), true))); } else { requestFailed(ARG_ACTION_LIKE); AppLog.e(T.NOTIFS, "Site with id: " + mNote.getSiteId() + " doesn't exist in the Site store"); } } private void approveComment() { if (mNote == null) { requestFailed(ARG_ACTION_APPROVE); return; } // Bump analytics AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_QUICK_ACTIONS_APPROVED); // Update pseudo comment (built from the note) CommentModel comment = mNote.buildComment(); comment.setStatus(CommentStatus.APPROVED.toString()); SiteModel site = mSiteStore.getSiteBySiteId(mNote.getSiteId()); if (site == null) { AppLog.e(T.NOTIFS, "Impossible to approve a comment on a site that is not in the App. SiteId: " + mNote.getSiteId()); requestFailed(ARG_ACTION_APPROVE); return; } // Push the comment mDispatcher.dispatch(CommentActionBuilder.newPushCommentAction(new RemoteCommentPayload(site, comment))); } private void replyToComment() { if (mNote == null) { requestFailed(ARG_ACTION_APPROVE); return; } // Bump analytics AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_QUICK_ACTIONS_REPLIED_TO); if (!TextUtils.isEmpty(mReplyText)) { SiteModel site = mSiteStore.getSiteBySiteId(mNote.getSiteId()); if (site == null) { AppLog.e(T.NOTIFS, "Impossible to reply to a comment on a site that is not in the App. SiteId: " + mNote.getSiteId()); requestFailed(ARG_ACTION_APPROVE); return; } // Pseudo comment (built from the note) CommentModel comment = mNote.buildComment(); // Pseudo comment reply CommentModel reply = new CommentModel(); reply.setContent(mReplyText); // Push the reply RemoteCreateCommentPayload payload = new RemoteCreateCommentPayload(site, comment, reply); mDispatcher.dispatch(CommentActionBuilder.newCreateNewCommentAction(payload)); } else { //cancel the current notification NativeNotificationsUtils.dismissNotification(mPushId, mContext); NativeNotificationsUtils.hideStatusBar(mContext); //and just trigger the Activity to allow the user to write a reply startReplyToCommentActivity(); } } private void startReplyToCommentActivity() { Intent intent = new Intent(mContext, WPMainActivity.class); intent.putExtra(WPMainActivity.ARG_OPENED_FROM_PUSH, true); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); intent.setAction("android.intent.action.MAIN"); intent.addCategory("android.intent.category.LAUNCHER"); intent.putExtra(NotificationsListFragment.NOTE_ID_EXTRA, mNoteId); intent.putExtra(NotificationsListFragment.NOTE_INSTANT_REPLY_EXTRA, true); startActivity(intent); } private void resetOriginalNotification(){ GCMMessageService.rebuildAndUpdateNotificationsOnSystemBarForThisNote(mContext, mNoteId); } } // OnChanged events @SuppressWarnings("unused") @Subscribe(threadMode = ThreadMode.MAIN) public void onCommentChanged(OnCommentChanged event) { if (mQuickActionProcessor == null) { return; } if (event.causeOfChange == CommentAction.PUSH_COMMENT) { if (event.isError()) { mQuickActionProcessor.requestFailed(ARG_ACTION_APPROVE); } else { mQuickActionProcessor.requestCompleted(ARG_ACTION_APPROVE); } } else if (event.causeOfChange == CommentAction.LIKE_COMMENT) { if (event.isError()) { mQuickActionProcessor.requestFailed(ARG_ACTION_LIKE); } else { mQuickActionProcessor.requestCompleted(ARG_ACTION_LIKE); } } else if (event.causeOfChange == CommentAction.CREATE_NEW_COMMENT) { if (event.isError()) { mQuickActionProcessor.requestFailed(ARG_ACTION_REPLY); } else { mQuickActionProcessor.requestCompleted(ARG_ACTION_REPLY); } } } }