/** * 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 <http://www.gnu.org/licenses/>. */ package org.thoughtcrime.securesms.notifications; import android.app.AlarmManager; 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.annotation.Nullable; import android.support.v4.app.NotificationManagerCompat; import android.text.TextUtils; import android.util.Log; import org.thoughtcrime.securesms.ConversationActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipients; import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.service.MessageRetrievalService; 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; /** * 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 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, Recipients recipients, long threadId) { if (visibleThread == threadId) { sendInThreadNotification(context, recipients); } else { Intent intent = new Intent(context, ConversationActivity.class); intent.putExtra(ConversationActivity.RECIPIENTS_EXTRA, recipients.getIds()); 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 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() != MessageRetrievalService.FOREGROUND_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, @Nullable MasterSecret masterSecret) { if (!TextSecurePreferences.isNotificationsEnabled(context)) { return; } updateNotification(context, masterSecret, false, 0); } public static void updateNotification(@NonNull Context context, @Nullable MasterSecret masterSecret, long threadId) { if (System.currentTimeMillis() - lastDesktopActivityTimestamp < DESKTOP_ACTIVITY_PERIOD) { Log.w(TAG, "Scheduling delayed notification..."); executor.execute(new DelayedNotification(context, masterSecret, threadId)); } else { updateNotification(context, masterSecret, threadId, true); } } public static void updateNotification(@NonNull Context context, @Nullable MasterSecret masterSecret, long threadId, boolean signal) { boolean isVisible = visibleThread == threadId; ThreadDatabase threads = DatabaseFactory.getThreadDatabase(context); Recipients recipients = DatabaseFactory.getThreadDatabase(context) .getRecipientsForThreadId(threadId); if (isVisible) { List<MarkedMessageInfo> messageIds = threads.setRead(threadId, false); MarkReadReceiver.process(context, messageIds); } if (!TextSecurePreferences.isNotificationsEnabled(context) || (recipients != null && recipients.isMuted())) { return; } if (isVisible) { sendInThreadNotification(context, threads.getRecipientsForThreadId(threadId)); } else { updateNotification(context, masterSecret, signal, 0); } } private static void updateNotification(@NonNull Context context, @Nullable MasterSecret masterSecret, 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, masterSecret, 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, masterSecret, new NotificationState(notificationState.getNotificationsForThread(threadId)), false, true); } } sendMultipleThreadNotification(context, notificationState, signal); } else { sendSingleThreadNotification(context, masterSecret, 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, @Nullable MasterSecret masterSecret, @NonNull NotificationState notificationState, boolean signal, boolean bundled) { if (notificationState.getNotifications().isEmpty()) { if (!bundled) cancelActiveNotifications(context); return; } SingleRecipientNotificationBuilder builder = new SingleRecipientNotificationBuilder(context, masterSecret, TextSecurePreferences.getNotificationPrivacy(context)); List<NotificationItem> notifications = notificationState.getNotifications(); Recipients recipients = notifications.get(0).getRecipients(); int notificationId = (int) (SUMMARY_NOTIFICATION_ID + (bundled ? notifications.get(0).getThreadId() : 0)); builder.setThread(notifications.get(0).getRecipients()); builder.setMessageCount(notificationState.getMessageCount()); builder.setPrimaryMessageBody(recipients, notifications.get(0).getIndividualRecipient(), notifications.get(0).getText(), notifications.get(0).getSlideDeck()); builder.setContentIntent(notifications.get(0).getPendingIntent(context)); builder.setGroup(NOTIFICATION_GROUP); builder.setDeleteIntent(notificationState.getDeleteIntent(context)); long timestamp = notifications.get(0).getTimestamp(); if (timestamp != 0) builder.setWhen(timestamp); builder.addActions(masterSecret, notificationState.getMarkAsReadIntent(context, notificationId), notificationState.getQuickReplyIntent(context, notifications.get(0).getRecipients()), notificationState.getRemoteReplyIntent(context, notifications.get(0).getRecipients())); builder.addAndroidAutoAction(notificationState.getAndroidAutoReplyIntent(context, notifications.get(0).getRecipients()), notificationState.getAndroidAutoHeardIntent(context, notificationId), notifications.get(0).getTimestamp()); ListIterator<NotificationItem> iterator = notifications.listIterator(notifications.size()); while(iterator.hasPrevious()) { NotificationItem item = iterator.previous(); builder.addMessageBody(item.getRecipients(), item.getIndividualRecipient(), item.getText()); } if (signal) { builder.setAlarms(notificationState.getRingtone(), notificationState.getVibrate()); builder.setTicker(notifications.get(0).getIndividualRecipient(), notifications.get(0).getText()); } if (!bundled) { builder.setGroupSummary(true); } NotificationManagerCompat.from(context).notify(notificationId, builder.build()); } private static void sendMultipleThreadNotification(@NonNull Context context, @NonNull NotificationState notificationState, boolean signal) { MultipleRecipientNotificationBuilder builder = new MultipleRecipientNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context)); List<NotificationItem> notifications = notificationState.getNotifications(); builder.setMessageCount(notificationState.getMessageCount(), notificationState.getThreadCount()); builder.setMostRecentSender(notifications.get(0).getIndividualRecipient()); builder.setGroup(NOTIFICATION_GROUP); builder.setDeleteIntent(notificationState.getDeleteIntent(context)); long timestamp = notifications.get(0).getTimestamp(); if (timestamp != 0) builder.setWhen(timestamp); builder.addActions(notificationState.getMarkAsReadIntent(context, SUMMARY_NOTIFICATION_ID)); ListIterator<NotificationItem> iterator = notifications.listIterator(notifications.size()); while(iterator.hasPrevious()) { NotificationItem item = iterator.previous(); builder.addMessageBody(item.getIndividualRecipient(), item.getText()); } if (signal) { builder.setAlarms(notificationState.getRingtone(), notificationState.getVibrate()); builder.setTicker(notifications.get(0).getIndividualRecipient(), notifications.get(0).getText()); } NotificationManagerCompat.from(context).notify(SUMMARY_NOTIFICATION_ID, builder.build()); } private static void sendInThreadNotification(Context context, Recipients recipients) { if (!TextSecurePreferences.isInThreadNotifications(context) || ServiceUtil.getAudioManager(context).getRingerMode() != AudioManager.RINGER_MODE_NORMAL) { return; } Uri uri = recipients != null ? recipients.getRingtone() : null; if (uri == null) { String ringtone = TextSecurePreferences.getNotificationRingtone(context); if (ringtone == null) { Log.w(TAG, "ringtone preference was null."); return; } uri = Uri.parse(ringtone); if (uri == null) { Log.w(TAG, "couldn't parse ringtone uri " + ringtone); return; } } 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, @Nullable MasterSecret masterSecret, @NonNull Cursor cursor) { NotificationState notificationState = new NotificationState(); MessageRecord record; MmsSmsDatabase.Reader reader; if (masterSecret == null) reader = DatabaseFactory.getMmsSmsDatabase(context).readerFor(cursor); else reader = DatabaseFactory.getMmsSmsDatabase(context).readerFor(cursor, masterSecret); while ((record = reader.getNext()) != null) { long id = record.getId(); boolean mms = record.isMms() || record.isMmsNotification(); Recipient recipient = record.getIndividualRecipient(); Recipients recipients = record.getRecipients(); long threadId = record.getThreadId(); CharSequence body = record.getDisplayBody(); Recipients threadRecipients = null; SlideDeck slideDeck = null; long timestamp = record.getTimestamp(); if (threadId != -1) { threadRecipients = DatabaseFactory.getThreadDatabase(context).getRecipientsForThreadId(threadId); } if (SmsDatabase.Types.isDecryptInProgressType(record.getType()) || !record.getBody().isPlaintext()) { body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message)); } else if (record.isMms() && TextUtils.isEmpty(body)) { body = SpanUtil.italic(context.getString(R.string.MessageNotifier_media_message)); slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); } else if (record.isMms() && !record.isMmsNotification()) { 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()) { notificationState.addNotification(new NotificationItem(id, mms, recipient, recipients, 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"; @Override public void onReceive(final Context context, final Intent intent) { new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { MasterSecret masterSecret = KeyCachingService.getMasterSecret(context); int reminderCount = intent.getIntExtra("reminder_count", 0); MessageNotifier.updateNotification(context, masterSecret, true, reminderCount + 1); return null; } }.execute(); } } 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 MasterSecret masterSecret; private final long threadId; private final long delayUntil; private DelayedNotification(Context context, MasterSecret masterSecret, long threadId) { this.context = context; this.masterSecret = masterSecret; this.threadId = threadId; this.delayUntil = System.currentTimeMillis() + DELAY; } @Override public void run() { MessageNotifier.updateNotification(context, masterSecret); long delayMillis = delayUntil - System.currentTimeMillis(); Log.w(TAG, "Waiting to notify: " + delayMillis); if (delayMillis > 0) { Util.sleep(delayMillis); } if (!canceled.get()) { Log.w(TAG, "Not canceled, notifying..."); MessageNotifier.updateNotification(context, masterSecret, 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<DelayedNotification> 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(); } } } } }