From 0a8c62e0e3d574a0a0b2be0d0f6d098935fa2686 Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Fri, 8 Feb 2013 11:57:54 -0800 Subject: [PATCH] Include incoming message body in notifications. 1) Refactor the master secret reset logic to properly interact with services. 2) Add support for "BigText" and "Inbox" style notifications. 3) Decrypt message bodies when unlocked, display 'encrypted' when locked. --- res/values/strings.xml | 12 +- .../securesms/ConversationActivity.java | 6 +- .../securesms/ConversationListActivity.java | 25 +- .../securesms/ConversationListFragment.java | 4 +- .../securesms/crypto/DecryptingQueue.java | 6 +- .../notifications/MessageNotifier.java | 321 ++++++++++++++++++ .../notifications/NotificationItem.java | 78 +++++ .../notifications/NotificationState.java | 39 +++ .../securesms/service/KeyCachingService.java | 33 +- .../securesms/service/MessageNotifier.java | 269 --------------- .../securesms/service/MmsReceiver.java | 3 +- .../securesms/service/SendReceiveService.java | 86 ++++- .../securesms/service/SmsReceiver.java | 3 +- src/org/thoughtcrime/securesms/util/Util.java | 22 ++ 14 files changed, 597 insertions(+), 310 deletions(-) create mode 100644 src/org/thoughtcrime/securesms/notifications/MessageNotifier.java create mode 100644 src/org/thoughtcrime/securesms/notifications/NotificationItem.java create mode 100644 src/org/thoughtcrime/securesms/notifications/NotificationState.java delete mode 100644 src/org/thoughtcrime/securesms/service/MessageNotifier.java diff --git a/res/values/strings.xml b/res/values/strings.xml index 1aa775b482..90054a114f 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -208,7 +208,7 @@ Decrypting, please wait... Message encrypted for non-existing session... Decryption error: local message corrupted, MAC doesn\'t match. Potential tampering? - + Connecting to MMS server... Downloading MMS... @@ -230,9 +230,13 @@ Passphrase Cached - (%d) New messages - (%1$d) New messages, most recent from: %2$s - Most recent from: %s + %d new messages + Most recent from: %s + Key exchange... + Encrypted message... + Corrupted ciphertext + (No Subject) + You have received a message from someone who supports TextSecure encrypted sessions. Would you like to initiate a secure session? diff --git a/src/org/thoughtcrime/securesms/ConversationActivity.java b/src/org/thoughtcrime/securesms/ConversationActivity.java index f4d1351040..9b8c99150f 100644 --- a/src/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/ConversationActivity.java @@ -60,12 +60,12 @@ import org.thoughtcrime.securesms.mms.AttachmentTypeSelectorAdapter; import org.thoughtcrime.securesms.mms.MediaTooLargeException; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.protocol.Tag; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientFormattingException; import org.thoughtcrime.securesms.recipients.Recipients; import org.thoughtcrime.securesms.service.KeyCachingService; -import org.thoughtcrime.securesms.service.MessageNotifier; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.CharacterCalculator; import org.thoughtcrime.securesms.util.EncryptedCharacterCalculator; @@ -540,7 +540,7 @@ public class ConversationActivity extends SherlockFragmentActivity }; registerReceiver(killActivityReceiver, - new IntentFilter(KeyCachingService.PASSPHRASE_EXPIRED_EVENT), + new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT), KeyCachingService.KEY_PERMISSION, null); registerReceiver(securityUpdateReceiver, @@ -703,7 +703,7 @@ public class ConversationActivity extends SherlockFragmentActivity @Override protected Void doInBackground(Long... params) { DatabaseFactory.getThreadDatabase(ConversationActivity.this).setRead(params[0]); - MessageNotifier.updateNotification(ConversationActivity.this); + MessageNotifier.updateNotification(ConversationActivity.this, masterSecret); return null; } }.execute(threadId); diff --git a/src/org/thoughtcrime/securesms/ConversationListActivity.java b/src/org/thoughtcrime/securesms/ConversationListActivity.java index ccec259080..ed7fca6605 100644 --- a/src/org/thoughtcrime/securesms/ConversationListActivity.java +++ b/src/org/thoughtcrime/securesms/ConversationListActivity.java @@ -48,6 +48,7 @@ public class ConversationListActivity extends SherlockFragmentActivity private ApplicationMigrationManager migrationManager; private boolean havePromptedForPassphrase = false; + private boolean isVisible = false; @Override public void onCreate(Bundle icicle) { @@ -75,6 +76,8 @@ public class ConversationListActivity extends SherlockFragmentActivity unregisterReceiver(newKeyReceiver); newKeyReceiver = null; } + + isVisible = false; } @Override @@ -83,6 +86,7 @@ public class ConversationListActivity extends SherlockFragmentActivity clearNotifications(); initializeKeyCachingServiceRegistration(); + isVisible = true; } @Override @@ -199,15 +203,9 @@ public class ConversationListActivity extends SherlockFragmentActivity } private void handleClearPassphrase() { - Intent keyService = new Intent(this, KeyCachingService.class); - - keyService.setAction(KeyCachingService.CLEAR_KEY_ACTION); - startService(keyService); - - this.masterSecret = null; - fragment.setMasterSecret(null); - - promptForPassphrase(); + Intent intent = new Intent(this, KeyCachingService.class); + intent.setAction(KeyCachingService.CLEAR_KEY_ACTION); + startService(intent); } private void initializeWithMasterSecret(MasterSecret masterSecret) { @@ -235,12 +233,17 @@ public class ConversationListActivity extends SherlockFragmentActivity this.killActivityReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { - finish(); + ConversationListActivity.this.masterSecret = null; + fragment.setMasterSecret(null); + + if (isVisible) { + promptForPassphrase(); + } } }; registerReceiver(this.killActivityReceiver, - new IntentFilter(KeyCachingService.PASSPHRASE_EXPIRED_EVENT), + new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT), KeyCachingService.KEY_PERMISSION, null); } diff --git a/src/org/thoughtcrime/securesms/ConversationListFragment.java b/src/org/thoughtcrime/securesms/ConversationListFragment.java index 72f1b5f749..558b810104 100644 --- a/src/org/thoughtcrime/securesms/ConversationListFragment.java +++ b/src/org/thoughtcrime/securesms/ConversationListFragment.java @@ -37,8 +37,8 @@ import android.widget.ListView; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.loaders.ConversationListLoader; +import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.recipients.Recipients; -import org.thoughtcrime.securesms.service.MessageNotifier; import com.actionbarsherlock.app.SherlockListFragment; import com.actionbarsherlock.view.ActionMode; @@ -179,7 +179,7 @@ public class ConversationListFragment extends SherlockListFragment @Override protected Void doInBackground(Void... params) { DatabaseFactory.getThreadDatabase(getActivity()).deleteConversations(selectedConversations); - MessageNotifier.updateNotification(getActivity()); + MessageNotifier.updateNotification(getActivity(), masterSecret); return null; } diff --git a/src/org/thoughtcrime/securesms/crypto/DecryptingQueue.java b/src/org/thoughtcrime/securesms/crypto/DecryptingQueue.java index e06c761f77..e738840f55 100644 --- a/src/org/thoughtcrime/securesms/crypto/DecryptingQueue.java +++ b/src/org/thoughtcrime/securesms/crypto/DecryptingQueue.java @@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.database.EncryptingMmsDatabase; import org.thoughtcrime.securesms.database.EncryptingSmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.mms.TextTransport; +import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.protocol.Prefix; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientFactory; @@ -145,6 +146,7 @@ public class DecryptingQueue { return null; } + @Override public void run() { EncryptingMmsDatabase database = DatabaseFactory.getEncryptingMmsDatabase(context, masterSecret); @@ -178,7 +180,6 @@ public class DecryptingQueue { Log.w("DecryptingQueue", "Successfully decrypted MMS!"); database.insertSecureDecryptedMessageReceived(plaintextPdu, threadId); database.delete(messageId); - } catch (RecipientFormattingException rfe) { Log.w("DecryptingQueue", rfe); database.markAsDecryptFailed(messageId, threadId); @@ -240,6 +241,7 @@ public class DecryptingQueue { } database.updateSecureMessageBody(masterSecret, messageId, plaintextBody); + MessageNotifier.updateNotification(context, masterSecret); } private void handleLocalAsymmetricEncrypt() { @@ -261,8 +263,10 @@ public class DecryptingQueue { } database.updateMessageBody(masterSecret, messageId, plaintextBody); + MessageNotifier.updateNotification(context, masterSecret); } + @Override public void run() { if (body.startsWith(Prefix.ASYMMETRIC_ENCRYPT)) handleRemoteAsymmetricEncrypt(); else if (body.startsWith(Prefix.ASYMMETRIC_LOCAL_ENCRYPT)) handleLocalAsymmetricEncrypt(); diff --git a/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java b/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java new file mode 100644 index 0000000000..9b555d6a88 --- /dev/null +++ b/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java @@ -0,0 +1,321 @@ +/** + * 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.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.net.Uri; +import android.preference.PreferenceManager; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationCompat.BigTextStyle; +import android.support.v4.app.NotificationCompat.InboxStyle; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.util.Log; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.crypto.MasterCipher; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.crypto.MessageDisplayHelper; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.protocol.Prefix; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientFactory; +import org.thoughtcrime.securesms.recipients.RecipientFormattingException; +import org.thoughtcrime.securesms.recipients.Recipients; +import org.thoughtcrime.securesms.util.InvalidMessageException; +import org.thoughtcrime.securesms.util.Util; + +import java.io.IOException; +import java.util.List; + +/** + * Handles posting system notifications for new messages. + * + * + * @author Moxie Marlinspike + */ + +public class MessageNotifier { + + public static final int NOTIFICATION_ID = 1338; + + private volatile static long visibleThread = -1; + + public static void setVisibleThread(long threadId) { + visibleThread = threadId; + } + + public static void updateNotification(Context context, MasterSecret masterSecret) { + updateNotification(context, masterSecret, false); + } + + public static void updateNotification(Context context, MasterSecret masterSecret, long threadId) { + if (visibleThread == threadId) { + DatabaseFactory.getThreadDatabase(context).setRead(threadId); + sendInThreadNotification(context); + } else { + updateNotification(context, masterSecret, true); + } + } + + private static void updateNotification(Context context, MasterSecret masterSecret, boolean signal) { + Cursor cursor = null; + + try { + cursor = DatabaseFactory.getMmsSmsDatabase(context).getUnread(); + + if (cursor == null || cursor.isAfterLast()) { + ((NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE)) + .cancel(NOTIFICATION_ID); + return; + } + + NotificationState notificationState = constructNotificationState(context, masterSecret, cursor); + + if (notificationState.hasMultipleThreads()) { + sendMultipleThreadNotification(context, notificationState, signal); + } else { + sendSingleThreadNotification(context, notificationState, signal); + } + } finally { + if (cursor != null) + cursor.close(); + } + } + + private static void sendSingleThreadNotification(Context context, + NotificationState notificationState, + boolean signal) + { + List notifications = notificationState.getNotifications(); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context); + Recipients recipients = notifications.get(0).getRecipients(); + + builder.setSmallIcon(R.drawable.icon_notification); + builder.setLargeIcon(recipients.getPrimaryRecipient().getContactPhoto()); + builder.setContentTitle(recipients.getPrimaryRecipient().toShortString()); + builder.setContentText(notifications.get(0).getText()); + builder.setContentIntent(notifications.get(0).getPendingIntent(context)); + + SpannableStringBuilder content = new SpannableStringBuilder(); + + for (NotificationItem item : notifications) { + content.append(item.getBigStyleSummary()); + content.append('\n'); + } + + builder.setStyle(new BigTextStyle().bigText(content)); + + setNotificationAlarms(context, builder, signal); + + if (signal) { + builder.setTicker(notifications.get(0).getTickerText()); + } + + ((NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE)) + .notify(NOTIFICATION_ID, builder.build()); + } + + private static void sendMultipleThreadNotification(Context context, + NotificationState notificationState, + boolean signal) + { + List notifications = notificationState.getNotifications(); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context); + + builder.setSmallIcon(R.drawable.icon_notification); + builder.setLargeIcon(BitmapFactory.decodeResource(context.getResources(), + R.drawable.icon_notification)); + builder.setContentTitle(String.format(context.getString(R.string.MessageNotifier_d_new_messages), + notificationState.getMessageCount())); + builder.setContentText(String.format(context.getString(R.string.MessageNotifier_most_recent_from_s), + notifications.get(0).getRecipientName())); + builder.setContentIntent(PendingIntent.getActivity(context, 0, new Intent(context, ConversationListActivity.class), 0)); + + InboxStyle style = new InboxStyle(); + + for (NotificationItem item : notifications) { + style.addLine(item.getTickerText()); + } + + builder.setStyle(style); + + setNotificationAlarms(context, builder, signal); + + if (signal) { + builder.setTicker(notifications.get(0).getTickerText()); + } + + ((NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE)) + .notify(NOTIFICATION_ID, builder.build()); + } + + private static void sendInThreadNotification(Context context) { + try { + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); + String ringtone = sp.getString(ApplicationPreferencesActivity.RINGTONE_PREF, null); + + if (ringtone == null) + return; + + Uri uri = Uri.parse(ringtone); + MediaPlayer player = new MediaPlayer(); + player.setAudioStreamType(AudioManager.STREAM_NOTIFICATION); + player.setDataSource(context, uri); + player.setLooping(false); + player.setVolume(0.25f, 0.25f); + player.prepare(); + + final AudioManager audioManager = ((AudioManager)context.getSystemService(Context.AUDIO_SERVICE)); + + audioManager.requestAudioFocus(null, AudioManager.STREAM_NOTIFICATION, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK); + + player.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer mp) { + audioManager.abandonAudioFocus(null); + } + }); + + player.start(); + } catch (IOException ioe) { + Log.w("MessageNotifier", ioe); + } + } + + private static NotificationState constructNotificationState(Context context, + MasterSecret masterSecret, + Cursor cursor) + { + NotificationState notificationState = new NotificationState(); + + while (cursor.moveToNext()) { + Recipients recipients = getRecipients(context, cursor); + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.THREAD_ID)); + CharSequence body = getBody(context, masterSecret, cursor); + Uri image = null; + + notificationState.addNotification(new NotificationItem(recipients, threadId, body, image)); + } + + return notificationState; + } + + private static CharSequence getBody(Context context, MasterSecret masterSecret, Cursor cursor) { + String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY)); + + if (body == null) { + return context.getString(R.string.MessageNotifier_no_subject); + } + + if (masterSecret != null) { + try { + body = MessageDisplayHelper.getDecryptedMessageBody(new MasterCipher(masterSecret), body); + } catch (InvalidMessageException e) { + Log.w("MessageNotifier", e); + return Util.getItalicizedString(context.getString(R.string.MessageNotifier_corrupted_ciphertext)); + } + } + + if (body.startsWith(Prefix.SYMMETRIC_ENCRYPT) || + body.startsWith(Prefix.ASYMMETRIC_ENCRYPT) || + body.startsWith(Prefix.ASYMMETRIC_LOCAL_ENCRYPT)) + { + return Util.getItalicizedString(context.getString(R.string.MessageNotifier_encrypted_message)); + } else if (body.startsWith(Prefix.KEY_EXCHANGE) || + body.startsWith(Prefix.PROCESSED_KEY_EXCHANGE)) + { + return Util.getItalicizedString(context.getString(R.string.MessageNotifier_key_exchange)); + } + + return body; + } + + private static Recipients getSmsRecipient(Context context, Cursor cursor) + throws RecipientFormattingException + { + String address = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.ADDRESS)); + return RecipientFactory.getRecipientsFromString(context, address, false); + } + + private static Recipients getMmsRecipient(Context context, Cursor cursor) + throws RecipientFormattingException + { + long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.ID)); + String address = DatabaseFactory.getMmsDatabase(context).getMessageRecipient(messageId); + return RecipientFactory.getRecipientsFromString(context, address, false); + } + + private static Recipients getRecipients(Context context, Cursor cursor) { + String type = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT)); + + try { + if (type.equals("sms")) { + return getSmsRecipient(context, cursor); + } else { + return getMmsRecipient(context, cursor); + } + } catch (RecipientFormattingException e) { + return new Recipients(new Recipient("Unknown", null, null)); + } + } + + private static void setNotificationAlarms(Context context, + NotificationCompat.Builder builder, + boolean signal) + { + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); + + String ringtone = sp.getString(ApplicationPreferencesActivity.RINGTONE_PREF, null); + boolean vibrate = sp.getBoolean(ApplicationPreferencesActivity.VIBRATE_PREF, true); + String ledColor = sp.getString(ApplicationPreferencesActivity.LED_COLOR_PREF, "green"); + String ledBlinkPattern = sp.getString(ApplicationPreferencesActivity.LED_BLINK_PREF, "500,2000"); + String ledBlinkPatternCustom = sp.getString(ApplicationPreferencesActivity.LED_BLINK_PREF_CUSTOM, "500,2000"); + String[] blinkPatternArray = parseBlinkPattern(ledBlinkPattern, ledBlinkPatternCustom); + + builder.setSound(TextUtils.isEmpty(ringtone) || !signal ? null : Uri.parse(ringtone)); + + if (signal && vibrate) + builder.setDefaults(Notification.DEFAULT_VIBRATE); + + builder.setLights(Color.parseColor(ledColor), Integer.parseInt(blinkPatternArray[0]), + Integer.parseInt(blinkPatternArray[1])); + } + + private static String[] parseBlinkPattern(String blinkPattern, String blinkPatternCustom) { + if (blinkPattern.equals("custom")) + blinkPattern = blinkPatternCustom; + + return blinkPattern.split(","); + } +} diff --git a/src/org/thoughtcrime/securesms/notifications/NotificationItem.java b/src/org/thoughtcrime/securesms/notifications/NotificationItem.java new file mode 100644 index 0000000000..50a4ac9814 --- /dev/null +++ b/src/org/thoughtcrime/securesms/notifications/NotificationItem.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.notifications; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.text.SpannableStringBuilder; + +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.recipients.Recipients; +import org.thoughtcrime.securesms.util.Util; + +public class NotificationItem { + + private final Recipients recipients; + private final long threadId; + private final CharSequence text; + private final Uri image; + + public NotificationItem(Recipients recipients, long threadId, CharSequence text, Uri image) { + this.recipients = recipients; + this.text = text; + this.image = image; + this.threadId = threadId; + } + + public Recipients getRecipients() { + return recipients; + } + + public String getRecipientName() { + return recipients.getPrimaryRecipient().toShortString(); + } + + public CharSequence getText() { + return text; + } + + public Uri getImage() { + return image; + } + + public boolean hasImage() { + return image != null; + } + + public long getThreadId() { + return threadId; + } + + public CharSequence getBigStyleSummary() { + return (text == null) ? "" : text; + } + + public CharSequence getTickerText() { + SpannableStringBuilder builder = new SpannableStringBuilder(); + builder.append(Util.getBoldedString(getRecipientName())); + builder.append(": "); + builder.append(getText()); + + return builder; + } + + public PendingIntent getPendingIntent(Context context) { + Intent intent = new Intent(context, ConversationListActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + + if (recipients.getPrimaryRecipient() != null) { + intent.putExtra("recipients", recipients); + intent.putExtra("thread_id", threadId); + } + + intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); + + return PendingIntent.getActivity(context, 0, intent, 0); + } + +} diff --git a/src/org/thoughtcrime/securesms/notifications/NotificationState.java b/src/org/thoughtcrime/securesms/notifications/NotificationState.java new file mode 100644 index 0000000000..237354de61 --- /dev/null +++ b/src/org/thoughtcrime/securesms/notifications/NotificationState.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.notifications; + +import android.graphics.Bitmap; + +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +public class NotificationState { + + private final LinkedList notifications = new LinkedList(); + private final Set threads = new HashSet(); + + private int notificationCount = 0; + + public void addNotification(NotificationItem item) { + notifications.addFirst(item); + threads.add(item.getThreadId()); + notificationCount++; + } + + public boolean hasMultipleThreads() { + return threads.size() > 1; + } + + public int getMessageCount() { + return notificationCount; + } + + public List getNotifications() { + return notifications; + } + + public Bitmap getContactPhoto() { + return notifications.get(0).getRecipients().getPrimaryRecipient().getContactPhoto(); + } + +} diff --git a/src/org/thoughtcrime/securesms/service/KeyCachingService.java b/src/org/thoughtcrime/securesms/service/KeyCachingService.java index b33e553d07..d8b9283781 100644 --- a/src/org/thoughtcrime/securesms/service/KeyCachingService.java +++ b/src/org/thoughtcrime/securesms/service/KeyCachingService.java @@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.ApplicationPreferencesActivity; import org.thoughtcrime.securesms.ConversationListActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.notifications.MessageNotifier; /** * Small service that stays running to keep a key cached in memory. @@ -48,7 +49,8 @@ public class KeyCachingService extends Service { public static final String KEY_PERMISSION = "org.thoughtcrime.securesms.ACCESS_SECRETS"; public static final String NEW_KEY_EVENT = "org.thoughtcrime.securesms.service.action.NEW_KEY_EVENT"; - public static final String PASSPHRASE_EXPIRED_EVENT = "org.thoughtcrime.securesms.service.action.PASSPHRASE_EXPIRED_EVENT"; + public static final String CLEAR_KEY_EVENT = "org.thoughtcrime.securesms.service.action.CLEAR_KEY_EVENT"; + private static final String PASSPHRASE_EXPIRED_EVENT = "org.thoughtcrime.securesms.service.action.PASSPHRASE_EXPIRED_EVENT"; public static final String CLEAR_KEY_ACTION = "org.thoughtcrime.securesms.service.action.CLEAR_KEY"; public static final String ACTIVITY_START_EVENT = "org.thoughtcrime.securesms.service.action.ACTIVITY_START_EVENT"; public static final String ACTIVITY_STOP_EVENT = "org.thoughtcrime.securesms.service.action.ACTIVITY_STOP_EVENT"; @@ -67,12 +69,19 @@ public class KeyCachingService extends Service { return masterSecret; } - public synchronized void setMasterSecret(MasterSecret masterSecret) { + public synchronized void setMasterSecret(final MasterSecret masterSecret) { this.masterSecret = masterSecret; foregroundService(); broadcastNewSecret(); startTimeoutIfAppropriate(); + + new Thread() { + @Override + public void run() { + MessageNotifier.updateNotification(KeyCachingService.this, masterSecret); + } + }.start(); } @Override @@ -86,17 +95,20 @@ public class KeyCachingService extends Service { else if (intent.getAction() != null && intent.getAction().equals(ACTIVITY_STOP_EVENT)) handleActivityStopped(); else if (intent.getAction() != null && intent.getAction().equals(PASSPHRASE_EXPIRED_EVENT)) - handlePassphraseExpired(); + handleClearKey(); } @Override public void onCreate() { + super.onCreate(); pending = PendingIntent.getService(this, 0, new Intent(PASSPHRASE_EXPIRED_EVENT, null, this, KeyCachingService.class), 0); } @Override public void onDestroy() { - Log.e("kcs", "KCS Is Being Destroyed!"); + super.onDestroy(); + Log.w("KeyCachingService", "KCS Is Being Destroyed!"); + handleClearKey(); } private void handleActivityStarted() { @@ -117,14 +129,18 @@ public class KeyCachingService extends Service { private void handleClearKey() { this.masterSecret = null; stopForeground(true); - } - private void handlePassphraseExpired() { - handleClearKey(); - Intent intent = new Intent(PASSPHRASE_EXPIRED_EVENT); + Intent intent = new Intent(CLEAR_KEY_EVENT); intent.setPackage(getApplicationContext().getPackageName()); sendBroadcast(intent, KEY_PERMISSION); + + new Thread() { + @Override + public void run() { + MessageNotifier.updateNotification(KeyCachingService.this, null); + } + }.start(); } private void startTimeoutIfAppropriate() { @@ -180,6 +196,7 @@ public class KeyCachingService extends Service { private void broadcastNewSecret() { Log.w("service", "Broadcasting new secret..."); + Intent intent = new Intent(NEW_KEY_EVENT); intent.putExtra("master_secret", masterSecret); intent.setPackage(getApplicationContext().getPackageName()); diff --git a/src/org/thoughtcrime/securesms/service/MessageNotifier.java b/src/org/thoughtcrime/securesms/service/MessageNotifier.java deleted file mode 100644 index 4feb34f070..0000000000 --- a/src/org/thoughtcrime/securesms/service/MessageNotifier.java +++ /dev/null @@ -1,269 +0,0 @@ -/** - * 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.service; - -import android.app.Notification; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.Color; -import android.media.AudioManager; -import android.media.MediaPlayer; -import android.net.Uri; -import android.preference.PreferenceManager; -import android.support.v4.app.NotificationCompat; -import android.text.TextUtils; -import android.util.Log; - -import org.thoughtcrime.securesms.ApplicationPreferencesActivity; -import org.thoughtcrime.securesms.ConversationListActivity; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.database.MmsDatabase; -import org.thoughtcrime.securesms.database.MmsSmsDatabase; -import org.thoughtcrime.securesms.database.SmsDatabase; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientFactory; -import org.thoughtcrime.securesms.recipients.RecipientFormattingException; -import org.thoughtcrime.securesms.recipients.Recipients; - -import java.io.IOException; -import java.util.LinkedList; - -/** - * Handles posting system notifications for new messages. - * - * - * @author Moxie Marlinspike - */ - -public class MessageNotifier { - - public static final int NOTIFICATION_ID = 1338; - - private volatile static long visibleThread = -1; - - public static void setVisibleThread(long threadId) { - visibleThread = threadId; - } - - private static Bitmap buildContactPhoto(Recipients recipients) { - Recipient recipient = recipients.getPrimaryRecipient(); - - if (recipient == null) { - return null; - } else { - return recipient.getContactPhoto(); - } - } - - private static String buildTickerMessage(Context context, int count, Recipients recipients) { - Recipient recipient = recipients.getPrimaryRecipient(); - - if (recipient == null) { - return String.format(context.getString(R.string.MessageNotifier_d_new_messages), count); - } else { - return String.format(context.getString(R.string.MessageNotifier_d_new_messages_most_recent_from_s), count, - recipient.getName() == null ? recipient.getNumber() : recipient.getName()); - } - } - - private static String buildTitleMessage(Context context, int count) { - return String.format(context.getString(R.string.MessageNotifier_d_new_messages), count); - } - - private static String buildSubtitleMessage(Context context, Recipients recipients) { - Recipient recipient = recipients.getPrimaryRecipient(); - - if (recipient != null) { - return String.format(context.getString(R.string.MessageNotifier_most_recent_from_s), - (recipient.getName() == null ? recipient.getNumber() : recipient.getName())); - } - - return null; - } - - private static Recipients getSmsRecipient(Context context, Cursor c) throws RecipientFormattingException { - String address = c.getString(c.getColumnIndexOrThrow(SmsDatabase.ADDRESS)); - return RecipientFactory.getRecipientsFromString(context, address, false); - } - - private static Recipients getMmsRecipient(Context context, Cursor c) throws RecipientFormattingException { - long messageId = c.getLong(c.getColumnIndexOrThrow(MmsDatabase.ID)); - String address = DatabaseFactory.getMmsDatabase(context).getMessageRecipient(messageId); - return RecipientFactory.getRecipientsFromString(context, address, false); - } - - private static Recipients getMostRecentRecipients(Context context, Cursor c) { - if (c != null && c.moveToLast()) { - try { - String type = c.getString(c.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT)); - - if (type.equals("sms")) - return getSmsRecipient(context, c); - else - return getMmsRecipient(context, c); - - } catch (RecipientFormattingException e) { - return new Recipients(new LinkedList()); - } - } - - return null; - } - - - private static PendingIntent buildPendingIntent(Context context, Cursor c, Recipients recipients) { - Intent intent = new Intent(context, ConversationListActivity.class); - intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); - - Log.w("SMSNotifier", "Building pending intent..."); - if (c != null && c.getCount() == 1) { - Log.w("SMSNotifier", "Adding extras..."); - c.moveToLast(); - long threadId = c.getLong(c.getColumnIndexOrThrow(SmsDatabase.THREAD_ID)); - Log.w("SmsNotifier", "Adding thread_id to pending intent: " + threadId); - - if (recipients.getPrimaryRecipient() != null) { - intent.putExtra("recipients", recipients); - intent.putExtra("thread_id", threadId); - } - - intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); - } - - return PendingIntent.getActivity(context, 0, intent, 0); - } - - private static void sendNotification(Context context, NotificationManager manager, - PendingIntent launchIntent, Bitmap contactPhoto, - String ticker, String title, - String subtitle, boolean signal) - { - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - if (!sp.getBoolean(ApplicationPreferencesActivity.NOTIFICATION_PREF, true)) return; - - String ringtone = sp.getString(ApplicationPreferencesActivity.RINGTONE_PREF, null); - boolean vibrate = sp.getBoolean(ApplicationPreferencesActivity.VIBRATE_PREF, true); - String ledColor = sp.getString(ApplicationPreferencesActivity.LED_COLOR_PREF, "green"); - String ledBlinkPattern = sp.getString(ApplicationPreferencesActivity.LED_BLINK_PREF, "500,2000"); - String ledBlinkPatternCustom = sp.getString(ApplicationPreferencesActivity.LED_BLINK_PREF_CUSTOM, "500,2000"); - String[] blinkPatternArray = parseBlinkPattern(ledBlinkPattern, ledBlinkPatternCustom); - - NotificationCompat.Builder builder = new NotificationCompat.Builder(context); - builder.setSmallIcon(R.drawable.icon_notification); - builder.setLargeIcon(contactPhoto); - builder.setTicker(ticker); - builder.setContentTitle(title); - builder.setContentText(subtitle); - builder.setContentIntent(launchIntent); - builder.setSound(TextUtils.isEmpty(ringtone) || !signal ? null : Uri.parse(ringtone)); - - if (signal && vibrate) - builder.setDefaults(Notification.DEFAULT_VIBRATE); - - builder.setLights(Color.parseColor(ledColor), Integer.parseInt(blinkPatternArray[0]), Integer.parseInt(blinkPatternArray[1])); - - manager.notify(NOTIFICATION_ID, builder.build()); - } - - private static void sendInThreadNotification(Context context) { - try { - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - String ringtone = sp.getString(ApplicationPreferencesActivity.RINGTONE_PREF, null); - - if (ringtone == null) - return; - - Uri uri = Uri.parse(ringtone); - MediaPlayer player = new MediaPlayer(); - player.setAudioStreamType(AudioManager.STREAM_NOTIFICATION); - player.setDataSource(context, uri); - player.setLooping(false); - player.setVolume(0.25f, 0.25f); - player.prepare(); - - final AudioManager audioManager = ((AudioManager)context.getSystemService(Context.AUDIO_SERVICE)); - - audioManager.requestAudioFocus(null, AudioManager.STREAM_NOTIFICATION, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK); - - player.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { - @Override - public void onCompletion(MediaPlayer mp) { - audioManager.abandonAudioFocus(null); - } - }); - - player.start(); - } catch (IOException ioe) { - Log.w("MessageNotifier", ioe); - } - } - - private static void updateNotification(Context context, boolean signal) { - NotificationManager manager = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE); - manager.cancel(NOTIFICATION_ID); - - Cursor c = null; - - try { - c = DatabaseFactory.getMmsSmsDatabase(context).getUnread(); - - if (c == null || !c.moveToFirst()) { - return; - } - - Recipients recipients = getMostRecentRecipients(context, c); - String ticker = buildTickerMessage(context, c.getCount(), recipients); - String title = buildTitleMessage(context, c.getCount()); - String subtitle = buildSubtitleMessage(context, recipients); - PendingIntent launchIntent = buildPendingIntent(context, c, recipients); - Bitmap contactPhoto = buildContactPhoto(recipients); - - sendNotification(context, manager, launchIntent, contactPhoto, - ticker, title, subtitle, signal); - } finally { - if (c != null) - c.close(); - } - } - - public static void updateNotification(final Context context) { - updateNotification(context, false); - } - - public static void updateNotification(Context context, long threadId) { - if (visibleThread == threadId) { - DatabaseFactory.getThreadDatabase(context).setRead(threadId); - sendInThreadNotification(context); - } else { - updateNotification(context, true); - } - } - - private static String[] parseBlinkPattern(String blinkPattern, String blinkPatternCustom) { - if (blinkPattern.equals("custom")) - blinkPattern = blinkPatternCustom; - - return blinkPattern.split(","); - } -} diff --git a/src/org/thoughtcrime/securesms/service/MmsReceiver.java b/src/org/thoughtcrime/securesms/service/MmsReceiver.java index 1d3552679b..32a7248318 100644 --- a/src/org/thoughtcrime/securesms/service/MmsReceiver.java +++ b/src/org/thoughtcrime/securesms/service/MmsReceiver.java @@ -23,6 +23,7 @@ import android.util.Log; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.notifications.MessageNotifier; import ws.com.google.android.mms.pdu.GenericPdu; import ws.com.google.android.mms.pdu.NotificationInd; @@ -63,7 +64,7 @@ public class MmsReceiver { long messageId = database.insertMessageReceived((NotificationInd)pdu); long threadId = database.getThreadIdForMessage(messageId); - MessageNotifier.updateNotification(context, threadId); + MessageNotifier.updateNotification(context, masterSecret, threadId); scheduleDownload((NotificationInd)pdu, messageId, threadId); Log.w("MmsReceiverService", "Inserted received notification..."); diff --git a/src/org/thoughtcrime/securesms/service/SendReceiveService.java b/src/org/thoughtcrime/securesms/service/SendReceiveService.java index 50c0b6e1d1..fb51e8eb51 100644 --- a/src/org/thoughtcrime/securesms/service/SendReceiveService.java +++ b/src/org/thoughtcrime/securesms/service/SendReceiveService.java @@ -70,13 +70,16 @@ public class SendReceiveService extends Service { private MmsDownloader mmsDownloader; private MasterSecret masterSecret; - private NewKeyReceiver receiver; + private boolean hasSecret; + + private NewKeyReceiver newKeyReceiver; + private ClearKeyReceiver clearKeyReceiver; private List workQueue; private List pendingSecretList; private Thread workerThread; @Override - public void onCreate() { + public void onCreate() { initializeHandlers(); initializeProcessors(); initializeAddressCanonicalization(); @@ -111,6 +114,18 @@ public class SendReceiveService extends Service { return null; } + @Override + public void onDestroy() { + Log.w("SendReceiveService", "onDestroy()..."); + super.onDestroy(); + + if (newKeyReceiver != null) + unregisterReceiver(newKeyReceiver); + + if (clearKeyReceiver != null) + unregisterReceiver(clearKeyReceiver); + } + private void initializeHandlers() { toastHandler = new ToastHandler(); } @@ -132,20 +147,27 @@ public class SendReceiveService extends Service { } private void initializeMasterSecret() { - receiver = new NewKeyReceiver(); - IntentFilter filter = new IntentFilter(KeyCachingService.NEW_KEY_EVENT); - registerReceiver(receiver, filter, KeyCachingService.KEY_PERMISSION, null); + hasSecret = false; + newKeyReceiver = new NewKeyReceiver(); + clearKeyReceiver = new ClearKeyReceiver(); + + IntentFilter newKeyFilter = new IntentFilter(KeyCachingService.NEW_KEY_EVENT); + registerReceiver(newKeyReceiver, newKeyFilter, KeyCachingService.KEY_PERMISSION, null); + + IntentFilter clearKeyFilter = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT); + registerReceiver(clearKeyReceiver, clearKeyFilter, KeyCachingService.KEY_PERMISSION, null); Intent bindIntent = new Intent(this, KeyCachingService.class); bindService(bindIntent, serviceConnection, Context.BIND_AUTO_CREATE); } private void initializeWithMasterSecret(MasterSecret masterSecret) { - Log.w("SendReceiveService", "SendReceive service got master secret: " + masterSecret); + Log.w("SendReceiveService", "SendReceive service got master secret."); if (masterSecret != null) { synchronized (workQueue) { this.masterSecret = masterSecret; + this.hasSecret = true; Iterator iterator = pendingSecretList.iterator(); while (iterator.hasNext()) @@ -173,7 +195,7 @@ public class SendReceiveService extends Service { Runnable work = new SendReceiveWorkItem(intent, what); synchronized (workQueue) { - if (masterSecret != null) { + if (hasSecret) { workQueue.add(work); workQueue.notifyAll(); } else { @@ -183,7 +205,6 @@ public class SendReceiveService extends Service { } private class SendReceiveWorkItem implements Runnable { - private final Intent intent; private final int what; @@ -192,6 +213,7 @@ public class SendReceiveService extends Service { this.what = what; } + @Override public void run() { switch (what) { case RECEIVE_SMS: smsReceiver.process(masterSecret, intent); return; @@ -210,12 +232,13 @@ public class SendReceiveService extends Service { this.sendMessage(message); } @Override - public void handleMessage(Message message) { + public void handleMessage(Message message) { Toast.makeText(SendReceiveService.this, (String)message.obj, Toast.LENGTH_LONG).show(); } } private ServiceConnection serviceConnection = new ServiceConnection() { + @Override public void onServiceConnected(ComponentName className, IBinder service) { KeyCachingService keyCachingService = ((KeyCachingService.KeyCachingBinder)service).getService(); MasterSecret masterSecret = keyCachingService.getMasterSecret(); @@ -225,6 +248,7 @@ public class SendReceiveService extends Service { SendReceiveService.this.unbindService(this); } + @Override public void onServiceDisconnected(ComponentName name) {} }; @@ -234,6 +258,48 @@ public class SendReceiveService extends Service { Log.w("SendReceiveService", "Got a MasterSecret broadcast..."); initializeWithMasterSecret((MasterSecret)intent.getParcelableExtra("master_secret")); } - }; + } + + /** + * This class receives broadcast notifications to clear the MasterSecret. + * + * We don't want to clear it immediately, since there are potentially jobs + * in the work queue which require the master secret. Instead, we reset a + * flag so that new incoming jobs will be evaluated as if no mastersecret is + * present. + * + * Then, we add a job to the end of the queue which actually clears the masterSecret + * value. That way all jobs before this moment will be processed correctly, and all + * jobs after this moment will be evaluated as if no mastersecret is present (and potentially + * held). + * + * When we go to actually clear the mastersecret, we ensure that the flag is still false. + * This allows a new mastersecret broadcast to come in correctly without us clobbering it. + * + */ + private class ClearKeyReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + Log.w("SendReceiveService", "Got a clear mastersecret broadcast..."); + + synchronized (workQueue) { + SendReceiveService.this.hasSecret = false; + workQueue.add(new Runnable() { + @Override + public void run() { + Log.w("SendReceiveService", "Running clear key work item..."); + + synchronized (workQueue) { + if (!SendReceiveService.this.hasSecret) { + Log.w("SendReceiveService", "Actually clearing key..."); + SendReceiveService.this.masterSecret = null; + } + } + } + }); + workQueue.notifyAll(); + } + } + }; } diff --git a/src/org/thoughtcrime/securesms/service/SmsReceiver.java b/src/org/thoughtcrime/securesms/service/SmsReceiver.java index 98e39171cd..8388a938bc 100644 --- a/src/org/thoughtcrime/securesms/service/SmsReceiver.java +++ b/src/org/thoughtcrime/securesms/service/SmsReceiver.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.crypto.KeyExchangeProcessor; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.crypto.MasterSecretUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.protocol.Prefix; import org.thoughtcrime.securesms.protocol.WirePrefix; import org.thoughtcrime.securesms.recipients.Recipient; @@ -161,7 +162,7 @@ public class SmsReceiver { long messageId = storeMessage(masterSecret, messages[0], message); long threadId = DatabaseFactory.getSmsDatabase(context).getThreadIdForMessage(messageId); - MessageNotifier.updateNotification(context, threadId); + MessageNotifier.updateNotification(context, masterSecret, threadId); } } diff --git a/src/org/thoughtcrime/securesms/util/Util.java b/src/org/thoughtcrime/securesms/util/Util.java index 05fe61b45b..5729d329d3 100644 --- a/src/org/thoughtcrime/securesms/util/Util.java +++ b/src/org/thoughtcrime/securesms/util/Util.java @@ -16,6 +16,10 @@ */ package org.thoughtcrime.securesms.util; +import android.graphics.Typeface; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.StyleSpan; import android.widget.EditText; import java.util.concurrent.ExecutorService; @@ -90,6 +94,24 @@ public class Util { return value == null || value.getText() == null || isEmpty(value.getText().toString()); } + public static CharSequence getBoldedString(String value) { + SpannableString spanned = new SpannableString(value); + spanned.setSpan(new StyleSpan(Typeface.BOLD), 0, + spanned.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + return spanned; + } + + public static CharSequence getItalicizedString(String value) { + SpannableString spanned = new SpannableString(value); + spanned.setSpan(new StyleSpan(Typeface.ITALIC), 0, + spanned.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + return spanned; + } + // public static Bitmap loadScaledBitmap(InputStream src, int targetWidth, int targetHeight) { // return BitmapFactory.decodeStream(src); //// BitmapFactory.Options options = new BitmapFactory.Options();