package org.wordpress.android.push;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.support.annotation.NonNull;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v4.app.RemoteInput;
import android.support.v4.util.ArrayMap;
import android.text.TextUtils;
import com.google.android.gms.gcm.GcmListenerService;
import org.apache.commons.lang3.StringEscapeUtils;
import org.wordpress.android.R;
import org.wordpress.android.WordPress;
import org.wordpress.android.analytics.AnalyticsTracker;
import org.wordpress.android.analytics.AnalyticsTracker.Stat;
import org.wordpress.android.analytics.AnalyticsTrackerMixpanel;
import org.wordpress.android.datasets.NotificationsTable;
import org.wordpress.android.fluxc.model.CommentStatus;
import org.wordpress.android.fluxc.store.AccountStore;
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.NotificationDismissBroadcastReceiver;
import org.wordpress.android.ui.notifications.NotificationEvents;
import org.wordpress.android.ui.notifications.NotificationsListFragment;
import org.wordpress.android.ui.notifications.utils.NotificationsActions;
import org.wordpress.android.ui.notifications.utils.NotificationsUtils;
import org.wordpress.android.ui.prefs.AppPrefs;
import org.wordpress.android.util.AppLog;
import org.wordpress.android.util.AppLog.T;
import org.wordpress.android.util.DateTimeUtils;
import org.wordpress.android.util.HelpshiftHelper;
import org.wordpress.android.util.ImageUtils;
import org.wordpress.android.util.PhotonUtils;
import org.wordpress.android.util.StringUtils;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import de.greenrobot.event.EventBus;
public class GCMMessageService extends GcmListenerService {
private static final ArrayMap<Integer, Bundle> sActiveNotificationsMap = new ArrayMap<>();
private static final NotificationHelper sNotificationHelper = new NotificationHelper();
private static final String NOTIFICATION_GROUP_KEY = "notification_group_key";
private static final int PUSH_NOTIFICATION_ID = 10000;
public static final int AUTH_PUSH_NOTIFICATION_ID = 20000;
public static final int GROUP_NOTIFICATION_ID = 30000;
public static final int ACTIONS_RESULT_NOTIFICATION_ID = 40000;
public static final int ACTIONS_PROGRESS_NOTIFICATION_ID = 50000;
public static final int GENERIC_LOCAL_NOTIFICATION_ID = 60000;
private static final int AUTH_PUSH_REQUEST_CODE_APPROVE = 0;
private static final int AUTH_PUSH_REQUEST_CODE_IGNORE = 1;
private static final int AUTH_PUSH_REQUEST_CODE_OPEN_DIALOG = 2;
public static final String EXTRA_VOICE_OR_INLINE_REPLY = "extra_voice_or_inline_reply";
private static final int MAX_INBOX_ITEMS = 5;
private static final String PUSH_ARG_USER = "user";
private static final String PUSH_ARG_TYPE = "type";
private static final String PUSH_ARG_TITLE = "title";
private static final String PUSH_ARG_MSG = "msg";
public static final String PUSH_ARG_NOTE_ID = "note_id";
public static final String PUSH_ARG_NOTE_FULL_DATA = "note_full_data";
private static final String PUSH_TYPE_COMMENT = "c";
private static final String PUSH_TYPE_LIKE = "like";
private static final String PUSH_TYPE_COMMENT_LIKE = "comment_like";
private static final String PUSH_TYPE_AUTOMATTCHER = "automattcher";
private static final String PUSH_TYPE_FOLLOW = "follow";
private static final String PUSH_TYPE_REBLOG = "reblog";
private static final String PUSH_TYPE_PUSH_AUTH = "push_auth";
private static final String PUSH_TYPE_BADGE_RESET = "badge-reset";
private static final String PUSH_TYPE_NOTE_DELETE = "note-delete";
@Inject AccountStore mAccountStore;
@Inject SiteStore mSiteStore;
private static final String KEY_CATEGORY_COMMENT_LIKE = "comment-like";
private static final String KEY_CATEGORY_COMMENT_REPLY = "comment-reply";
private static final String KEY_CATEGORY_COMMENT_MODERATE = "comment-moderate";
@Override
public void onCreate() {
super.onCreate();
((WordPress) getApplication()).component().inject(this);
}
// Add to the analytics properties map a subset of the push notification payload.
private static final String[] propertiesToCopyIntoAnalytics = {PUSH_ARG_NOTE_ID, PUSH_ARG_TYPE, "blog_id", "post_id",
"comment_id"};
private void synchronizedHandleDefaultPush(@NonNull Bundle data) {
// sActiveNotificationsMap being static, we can't just synchronize the method
synchronized (GCMMessageService.class) {
sNotificationHelper.handleDefaultPush(this, data, mAccountStore.getAccount().getUserId());
}
}
@Override
public void onMessageReceived(String from, Bundle data) {
AppLog.v(T.NOTIFS, "Received Message");
if (data == null) {
AppLog.v(T.NOTIFS, "No notification message content received. Aborting.");
return;
}
// Handle helpshift PNs
if (TextUtils.equals(data.getString("origin"), "helpshift")) {
HelpshiftHelper.getInstance().handlePush(this, new Intent().putExtras(data));
return;
}
// Handle mixpanel PNs
if (data.containsKey("mp_message")) {
String mpMessage = data.getString("mp_message");
String title = getString(R.string.app_name);
Intent resultIntent = new Intent(this, WPMainActivity.class);
resultIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TASK);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, resultIntent,
PendingIntent.FLAG_UPDATE_CURRENT);
AnalyticsTrackerMixpanel.showNotification(this, pendingIntent,
R.drawable.ic_my_sites_24dp, title, mpMessage);
return;
}
if (!mAccountStore.hasAccessToken()) {
return;
}
synchronizedHandleDefaultPush(data);
}
public static synchronized void rebuildAndUpdateNotificationsOnSystemBarForThisNote(Context context,
String noteId) {
if (sActiveNotificationsMap.size() > 0) {
//get the corresponding bundle for this noteId
for (Map.Entry<Integer, Bundle> row : sActiveNotificationsMap.entrySet()) {
Bundle noteBundle = row.getValue();
if (noteBundle.getString(PUSH_ARG_NOTE_ID, "").equals(noteId)) {
sNotificationHelper.rebuildAndUpdateNotificationsOnSystemBar(context, noteBundle);
return;
}
}
}
}
public static synchronized void rebuildAndUpdateNotifsOnSystemBarForRemainingNote(Context context) {
if (sActiveNotificationsMap.size() > 0) {
Bundle remainingNote = sActiveNotificationsMap.values().iterator().next();
sNotificationHelper.rebuildAndUpdateNotificationsOnSystemBar(context, remainingNote);
}
}
public static synchronized Bundle getCurrentNoteBundleForNoteId(String noteId){
if (sActiveNotificationsMap.size() > 0) {
//get the corresponding bundle for this noteId
for(Iterator<Map.Entry<Integer, Bundle>> it = sActiveNotificationsMap.entrySet().iterator(); it.hasNext(); ) {
Map.Entry<Integer, Bundle> row = it.next();
Bundle noteBundle = row.getValue();
if (noteBundle.getString(PUSH_ARG_NOTE_ID, "").equals(noteId)) {
return noteBundle;
}
}
}
return null;
}
public static synchronized void clearNotifications() {
Bundle authPNBundle = sActiveNotificationsMap.remove(AUTH_PUSH_NOTIFICATION_ID);
sActiveNotificationsMap.clear();
//reinsert 2fa bundle if it was present
if (authPNBundle != null) {
sActiveNotificationsMap.put(AUTH_PUSH_NOTIFICATION_ID, authPNBundle);
}
}
public static synchronized int getNotificationsCount() {
return sActiveNotificationsMap.size();
}
public static synchronized boolean hasNotifications() {
return !sActiveNotificationsMap.isEmpty();
}
// Removes a specific notification from the internal map - only use this when we know
// the user has dismissed the app by swiping it off the screen
public static synchronized void removeNotification(int notificationId) {
sActiveNotificationsMap.remove(notificationId);
}
// Removes a specific notification from the system bar
public static synchronized void removeNotificationWithNoteIdFromSystemBar(Context context, String noteID) {
if (context == null || TextUtils.isEmpty(noteID) || !hasNotifications()) {
return;
}
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
// here we loop with an Iterator as there might be several Notifications with the same Note ID (i.e. likes on the same Note)
// so we need to keep cancelling them and removing them from our activeNotificationsMap as we find it suitable
for(Iterator<Map.Entry<Integer, Bundle>> it = sActiveNotificationsMap.entrySet().iterator(); it.hasNext(); ) {
Map.Entry<Integer, Bundle> row = it.next();
Integer pushId = row.getKey();
Bundle noteBundle = row.getValue();
if (noteBundle.getString(PUSH_ARG_NOTE_ID, "").equals(noteID)) {
notificationManager.cancel(pushId);
it.remove();
}
}
if (sActiveNotificationsMap.size() == 0) {
notificationManager.cancel(GCMMessageService.GROUP_NOTIFICATION_ID);
}
}
// Removes all app notifications from the system bar
public static synchronized void removeAllNotifications(Context context) {
if (context == null || !hasNotifications()) {
return;
}
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
Bundle authPNBundle = sActiveNotificationsMap.remove(AUTH_PUSH_NOTIFICATION_ID);
for (Integer pushId : sActiveNotificationsMap.keySet()) {
notificationManager.cancel(pushId);
}
notificationManager.cancel(GCMMessageService.GROUP_NOTIFICATION_ID);
//reinsert 2fa bundle if it was present
if (authPNBundle != null) {
sActiveNotificationsMap.put(AUTH_PUSH_NOTIFICATION_ID, authPNBundle);
}
clearNotifications();
}
public static synchronized void remove2FANotification(Context context) {
if (context == null || !hasNotifications()) {
return;
}
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
notificationManager.cancel(AUTH_PUSH_NOTIFICATION_ID);
sActiveNotificationsMap.remove(AUTH_PUSH_NOTIFICATION_ID);
}
// NoteID is the ID if the note in WordPress
public static synchronized void bumpPushNotificationsTappedAnalytics(String noteID) {
for (int id : sActiveNotificationsMap.keySet()) {
Bundle noteBundle = sActiveNotificationsMap.get(id);
if (noteBundle.getString(PUSH_ARG_NOTE_ID, "").equals(noteID)) {
bumpPushNotificationsAnalytics(Stat.PUSH_NOTIFICATION_TAPPED, noteBundle, null);
AnalyticsTracker.flush();
return;
}
}
}
// Mark all notifications as tapped
public static synchronized void bumpPushNotificationsTappedAllAnalytics() {
for (int id : sActiveNotificationsMap.keySet()) {
Bundle noteBundle = sActiveNotificationsMap.get(id);
bumpPushNotificationsAnalytics(Stat.PUSH_NOTIFICATION_TAPPED, noteBundle, null);
}
AnalyticsTracker.flush();
}
private static void bumpPushNotificationsAnalytics(Stat stat, Bundle noteBundle,
Map<String, Object> properties) {
// Bump Analytics for PNs if "Show notifications" setting is checked (default). Skip otherwise.
if (!NotificationsUtils.isNotificationsEnabled(WordPress.getContext())) {
return;
}
if (properties == null) {
properties = new HashMap<>();
}
String notificationID = noteBundle.getString(PUSH_ARG_NOTE_ID, "");
if (!TextUtils.isEmpty(notificationID)) {
for (String currentPropertyToCopy : propertiesToCopyIntoAnalytics) {
if (noteBundle.containsKey(currentPropertyToCopy)) {
properties.put("push_notification_" + currentPropertyToCopy, noteBundle.get(currentPropertyToCopy));
}
}
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(WordPress.getContext());
String lastRegisteredGCMToken = preferences.getString(NotificationsUtils.WPCOM_PUSH_DEVICE_TOKEN, null);
properties.put("push_notification_token", lastRegisteredGCMToken);
AnalyticsTracker.track(stat, properties);
}
}
private static void addAuthPushNotificationToNotificationMap(Bundle data) {
sActiveNotificationsMap.put(AUTH_PUSH_NOTIFICATION_ID, data);
}
private static class NotificationHelper {
private void handleDefaultPush(Context context, @NonNull Bundle data, long wpcomUserId) {
String pushUserId = data.getString(PUSH_ARG_USER);
// pushUserId is always set server side, but better to double check it here.
if (!String.valueOf(wpcomUserId).equals(pushUserId)) {
AppLog.e(T.NOTIFS, "wpcom userId found in the app doesn't match with the ID in the PN. Aborting.");
return;
}
String noteType = StringUtils.notNullStr(data.getString(PUSH_ARG_TYPE));
// Check for wpcom auth push, if so we will process this push differently
if (noteType.equals(PUSH_TYPE_PUSH_AUTH)) {
addAuthPushNotificationToNotificationMap(data);
handlePushAuth(context, data);
return;
}
if (noteType.equals(PUSH_TYPE_BADGE_RESET)) {
handleBadgeResetPN(context, data);
return;
}
if (noteType.equals(PUSH_TYPE_NOTE_DELETE)) {
handleNoteDeletePN(context, data);
return;
}
buildAndShowNotificationFromNoteData(context, data);
}
private void buildAndShowNotificationFromNoteData(Context context, Bundle data) {
if (data == null) {
AppLog.e(T.NOTIFS, "Push notification received without a valid Bundle!");
return;
}
final String wpcomNoteID = data.getString(PUSH_ARG_NOTE_ID, "");
if (TextUtils.isEmpty(wpcomNoteID)) {
// At this point 'note_id' is always available in the notification bundle.
AppLog.e(T.NOTIFS, "Push notification received without a valid note_id in in payload!");
return;
}
// Try to build the note object from the PN payload, and save it to the DB.
NotificationsUtils.buildNoteObjectFromBundleAndSaveIt(data);
EventBus.getDefault().post(new NotificationEvents.NotificationsChanged(true));
// Always do this, since a note can be updated on the server after a PN is sent
NotificationsActions.downloadNoteAndUpdateDB(wpcomNoteID, null, null);
String noteType = StringUtils.notNullStr(data.getString(PUSH_ARG_TYPE));
String title = StringEscapeUtils.unescapeHtml4(data.getString(PUSH_ARG_TITLE));
if (title == null) {
title = context.getString(R.string.app_name);
}
String message = StringEscapeUtils.unescapeHtml4(data.getString(PUSH_ARG_MSG));
/*
* if this has the same note_id as the previous notification, and the previous notification
* was received within the last second, then skip showing it - this handles duplicate
* notifications being shown due to the device being registered multiple times with different tokens.
* (still investigating how this could happen - 21-Oct-13)
*
* this also handles the (rare) case where the user receives rapid-fire sub-second like notifications
* due to sudden popularity (post gets added to FP and is liked by many people all at once, etc.),
* which we also want to avoid since it would drain the battery and annoy the user
*
* NOTE: different comments on the same post will have a different note_id, but different likes
* on the same post will have the same note_id, so don't assume that the note_id is unique
*/
long thisTime = System.currentTimeMillis();
if (AppPrefs.getLastPushNotificationWpcomNoteId().equals(wpcomNoteID)) {
long seconds = TimeUnit.MILLISECONDS.toSeconds(thisTime - AppPrefs.getLastPushNotificationTime());
if (seconds <= 1) {
AppLog.w(T.NOTIFS, "skipped potential duplicate notification");
return;
}
}
AppPrefs.setLastPushNotificationTime(thisTime);
AppPrefs.setLastPushNotificationWpcomNoteId(wpcomNoteID);
// Update notification content for the same noteId if it is already showing
int pushId = 0;
for (Integer id : sActiveNotificationsMap.keySet()) {
if (id == null) {
continue;
}
Bundle noteBundle = sActiveNotificationsMap.get(id);
if (noteBundle != null && noteBundle.getString(PUSH_ARG_NOTE_ID, "").equals(wpcomNoteID)) {
pushId = id;
sActiveNotificationsMap.put(pushId, data);
break;
}
}
if (pushId == 0) {
pushId = PUSH_NOTIFICATION_ID + sActiveNotificationsMap.size();
sActiveNotificationsMap.put(pushId, data);
}
// Bump Analytics for PNs if "Show notifications" setting is checked (default). Skip otherwise.
if (NotificationsUtils.isNotificationsEnabled(context)) {
Map<String, Object> properties = new HashMap<>();
if (!TextUtils.isEmpty(noteType)) {
// 'comment' and 'comment_pingback' types are sent in PN as type = "c"
if (noteType.equals(PUSH_TYPE_COMMENT)) {
properties.put("notification_type", "comment");
} else {
properties.put("notification_type", noteType);
}
}
bumpPushNotificationsAnalytics(Stat.PUSH_NOTIFICATION_RECEIVED, data, properties);
AnalyticsTracker.flush();
}
// Build the new notification, add group to support wearable stacking
NotificationCompat.Builder builder = getNotificationBuilder(context, title, message);
Bitmap largeIconBitmap = getLargeIconBitmap(context, data.getString("icon"), shouldCircularizeNoteIcon(noteType));
if (largeIconBitmap != null) {
builder.setLargeIcon(largeIconBitmap);
}
showSingleNotificationForBuilder(context, builder, noteType, wpcomNoteID, pushId, true);
// Also add a group summary notification, which is required for non-wearable devices
// Do not need to play the sound again. We've already played it in the individual builder.
showGroupNotificationForBuilder(context, builder, wpcomNoteID, message);
}
private void addActionsForCommentNotification(Context context, NotificationCompat.Builder builder, String noteId) {
// Add some actions if this is a comment notification
boolean areActionsSet = false;
Note note = NotificationsTable.getNoteById(noteId);
if (note != null) {
//if note can be replied to, we'll always add this action first
if (note.canReply()) {
addCommentReplyActionForCommentNotification(context, builder, noteId);
}
// if the comment is lacking approval, offer moderation actions
if (note.getCommentStatus() == CommentStatus.UNAPPROVED) {
if (note.canModerate()) {
addCommentApproveActionForCommentNotification(context, builder, noteId);
}
} else {
// else offer REPLY / LIKE actions
// LIKE can only be enabled for wp.com sites, so if this is a Jetpack site don't enable LIKEs
if (note.canLike()) {
addCommentLikeActionForCommentNotification(context, builder, noteId);
}
}
areActionsSet = true;
}
// if we could not set the actions, set the default one REPLY as it's then only safe bet
// we can make at this point
if (!areActionsSet) {
addCommentReplyActionForCommentNotification(context, builder, noteId);
}
}
private void addCommentReplyActionForCommentNotification(Context context, NotificationCompat.Builder builder, String noteId) {
// adding comment reply action
Intent commentReplyIntent = getCommentActionReplyIntent(context, noteId);
commentReplyIntent.addCategory(KEY_CATEGORY_COMMENT_REPLY);
commentReplyIntent.putExtra(NotificationsProcessingService.ARG_ACTION_TYPE, NotificationsProcessingService.ARG_ACTION_REPLY);
if (noteId != null) {
commentReplyIntent.putExtra(NotificationsProcessingService.ARG_NOTE_ID, noteId);
}
commentReplyIntent.putExtra(NotificationsProcessingService.ARG_NOTE_BUNDLE, getCurrentNoteBundleForNoteId(noteId));
PendingIntent commentReplyPendingIntent = getCommentActionPendingIntent(context, commentReplyIntent);
// The following code adds the behavior for Direct reply, available on Android N (7.0) and on.
// Using backward compatibility with NotificationCompat.
String replyLabel = context.getString(R.string.reply);
RemoteInput remoteInput = new RemoteInput.Builder(EXTRA_VOICE_OR_INLINE_REPLY)
.setLabel(replyLabel)
.build();
NotificationCompat.Action action =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_grey_32dp,
context.getString(R.string.reply), commentReplyPendingIntent)
.addRemoteInput(remoteInput)
.build();
// now add the action corresponding to direct-reply
builder.addAction(action);
}
private void addCommentLikeActionForCommentNotification(Context context, NotificationCompat.Builder builder, String noteId) {
// adding comment like action
Intent commentLikeIntent = getCommentActionIntent(context);
commentLikeIntent.addCategory(KEY_CATEGORY_COMMENT_LIKE);
commentLikeIntent.putExtra(NotificationsProcessingService.ARG_ACTION_TYPE, NotificationsProcessingService.ARG_ACTION_LIKE);
if (noteId != null) {
commentLikeIntent.putExtra(NotificationsProcessingService.ARG_NOTE_ID, noteId);
}
commentLikeIntent.putExtra(NotificationsProcessingService.ARG_NOTE_BUNDLE, getCurrentNoteBundleForNoteId(noteId));
PendingIntent commentLikePendingIntent = getCommentActionPendingIntentForService(context,
commentLikeIntent);
builder.addAction(R.drawable.ic_star_grey_32dp, context.getText(R.string.like), commentLikePendingIntent);
}
private void addCommentApproveActionForCommentNotification(Context context, NotificationCompat.Builder builder, String noteId) {
// adding comment approve action
Intent commentApproveIntent = getCommentActionIntent(context);
commentApproveIntent.addCategory(KEY_CATEGORY_COMMENT_MODERATE);
commentApproveIntent.putExtra(NotificationsProcessingService.ARG_ACTION_TYPE, NotificationsProcessingService.ARG_ACTION_APPROVE);
if (noteId != null) {
commentApproveIntent.putExtra(NotificationsProcessingService.ARG_NOTE_ID, noteId);
}
commentApproveIntent.putExtra(NotificationsProcessingService.ARG_NOTE_BUNDLE, getCurrentNoteBundleForNoteId(noteId));
PendingIntent commentApprovePendingIntent = getCommentActionPendingIntentForService(context,
commentApproveIntent);
builder.addAction(R.drawable.ic_checkmark_grey_32dp, context.getText(R.string.approve),
commentApprovePendingIntent);
}
private PendingIntent getCommentActionPendingIntent(Context context, Intent intent){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return getCommentActionPendingIntentForService(context, intent);
} else {
return getCommentActionPendingIntentForActivity(context, intent);
}
}
private PendingIntent getCommentActionPendingIntentForService(Context context, Intent intent){
return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
}
private PendingIntent getCommentActionPendingIntentForActivity(Context context, Intent intent){
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
}
private Intent getCommentActionReplyIntent(Context context, String noteId){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return getCommentActionIntentForService(context);
} else {
return getCommentActionIntentForActivity(context, noteId);
}
}
private Intent getCommentActionIntent(Context context){
return getCommentActionIntentForService(context);
}
private Intent getCommentActionIntentForService(Context context){
return new Intent(context, NotificationsProcessingService.class);
}
private Intent getCommentActionIntentForActivity(Context context, String noteId){
Intent intent = new Intent(context, 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, noteId);
intent.putExtra(NotificationsListFragment.NOTE_INSTANT_REPLY_EXTRA, true);
return intent;
}
private Bitmap getLargeIconBitmap(Context context, String iconUrl, boolean shouldCircularizeIcon){
Bitmap largeIconBitmap = null;
if (iconUrl != null) {
try {
iconUrl = URLDecoder.decode(iconUrl, "UTF-8");
int largeIconSize = context.getResources().getDimensionPixelSize(
android.R.dimen.notification_large_icon_height);
String resizedUrl = PhotonUtils.getPhotonImageUrl(iconUrl, largeIconSize, largeIconSize);
largeIconBitmap = ImageUtils.downloadBitmap(resizedUrl);
if (largeIconBitmap != null && shouldCircularizeIcon) {
largeIconBitmap = ImageUtils.getCircularBitmap(largeIconBitmap);
}
} catch (UnsupportedEncodingException e) {
AppLog.e(T.NOTIFS, e);
}
}
return largeIconBitmap;
}
private NotificationCompat.Builder getNotificationBuilder(Context context, String title, String message){
// Build the new notification, add group to support wearable stacking
return new NotificationCompat.Builder(context)
.setSmallIcon(R.drawable.ic_my_sites_24dp)
.setColor(context.getResources().getColor(R.color.blue_wordpress))
.setContentTitle(title)
.setContentText(message)
.setTicker(message)
.setAutoCancel(true)
.setStyle(new NotificationCompat.BigTextStyle().bigText(message))
.setGroup(NOTIFICATION_GROUP_KEY);
}
private void showGroupNotificationForBuilder(Context context, NotificationCompat.Builder builder,
String wpcomNoteID, String message) {
if (builder == null || context == null) {
return;
}
//first remove 2fa push from the map, then reinsert it, so it's not shown in the inbox style group notif
Bundle authPNBundle = sActiveNotificationsMap.remove(AUTH_PUSH_NOTIFICATION_ID);
if (sActiveNotificationsMap.size() > 1) {
NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
int noteCtr = 1;
for (Bundle pushBundle : sActiveNotificationsMap.values()) {
// InboxStyle notification is limited to 5 lines
if (noteCtr > MAX_INBOX_ITEMS) {
break;
}
if (pushBundle == null || pushBundle.getString(PUSH_ARG_MSG) == null) {
continue;
}
if (pushBundle.getString(PUSH_ARG_TYPE, "").equals(PUSH_TYPE_COMMENT)) {
String pnTitle = StringEscapeUtils.unescapeHtml4((pushBundle.getString(PUSH_ARG_TITLE)));
String pnMessage = StringEscapeUtils.unescapeHtml4((pushBundle.getString(PUSH_ARG_MSG)));
inboxStyle.addLine(pnTitle + ": " + pnMessage);
} else {
String pnMessage = StringEscapeUtils.unescapeHtml4((pushBundle.getString(PUSH_ARG_MSG)));
inboxStyle.addLine(pnMessage);
}
noteCtr++;
}
if (sActiveNotificationsMap.size() > MAX_INBOX_ITEMS) {
inboxStyle.setSummaryText(String.format(context.getString(R.string.more_notifications),
sActiveNotificationsMap.size() - MAX_INBOX_ITEMS));
}
String subject = String.format(context.getString(R.string.new_notifications), sActiveNotificationsMap.size());
NotificationCompat.Builder groupBuilder = new NotificationCompat.Builder(context)
.setSmallIcon(R.drawable.ic_my_sites_24dp)
.setColor(context.getResources().getColor(R.color.blue_wordpress))
.setGroup(NOTIFICATION_GROUP_KEY)
.setGroupSummary(true)
.setAutoCancel(true)
.setTicker(message)
.setContentTitle(context.getString(R.string.app_name))
.setContentText(subject)
.setStyle(inboxStyle);
showNotificationForBuilder(groupBuilder, context, wpcomNoteID, GROUP_NOTIFICATION_ID, false);
} else {
// Set the individual notification we've already built as the group summary
builder.setGroupSummary(true);
showNotificationForBuilder(builder, context, wpcomNoteID, GROUP_NOTIFICATION_ID, false);
}
//reinsert 2fa bundle if it was present
if (authPNBundle != null) {
sActiveNotificationsMap.put(AUTH_PUSH_NOTIFICATION_ID, authPNBundle);
}
}
private void showSingleNotificationForBuilder(Context context, NotificationCompat.Builder builder,
String noteType, String wpcomNoteID, int pushId, boolean notifyUser) {
if (builder == null || context == null) {
return;
}
if (noteType.equals(PUSH_TYPE_COMMENT)) {
addActionsForCommentNotification(context, builder, wpcomNoteID);
}
showNotificationForBuilder(builder, context, wpcomNoteID, pushId, notifyUser);
}
// Displays a notification to the user
private void showNotificationForBuilder(NotificationCompat.Builder builder, Context context,
String wpcomNoteID, int pushId, boolean notifyUser) {
if (builder == null || context == null) {
return;
}
Intent resultIntent = new Intent(context, WPMainActivity.class);
resultIntent.putExtra(WPMainActivity.ARG_OPENED_FROM_PUSH, true);
resultIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TASK);
resultIntent.setAction("android.intent.action.MAIN");
resultIntent.addCategory("android.intent.category.LAUNCHER");
resultIntent.putExtra(NotificationsListFragment.NOTE_ID_EXTRA, wpcomNoteID);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
if (notifyUser) {
boolean shouldVibrate = prefs.getBoolean("wp_pref_notification_vibrate", false);
boolean shouldBlinkLight = prefs.getBoolean("wp_pref_notification_light", true);
String notificationSound = prefs.getString("wp_pref_custom_notification_sound", "content://settings/system/notification_sound"); //"" if None is selected
if (!TextUtils.isEmpty(notificationSound)) {
builder.setSound(Uri.parse(notificationSound));
}
if (shouldVibrate) {
builder.setVibrate(new long[]{500, 500, 500});
}
if (shouldBlinkLight) {
builder.setLights(0xff0000ff, 1000, 5000);
}
} else {
builder.setVibrate(null);
builder.setSound(null);
// Do not turn the led off otherwise the previous (single) notification led is not shown. We're re-using the same builder for single and group.
}
// Call broadcast receiver when notification is dismissed
Intent notificationDeletedIntent = new Intent(context, NotificationDismissBroadcastReceiver.class);
notificationDeletedIntent.putExtra("notificationId", pushId);
notificationDeletedIntent.setAction(String.valueOf(pushId));
PendingIntent pendingDeleteIntent =
PendingIntent.getBroadcast(context, pushId, notificationDeletedIntent, 0);
builder.setDeleteIntent(pendingDeleteIntent);
builder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
PendingIntent pendingIntent = PendingIntent.getActivity(context, pushId, resultIntent,
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_UPDATE_CURRENT);
builder.setContentIntent(pendingIntent);
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
notificationManager.notify(pushId, builder.build());
}
private void rebuildAndUpdateNotificationsOnSystemBar(Context context, Bundle data) {
String noteType = StringUtils.notNullStr(data.getString(PUSH_ARG_TYPE));
// Check for wpcom auth push, if so we will process this push differently
// and we'll remove the auth special notif out of the map while we re-build the remaining notifs
Bundle authPNBundle = sActiveNotificationsMap.remove(AUTH_PUSH_NOTIFICATION_ID);
if (authPNBundle != null) {
handlePushAuth(context, authPNBundle);
if (sActiveNotificationsMap.size() > 0 && noteType.equals(PUSH_TYPE_PUSH_AUTH)) {
//get the data for the next notification in map for re-build
//because otherwise we would be keeping the PUSH_AUTH type note in `data`
data = sActiveNotificationsMap.values().iterator().next();
} else if (noteType.equals(PUSH_TYPE_PUSH_AUTH)) {
//only note is the 2fa note, just reinsert it in the map and return
sActiveNotificationsMap.put(AUTH_PUSH_NOTIFICATION_ID, authPNBundle);
return;
}
}
Bitmap largeIconBitmap = null;
// here notify the existing group notification by eliminating the line that is now gone
String title = getNotificationTitleOrAppNameFromBundle(context, data);
String message = StringEscapeUtils.unescapeHtml4(data.getString(PUSH_ARG_MSG));
NotificationCompat.Builder builder = null;
String wpcomNoteID = null;
if (sActiveNotificationsMap.size() == 1) {
//only one notification remains, so get the proper message for it and re-instate in the system dashboard
Bundle remainingNote = sActiveNotificationsMap.values().iterator().next();
if (remainingNote != null) {
String remainingNoteTitle = StringEscapeUtils.unescapeHtml4(remainingNote.getString(PUSH_ARG_TITLE));
if (!TextUtils.isEmpty(remainingNoteTitle)) {
title = remainingNoteTitle;
}
String remainingNoteMessage = StringEscapeUtils.unescapeHtml4(remainingNote.getString(PUSH_ARG_MSG));
if (!TextUtils.isEmpty(remainingNoteMessage)) {
message = remainingNoteMessage;
}
largeIconBitmap = getLargeIconBitmap(context, remainingNote.getString("icon"),
shouldCircularizeNoteIcon(remainingNote.getString(PUSH_ARG_TYPE)));
builder = getNotificationBuilder(context, title, message);
// set timestamp for note: first try with the notification timestamp, then try google's sent time
// if not available; finally just set the system's current time if everything else fails (not likely)
long timeStampToShow =
DateTimeUtils.timestampFromIso8601Millis(remainingNote.getString("note_timestamp"));
timeStampToShow = timeStampToShow != 0 ? timeStampToShow :
remainingNote.getLong("google.sent_time", System.currentTimeMillis());
builder.setWhen(timeStampToShow);
noteType = StringUtils.notNullStr(remainingNote.getString(PUSH_ARG_TYPE));
wpcomNoteID = remainingNote.getString(PUSH_ARG_NOTE_ID, "");
if (!sActiveNotificationsMap.isEmpty()) {
showSingleNotificationForBuilder(context, builder, noteType, wpcomNoteID,
sActiveNotificationsMap.keyAt(0), false);
}
}
}
if (builder == null) {
builder = getNotificationBuilder(context, title, message);
}
if (largeIconBitmap == null) {
largeIconBitmap = getLargeIconBitmap(context, data.getString("icon"), shouldCircularizeNoteIcon(PUSH_TYPE_BADGE_RESET));
}
if (wpcomNoteID == null) {
wpcomNoteID = AppPrefs.getLastPushNotificationWpcomNoteId();
}
if (largeIconBitmap != null) {
builder.setLargeIcon(largeIconBitmap);
}
showGroupNotificationForBuilder(context, builder, wpcomNoteID, message);
//reinsert 2fa bundle if it was present
if (authPNBundle != null) {
sActiveNotificationsMap.put(AUTH_PUSH_NOTIFICATION_ID, authPNBundle);
}
}
private String getNotificationTitleOrAppNameFromBundle(Context context, Bundle data){
String title = StringEscapeUtils.unescapeHtml4(data.getString(PUSH_ARG_TITLE));
if (title == null) {
title = context.getString(R.string.app_name);
}
return title;
}
// Clear all notifications
private void handleBadgeResetPN(Context context, Bundle data) {
if (data == null || !data.containsKey(PUSH_ARG_NOTE_ID)) {
// ignore the reset-badge PN if it's a global one
return;
}
String noteID = data.getString(PUSH_ARG_NOTE_ID, "");
if (!TextUtils.isEmpty(noteID)) {
Note note = NotificationsTable.getNoteById(noteID);
// mark the note as read if it's unread and update the DB silently
if (note != null && note.isUnread()) {
note.setRead();
NotificationsTable.saveNote(note);
}
}
removeNotificationWithNoteIdFromSystemBar(context, noteID);
//now that we cleared the specific notif, we can check and make any visual updates
if (sActiveNotificationsMap.size() > 0) {
rebuildAndUpdateNotificationsOnSystemBar(context, data);
}
EventBus.getDefault().post(new NotificationEvents.NotificationsChanged(sActiveNotificationsMap.size() > 0));
}
private void handleNoteDeletePN(Context context, Bundle data) {
if (data == null || !data.containsKey(PUSH_ARG_NOTE_ID)) {
return;
}
String noteID = data.getString(PUSH_ARG_NOTE_ID, "");
if (!TextUtils.isEmpty(noteID)) {
NotificationsTable.deleteNoteById(noteID);
}
removeNotificationWithNoteIdFromSystemBar(context, noteID);
//now that we cleared the specific notif, we can check and make any visual updates
if (sActiveNotificationsMap.size() > 0) {
rebuildAndUpdateNotificationsOnSystemBar(context, data);
}
EventBus.getDefault().post(new NotificationEvents.NotificationsChanged(sActiveNotificationsMap.size() > 0));
}
// Show a notification for two-step auth users who log in from a web browser
private void handlePushAuth(Context context, Bundle data) {
if (data == null) {
return;
}
String pushAuthToken = data.getString("push_auth_token", "");
String title = data.getString("title", "");
String message = data.getString("msg", "");
long expirationTimestamp = Long.valueOf(data.getString("expires", "0"));
// No strings, no service
if (TextUtils.isEmpty(pushAuthToken) || TextUtils.isEmpty(title) || TextUtils.isEmpty(message)) {
return;
}
// Show authorization intent
Intent pushAuthIntent = new Intent(context, WPMainActivity.class);
pushAuthIntent.putExtra(WPMainActivity.ARG_OPENED_FROM_PUSH, true);
pushAuthIntent.putExtra(NotificationsUtils.ARG_PUSH_AUTH_TOKEN, pushAuthToken);
pushAuthIntent.putExtra(NotificationsUtils.ARG_PUSH_AUTH_TITLE, title);
pushAuthIntent.putExtra(NotificationsUtils.ARG_PUSH_AUTH_MESSAGE, message);
pushAuthIntent.putExtra(NotificationsUtils.ARG_PUSH_AUTH_EXPIRES, expirationTimestamp);
pushAuthIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TASK);
pushAuthIntent.setAction("android.intent.action.MAIN");
pushAuthIntent.addCategory("android.intent.category.LAUNCHER");
NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
.setSmallIcon(R.drawable.ic_my_sites_24dp)
.setColor(context.getResources().getColor(R.color.blue_wordpress))
.setContentTitle(title)
.setContentText(message)
.setAutoCancel(true)
.setStyle(new NotificationCompat.BigTextStyle().bigText(message))
.setPriority(NotificationCompat.PRIORITY_MAX);
PendingIntent pendingIntent = PendingIntent.getActivity(context, AUTH_PUSH_REQUEST_CODE_OPEN_DIALOG, pushAuthIntent,
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_UPDATE_CURRENT);
builder.setContentIntent(pendingIntent);
// adding ignore / approve quick actions
Intent authApproveIntent = new Intent(context, WPMainActivity.class);
authApproveIntent.putExtra(WPMainActivity.ARG_OPENED_FROM_PUSH, true);
authApproveIntent.putExtra(NotificationsProcessingService.ARG_ACTION_TYPE, NotificationsProcessingService.ARG_ACTION_AUTH_APPROVE);
authApproveIntent.putExtra(NotificationsUtils.ARG_PUSH_AUTH_TOKEN, pushAuthToken);
authApproveIntent.putExtra(NotificationsUtils.ARG_PUSH_AUTH_TITLE, title);
authApproveIntent.putExtra(NotificationsUtils.ARG_PUSH_AUTH_MESSAGE, message);
authApproveIntent.putExtra(NotificationsUtils.ARG_PUSH_AUTH_EXPIRES, expirationTimestamp);
authApproveIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TASK);
authApproveIntent.setAction("android.intent.action.MAIN");
authApproveIntent.addCategory("android.intent.category.LAUNCHER");
PendingIntent authApprovePendingIntent = PendingIntent.getActivity(context, AUTH_PUSH_REQUEST_CODE_APPROVE, authApproveIntent,
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_UPDATE_CURRENT);
builder.addAction(R.drawable.ic_checkmark_grey_32dp, context.getText(R.string.approve), authApprovePendingIntent);
Intent authIgnoreIntent = new Intent(context, NotificationsProcessingService.class);
authIgnoreIntent.putExtra(NotificationsProcessingService.ARG_ACTION_TYPE, NotificationsProcessingService.ARG_ACTION_AUTH_IGNORE);
PendingIntent authIgnorePendingIntent = PendingIntent.getService(context,
AUTH_PUSH_REQUEST_CODE_IGNORE, authIgnoreIntent, PendingIntent.FLAG_CANCEL_CURRENT);
builder.addAction(R.drawable.ic_close_white_24dp, context.getText(R.string.ignore), authIgnorePendingIntent);
// Call broadcast receiver when notification is dismissed
Intent notificationDeletedIntent = new Intent(context, NotificationDismissBroadcastReceiver.class);
notificationDeletedIntent.putExtra("notificationId", AUTH_PUSH_NOTIFICATION_ID);
notificationDeletedIntent.setAction(String.valueOf(AUTH_PUSH_NOTIFICATION_ID));
PendingIntent pendingDeleteIntent =
PendingIntent.getBroadcast(context, AUTH_PUSH_NOTIFICATION_ID, notificationDeletedIntent, 0);
builder.setDeleteIntent(pendingDeleteIntent);
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
notificationManager.notify(AUTH_PUSH_NOTIFICATION_ID, builder.build());
}
// Returns true if the note type is known to have a gravatar
public boolean shouldCircularizeNoteIcon(String noteType) {
if (TextUtils.isEmpty(noteType)) {
return false;
}
switch (noteType) {
case PUSH_TYPE_COMMENT:
case PUSH_TYPE_LIKE:
case PUSH_TYPE_COMMENT_LIKE:
case PUSH_TYPE_AUTOMATTCHER:
case PUSH_TYPE_FOLLOW:
case PUSH_TYPE_REBLOG:
return true;
default:
return false;
}
}
}
}