/* * Copyright (C) 2011 Whisper Systems * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.thoughtcrime.securesms.notifications; import android.annotation.SuppressLint; import android.app.AlarmManager; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.media.AudioAttributes; import android.media.AudioManager; import android.media.Ringtone; import android.media.RingtoneManager; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.service.notification.StatusBarNotification; import android.support.annotation.NonNull; import android.support.v4.app.NotificationCompat; import android.support.v4.app.NotificationManagerCompat; import android.text.TextUtils; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.conversation.ConversationActivity; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.service.IncomingMessageObserver; import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.SpanUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder; import org.whispersystems.signalservice.internal.util.Util; import java.util.HashSet; import java.util.List; import java.util.ListIterator; import java.util.Set; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import me.leolin.shortcutbadger.ShortcutBadger; import network.loki.messenger.R; /** * Handles posting system notifications for new messages. * * * @author Moxie Marlinspike */ public class MessageNotifier { private static final String TAG = MessageNotifier.class.getSimpleName(); public static final String EXTRA_REMOTE_REPLY = "extra_remote_reply"; private static final int SUMMARY_NOTIFICATION_ID = 1338; private static final int PENDING_MESSAGES_ID = 1111; private static final String NOTIFICATION_GROUP = "messages"; private static final long MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(2); private static final long DESKTOP_ACTIVITY_PERIOD = TimeUnit.MINUTES.toMillis(1); private volatile static long visibleThread = -1; private volatile static long lastDesktopActivityTimestamp = -1; private volatile static long lastAudibleNotification = -1; private static final CancelableExecutor executor = new CancelableExecutor(); public static void setVisibleThread(long threadId) { visibleThread = threadId; } public static void setLastDesktopActivityTimestamp(long timestamp) { lastDesktopActivityTimestamp = timestamp; } public static void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId) { if (visibleThread == threadId) { sendInThreadNotification(context, recipient); } else { Intent intent = new Intent(context, ConversationActivity.class); intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.getAddress()); intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId); intent.setData((Uri.parse("custom://" + System.currentTimeMillis()))); FailedNotificationBuilder builder = new FailedNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context), intent); ((NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE)) .notify((int)threadId, builder.build()); } } public static void notifyMessagesPending(Context context) { if (!TextSecurePreferences.isNotificationsEnabled(context)) { return; } PendingMessageNotificationBuilder builder = new PendingMessageNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context)); ServiceUtil.getNotificationManager(context).notify(PENDING_MESSAGES_ID, builder.build()); } public static void cancelDelayedNotifications() { executor.cancel(); } private static void cancelActiveNotifications(@NonNull Context context) { NotificationManager notifications = ServiceUtil.getNotificationManager(context); notifications.cancel(SUMMARY_NOTIFICATION_ID); if (Build.VERSION.SDK_INT >= 23) { try { StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); for (StatusBarNotification activeNotification : activeNotifications) { if (activeNotification.getId() != CallNotificationBuilder.WEBRTC_NOTIFICATION) { notifications.cancel(activeNotification.getId()); } } } catch (Throwable e) { // XXX Appears to be a ROM bug, see #6043 Log.w(TAG, e); notifications.cancelAll(); } } } private static void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) { if (Build.VERSION.SDK_INT >= 23) { try { NotificationManager notifications = ServiceUtil.getNotificationManager(context); StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); for (StatusBarNotification notification : activeNotifications) { boolean validNotification = false; if (notification.getId() != SUMMARY_NOTIFICATION_ID && notification.getId() != CallNotificationBuilder.WEBRTC_NOTIFICATION && notification.getId() != KeyCachingService.SERVICE_RUNNING_ID && notification.getId() != IncomingMessageObserver.FOREGROUND_ID && notification.getId() != PENDING_MESSAGES_ID) { for (NotificationItem item : notificationState.getNotifications()) { if (notification.getId() == (SUMMARY_NOTIFICATION_ID + item.getThreadId())) { validNotification = true; break; } } if (!validNotification) { notifications.cancel(notification.getId()); } } } } catch (Throwable e) { // XXX Android ROM Bug, see #6043 Log.w(TAG, e); } } } public static void updateNotification(@NonNull Context context) { if (!TextSecurePreferences.isNotificationsEnabled(context)) { return; } updateNotification(context, false, 0); } public static void updateNotification(@NonNull Context context, long threadId) { if (System.currentTimeMillis() - lastDesktopActivityTimestamp < DESKTOP_ACTIVITY_PERIOD) { Log.i(TAG, "Scheduling delayed notification..."); executor.execute(new DelayedNotification(context, threadId)); } else { updateNotification(context, threadId, true); } } public static void updateNotification(@NonNull Context context, long threadId, boolean signal) { boolean isVisible = visibleThread == threadId; ThreadDatabase threads = DatabaseFactory.getThreadDatabase(context); Recipient recipients = DatabaseFactory.getThreadDatabase(context) .getRecipientForThreadId(threadId); if (isVisible) { List messageIds = threads.setRead(threadId, false); MarkReadReceiver.process(context, messageIds); } if (!TextSecurePreferences.isNotificationsEnabled(context) || (recipients != null && recipients.isMuted())) { return; } if (isVisible) { sendInThreadNotification(context, threads.getRecipientForThreadId(threadId)); } else { updateNotification(context, signal, 0); } } private static void updateNotification(@NonNull Context context, boolean signal, int reminderCount) { Cursor telcoCursor = null; Cursor pushCursor = null; try { telcoCursor = DatabaseFactory.getMmsSmsDatabase(context).getUnread(); pushCursor = DatabaseFactory.getPushDatabase(context).getPending(); if ((telcoCursor == null || telcoCursor.isAfterLast()) && (pushCursor == null || pushCursor.isAfterLast())) { cancelActiveNotifications(context); updateBadge(context, 0); clearReminder(context); return; } NotificationState notificationState = constructNotificationState(context, telcoCursor); if (signal && (System.currentTimeMillis() - lastAudibleNotification) < MIN_AUDIBLE_PERIOD_MILLIS) { signal = false; } else if (signal) { lastAudibleNotification = System.currentTimeMillis(); } if (notificationState.hasMultipleThreads()) { if (Build.VERSION.SDK_INT >= 23) { for (long threadId : notificationState.getThreads()) { sendSingleThreadNotification(context, new NotificationState(notificationState.getNotificationsForThread(threadId)), false, true); } } sendMultipleThreadNotification(context, notificationState, signal); } else { sendSingleThreadNotification(context, notificationState, signal, false); } cancelOrphanedNotifications(context, notificationState); updateBadge(context, notificationState.getMessageCount()); if (signal) { scheduleReminder(context, reminderCount); } } finally { if (telcoCursor != null) telcoCursor.close(); if (pushCursor != null) pushCursor.close(); } } private static void sendSingleThreadNotification(@NonNull Context context, @NonNull NotificationState notificationState, boolean signal, boolean bundled) { Log.i(TAG, "sendSingleThreadNotification() signal: " + signal + " bundled: " + bundled); if (notificationState.getNotifications().isEmpty()) { if (!bundled) cancelActiveNotifications(context); Log.i(TAG, "Empty notification state. Skipping."); return; } SingleRecipientNotificationBuilder builder = new SingleRecipientNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context)); List notifications = notificationState.getNotifications(); Recipient recipient = notifications.get(0).getRecipient(); int notificationId = (int) (SUMMARY_NOTIFICATION_ID + (bundled ? notifications.get(0).getThreadId() : 0)); builder.setThread(notifications.get(0).getRecipient()); builder.setMessageCount(notificationState.getMessageCount()); builder.setPrimaryMessageBody(recipient, notifications.get(0).getIndividualRecipient(), notifications.get(0).getText(), notifications.get(0).getSlideDeck()); builder.setContentIntent(notifications.get(0).getPendingIntent(context)); builder.setDeleteIntent(notificationState.getDeleteIntent(context)); builder.setOnlyAlertOnce(!signal); builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); long timestamp = notifications.get(0).getTimestamp(); if (timestamp != 0) builder.setWhen(timestamp); ReplyMethod replyMethod = ReplyMethod.forRecipient(context, recipient); builder.addActions(notificationState.getMarkAsReadIntent(context, notificationId), /*notificationState.getQuickReplyIntent(context, notifications.get(0).getRecipient()),*/ notificationState.getRemoteReplyIntent(context, notifications.get(0).getRecipient(), replyMethod), replyMethod); builder.addAndroidAutoAction(notificationState.getAndroidAutoReplyIntent(context, notifications.get(0).getRecipient()), notificationState.getAndroidAutoHeardIntent(context, notificationId), notifications.get(0).getTimestamp()); ListIterator iterator = notifications.listIterator(notifications.size()); while(iterator.hasPrevious()) { NotificationItem item = iterator.previous(); builder.addMessageBody(item.getRecipient(), item.getIndividualRecipient(), item.getText()); } if (signal) { builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); builder.setTicker(notifications.get(0).getIndividualRecipient(), notifications.get(0).getText()); } if (bundled) { builder.setGroup(NOTIFICATION_GROUP); builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); } Notification notification = builder.build(); NotificationManagerCompat.from(context).notify(notificationId, notification); Log.i(TAG, "Posted notification. " + notification.toString()); } private static void sendMultipleThreadNotification(@NonNull Context context, @NonNull NotificationState notificationState, boolean signal) { Log.i(TAG, "sendMultiThreadNotification() signal: " + signal); MultipleRecipientNotificationBuilder builder = new MultipleRecipientNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context)); List notifications = notificationState.getNotifications(); builder.setMessageCount(notificationState.getMessageCount(), notificationState.getThreadCount()); builder.setMostRecentSender(notifications.get(0).getIndividualRecipient()); builder.setGroup(NOTIFICATION_GROUP); builder.setDeleteIntent(notificationState.getDeleteIntent(context)); builder.setOnlyAlertOnce(!signal); builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); long timestamp = notifications.get(0).getTimestamp(); if (timestamp != 0) builder.setWhen(timestamp); builder.addActions(notificationState.getMarkAsReadIntent(context, SUMMARY_NOTIFICATION_ID)); ListIterator iterator = notifications.listIterator(notifications.size()); while(iterator.hasPrevious()) { NotificationItem item = iterator.previous(); builder.addMessageBody(item.getIndividualRecipient(), item.getText()); } if (signal) { builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); builder.setTicker(notifications.get(0).getIndividualRecipient(), notifications.get(0).getText()); } Notification notification = builder.build(); NotificationManagerCompat.from(context).notify(SUMMARY_NOTIFICATION_ID, builder.build()); Log.i(TAG, "Posted notification. " + notification.toString()); } private static void sendInThreadNotification(Context context, Recipient recipient) { // Mute group chats if (recipient.isGroupRecipient()) { return; } if (!TextSecurePreferences.isInThreadNotifications(context) || ServiceUtil.getAudioManager(context).getRingerMode() != AudioManager.RINGER_MODE_NORMAL) { return; } Uri uri = null; if (recipient != null) { uri = NotificationChannels.supported() ? NotificationChannels.getMessageRingtone(context, recipient) : recipient.getMessageRingtone(); } if (uri == null) { uri = NotificationChannels.supported() ? NotificationChannels.getMessageRingtone(context) : TextSecurePreferences.getNotificationRingtone(context); } if (uri.toString().isEmpty()) { Log.d(TAG, "ringtone uri is empty"); return; } Ringtone ringtone = RingtoneManager.getRingtone(context, uri); if (ringtone == null) { Log.w(TAG, "ringtone is null"); return; } if (Build.VERSION.SDK_INT >= 21) { ringtone.setAudioAttributes(new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) .build()); } else { ringtone.setStreamType(AudioManager.STREAM_NOTIFICATION); } ringtone.play(); } private static NotificationState constructNotificationState(@NonNull Context context, @NonNull Cursor cursor) { NotificationState notificationState = new NotificationState(); MmsSmsDatabase.Reader reader = DatabaseFactory.getMmsSmsDatabase(context).readerFor(cursor); MessageRecord record; while ((record = reader.getNext()) != null) { long id = record.getId(); boolean mms = record.isMms() || record.isMmsNotification(); Recipient recipient = record.getIndividualRecipient(); Recipient conversationRecipient = record.getRecipient(); long threadId = record.getThreadId(); CharSequence body = record.getDisplayBody(context); Recipient threadRecipients = null; SlideDeck slideDeck = null; long timestamp = record.getTimestamp(); if (threadId != -1) { threadRecipients = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); } if (KeyCachingService.isLocked(context)) { body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message)); } else if (record.isMms() && !((MmsMessageRecord) record).getSharedContacts().isEmpty()) { Contact contact = ((MmsMessageRecord) record).getSharedContacts().get(0); body = ContactUtil.getStringSummary(context, contact); } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) { body = SpanUtil.italic(context.getString(R.string.MessageNotifier_sticker)); slideDeck = ((MmsMessageRecord) record).getSlideDeck(); } else if (record.isMms() && TextUtils.isEmpty(body) && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { body = SpanUtil.italic(context.getString(R.string.MessageNotifier_media_message)); slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); } else if (record.isMms() && !record.isMmsNotification() && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { String message = context.getString(R.string.MessageNotifier_media_message_with_text, body); int italicLength = message.length() - body.length(); body = SpanUtil.italic(message, italicLength); slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); } if ((threadRecipients == null || !threadRecipients.isMuted()) && (threadRecipients == null || !threadRecipients.isGroupRecipient())) { notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)); } } reader.close(); return notificationState; } private static void updateBadge(Context context, int count) { try { if (count == 0) ShortcutBadger.removeCount(context); else ShortcutBadger.applyCount(context, count); } catch (Throwable t) { // NOTE :: I don't totally trust this thing, so I'm catching // everything. Log.w("MessageNotifier", t); } } private static void scheduleReminder(Context context, int count) { if (count >= TextSecurePreferences.getRepeatAlertsCount(context)) { return; } AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); Intent alarmIntent = new Intent(ReminderReceiver.REMINDER_ACTION); alarmIntent.putExtra("reminder_count", count); PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT); long timeout = TimeUnit.MINUTES.toMillis(2); alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeout, pendingIntent); } public static void clearReminder(Context context) { Intent alarmIntent = new Intent(ReminderReceiver.REMINDER_ACTION); PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT); AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); alarmManager.cancel(pendingIntent); } public static class ReminderReceiver extends BroadcastReceiver { public static final String REMINDER_ACTION = "org.thoughtcrime.securesms.MessageNotifier.REMINDER_ACTION"; @SuppressLint("StaticFieldLeak") @Override public void onReceive(final Context context, final Intent intent) { new AsyncTask() { @Override protected Void doInBackground(Void... params) { int reminderCount = intent.getIntExtra("reminder_count", 0); MessageNotifier.updateNotification(context, true, reminderCount + 1); return null; } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } } private static class DelayedNotification implements Runnable { private static final long DELAY = TimeUnit.SECONDS.toMillis(5); private final AtomicBoolean canceled = new AtomicBoolean(false); private final Context context; private final long threadId; private final long delayUntil; private DelayedNotification(Context context, long threadId) { this.context = context; this.threadId = threadId; this.delayUntil = System.currentTimeMillis() + DELAY; } @Override public void run() { long delayMillis = delayUntil - System.currentTimeMillis(); Log.i(TAG, "Waiting to notify: " + delayMillis); if (delayMillis > 0) { Util.sleep(delayMillis); } if (!canceled.get()) { Log.i(TAG, "Not canceled, notifying..."); MessageNotifier.updateNotification(context, threadId, true); MessageNotifier.cancelDelayedNotifications(); } else { Log.w(TAG, "Canceled, not notifying..."); } } public void cancel() { canceled.set(true); } } private static class CancelableExecutor { private final Executor executor = Executors.newSingleThreadExecutor(); private final Set tasks = new HashSet<>(); public void execute(final DelayedNotification runnable) { synchronized (tasks) { tasks.add(runnable); } Runnable wrapper = new Runnable() { @Override public void run() { runnable.run(); synchronized (tasks) { tasks.remove(runnable); } } }; executor.execute(wrapper); } public void cancel() { synchronized (tasks) { for (DelayedNotification task : tasks) { task.cancel(); } } } } }