From e9b85a10a6b66c41125ea189045ef2955ca47716 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 16 Aug 2018 09:47:43 -0700 Subject: [PATCH] Add per-contact notification channels. Fixes #8119 Fixes #8121 Fixes #8122 --- AndroidManifest.xml | 7 +- res/values/strings.xml | 6 +- res/xml/preferences_notifications.xml | 6 + res/xml/recipient_preferences.xml | 12 + .../ApplicationPreferencesActivity.java | 4 +- .../securesms/ConversationActivity.java | 2 + .../RecipientPreferenceActivity.java | 80 ++++- .../securesms/database/RecipientDatabase.java | 58 +++- .../database/helpers/SQLCipherOpenHelper.java | 31 +- .../jobs/MultiDeviceBlockedUpdateJob.java | 20 +- .../securesms/jobs/PushDecryptJob.java | 2 +- .../AbstractNotificationBuilder.java | 2 +- .../notifications/NotificationChannels.java | 310 +++++++++++++++++- .../SingleRecipientNotificationBuilder.java | 2 + .../NotificationsPreferenceFragment.java | 57 +++- .../widgets/SignalListPreference.java | 15 +- .../securesms/recipients/Recipient.java | 31 ++ .../recipients/RecipientProvider.java | 2 + .../securesms/util/DirectoryHelper.java | 9 + .../securesms/util/TextSecurePreferences.java | 21 +- 20 files changed, 609 insertions(+), 68 deletions(-) diff --git a/AndroidManifest.xml b/AndroidManifest.xml index cdaa85d8e0..e07e098efe 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -288,7 +288,12 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/> + android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"> + + + + + Contact - Messages + Default Calls Failures Backups Lock status App updates Other + Messages Quick response unavailable when Signal is locked! @@ -960,6 +961,8 @@ Mute conversation + Custom notifications + System notification settings Notification sound Vibrate Block @@ -1086,6 +1089,7 @@ Inactivity timeout passphrase Inactivity timeout interval Notifications + System notification settings LED color Unknown LED blink pattern diff --git a/res/xml/preferences_notifications.xml b/res/xml/preferences_notifications.xml index 1fa08efe50..b53048beb7 100644 --- a/res/xml/preferences_notifications.xml +++ b/res/xml/preferences_notifications.xml @@ -9,6 +9,12 @@ android:title="@string/preferences__notifications" android:defaultValue="true" /> + + + + + + () { + @Override + protected Void doInBackground(Void... params) { + if (enabled) { + String channel = NotificationChannels.createChannelFor(getActivity(), recipient); + DatabaseFactory.getRecipientDatabase(getActivity()).setNotificationChannel(recipient, channel); + } else { + NotificationChannels.deleteChannelFor(getActivity(), recipient); + DatabaseFactory.getRecipientDatabase(getActivity()).setNotificationChannel(recipient, null); + } + return null; + } + }.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); + + return true; + } + } + + private class NotificationSettingsClickedListener implements Preference.OnPreferenceClickListener { + + @Override + public boolean onPreferenceClick(Preference preference) { + NotificationChannels.openChannelSettings(getActivity(), recipient.getNotificationChannel(getActivity())); + return true; + } + } } } diff --git a/src/org/thoughtcrime/securesms/database/RecipientDatabase.java b/src/org/thoughtcrime/securesms/database/RecipientDatabase.java index 3b1fd26f07..dbafebcc52 100644 --- a/src/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/src/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; +import java.io.Closeable; import java.io.IOException; import java.util.HashMap; import java.util.HashSet; @@ -53,11 +54,12 @@ public class RecipientDatabase extends Database { private static final String PROFILE_SHARING = "profile_sharing_approval"; private static final String CALL_RINGTONE = "call_ringtone"; private static final String CALL_VIBRATE = "call_vibrate"; + private static final String NOTIFICATION_CHANNEL = "notification_channel"; private static final String[] RECIPIENT_PROJECTION = new String[] { BLOCK, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, - SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING + SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL }; static final List TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) @@ -122,7 +124,8 @@ public class RecipientDatabase extends Database { SIGNAL_PROFILE_AVATAR + " TEXT DEFAULT NULL, " + PROFILE_SHARING + " INTEGER DEFAULT 0, " + CALL_RINGTONE + " TEXT DEFAULT NULL, " + - CALL_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ");"; + CALL_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " + + NOTIFICATION_CHANNEL + " TEXT DEFAULT NULL);"; public RecipientDatabase(Context context, SQLCipherOpenHelper databaseHelper) { super(context, databaseHelper); @@ -135,8 +138,16 @@ public class RecipientDatabase extends Database { null, null, null, null, null); } - public BlockedReader readerForBlocked(Cursor cursor) { - return new BlockedReader(context, cursor); + public RecipientReader readerForBlocked(Cursor cursor) { + return new RecipientReader(context, cursor); + } + + public RecipientReader getRecipientsWithNotificationChannels() { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + Cursor cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS}, NOTIFICATION_CHANNEL + " NOT NULL", + null, null, null, null, null); + + return new RecipientReader(context, cursor); } @@ -177,6 +188,7 @@ public class RecipientDatabase extends Database { String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME)); String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_AVATAR)); boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1; + String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL)); MaterialColor color; byte[] profileKey = null; @@ -206,7 +218,8 @@ public class RecipientDatabase extends Database { RegisteredState.fromId(registeredState), profileKey, systemDisplayName, systemContactPhoto, systemPhoneLabel, systemContactUri, - signalProfileName, signalProfileAvatar, profileSharing)); + signalProfileName, signalProfileAvatar, profileSharing, + notificationChannel)); } public BulkOperationsHandle resetAllSystemContactInfo() { @@ -324,6 +337,22 @@ public class RecipientDatabase extends Database { recipient.setProfileSharing(enabled); } + public void setNotificationChannel(@NonNull Recipient recipient, @Nullable String notificationChannel) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(NOTIFICATION_CHANNEL, notificationChannel); + updateOrInsert(recipient.getAddress(), contentValues); + recipient.setNotificationChannel(notificationChannel); + } + + public boolean isNotificationChannelPresent(@NonNull String notificationChannel) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = database.query(TABLE_NAME, new String[] { ID }, NOTIFICATION_CHANNEL + " = ?", + new String[] { notificationChannel }, null, null, null, null)) { + return cursor != null && cursor.moveToFirst(); + } + } + public Set
getAllAddresses() { SQLiteDatabase db = databaseHelper.getReadableDatabase(); Set
results = new HashSet<>(); @@ -472,6 +501,7 @@ public class RecipientDatabase extends Database { private final String signalProfileName; private final String signalProfileAvatar; private final boolean profileSharing; + private final String notificationChannel; RecipientSettings(boolean blocked, long muteUntil, @NonNull VibrateState messageVibrateState, @@ -490,7 +520,8 @@ public class RecipientDatabase extends Database { @Nullable String systemContactUri, @Nullable String signalProfileName, @Nullable String signalProfileAvatar, - boolean profileSharing) + boolean profileSharing, + @Nullable String notificationChannel) { this.blocked = blocked; this.muteUntil = muteUntil; @@ -511,6 +542,7 @@ public class RecipientDatabase extends Database { this.signalProfileName = signalProfileName; this.signalProfileAvatar = signalProfileAvatar; this.profileSharing = profileSharing; + this.notificationChannel = notificationChannel; } public @Nullable MaterialColor getColor() { @@ -588,14 +620,18 @@ public class RecipientDatabase extends Database { public boolean isProfileSharing() { return profileSharing; } + + public @Nullable String getNotificationChannel() { + return notificationChannel; + } } - public static class BlockedReader { + public static class RecipientReader implements Closeable { private final Context context; - private final Cursor cursor; + private final Cursor cursor; - BlockedReader(Context context, Cursor cursor) { + RecipientReader(Context context, Cursor cursor) { this.context = context; this.cursor = cursor; } @@ -612,6 +648,10 @@ public class RecipientDatabase extends Database { return getCurrent(); } + + public void close() { + cursor.close(); + } } private static class PendingContactInfo { diff --git a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 2caa72319a..021208e660 100644 --- a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -4,9 +4,12 @@ package org.thoughtcrime.securesms.database.helpers; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; +import android.net.Uri; import android.os.SystemClock; import android.support.annotation.NonNull; import android.text.TextUtils; + +import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.logging.Log; import net.sqlcipher.database.SQLiteDatabase; @@ -31,6 +34,7 @@ import org.thoughtcrime.securesms.database.SignedPreKeyDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; +import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -51,8 +55,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int FULL_TEXT_SEARCH = 9; private static final int BAD_IMPORT_CLEANUP = 10; private static final int QUOTE_MISSING = 11; + private static final int NOTIFICATION_CHANNELS = 12; - private static final int DATABASE_VERSION = 11; + private static final int DATABASE_VERSION = 12; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -240,6 +245,30 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL("ALTER TABLE mms ADD COLUMN quote_missing INTEGER DEFAULT 0"); } + if (oldVersion < NOTIFICATION_CHANNELS) { + db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN notification_channel TEXT DEFAULT NULL"); + + try (Cursor cursor = db.rawQuery("SELECT recipient_ids, system_display_name, signal_profile_name, notification, vibrate FROM recipient_preferences WHERE notification NOT NULL OR vibrate != 0", null)) { + while (cursor != null && cursor.moveToNext()) { + String addressString = cursor.getString(cursor.getColumnIndexOrThrow("recipient_ids")); + Address address = Address.fromExternal(context, addressString); + String systemName = cursor.getString(cursor.getColumnIndexOrThrow("system_display_name")); + String profileName = cursor.getString(cursor.getColumnIndexOrThrow("signal_profile_name")); + String messageSound = cursor.getString(cursor.getColumnIndexOrThrow("notification")); + Uri messageSoundUri = messageSound != null ? Uri.parse(messageSound) : null; + int vibrateState = cursor.getInt(cursor.getColumnIndexOrThrow("vibrate")); + String displayName = NotificationChannels.getChannelDisplayNameFor(systemName, profileName, address); + boolean vibrateEnabled = vibrateState == 0 ? TextSecurePreferences.isNotificationVibrateEnabled(context) : vibrateState == 1; + + String channelId = NotificationChannels.createChannelFor(context, address, displayName, messageSoundUri, vibrateEnabled); + + ContentValues values = new ContentValues(1); + values.put("notification_channel", channelId); + db.update("recipient_preferences", values, "recipient_ids = ?", new String[] { addressString }); + } + } + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/src/org/thoughtcrime/securesms/jobs/MultiDeviceBlockedUpdateJob.java b/src/org/thoughtcrime/securesms/jobs/MultiDeviceBlockedUpdateJob.java index 311df39092..8e79ca9f4d 100644 --- a/src/org/thoughtcrime/securesms/jobs/MultiDeviceBlockedUpdateJob.java +++ b/src/org/thoughtcrime/securesms/jobs/MultiDeviceBlockedUpdateJob.java @@ -5,7 +5,7 @@ import android.content.Context; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.RecipientDatabase; -import org.thoughtcrime.securesms.database.RecipientDatabase.BlockedReader; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientReader; import org.thoughtcrime.securesms.dependencies.InjectableType; import org.thoughtcrime.securesms.jobmanager.JobParameters; import org.thoughtcrime.securesms.jobmanager.requirements.NetworkRequirement; @@ -45,18 +45,20 @@ public class MultiDeviceBlockedUpdateJob extends MasterSecretJob implements Inje throws IOException, UntrustedIdentityException { RecipientDatabase database = DatabaseFactory.getRecipientDatabase(context); - BlockedReader reader = database.readerForBlocked(database.getBlocked()); - List blocked = new LinkedList<>(); - Recipient recipient; + try (RecipientReader reader = database.readerForBlocked(database.getBlocked())) { + List blocked = new LinkedList<>(); - while ((recipient = reader.getNext()) != null) { - if (!recipient.isGroupRecipient()) { - blocked.add(recipient.getAddress().serialize()); + Recipient recipient; + + while ((recipient = reader.getNext()) != null) { + if (!recipient.isGroupRecipient()) { + blocked.add(recipient.getAddress().serialize()); + } } - } - messageSender.sendMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(blocked))); + messageSender.sendMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(blocked))); + } } @Override diff --git a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java index 9b675a7b7d..9bc277f377 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java @@ -136,7 +136,7 @@ public class PushDecryptJob extends ContextJob { if (TextSecurePreferences.getNeedsSqlCipherMigration(context)) { Log.w(TAG, "Skipping job, waiting for sqlcipher migration..."); NotificationManagerCompat.from(context).notify(494949, - new NotificationCompat.Builder(context, NotificationChannels.MESSAGES) + new NotificationCompat.Builder(context, NotificationChannels.getMessagesChannel(context)) .setSmallIcon(R.drawable.icon_notification) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_MESSAGE) diff --git a/src/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java b/src/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java index 341bed7116..f10c51f305 100644 --- a/src/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java +++ b/src/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java @@ -31,7 +31,7 @@ public abstract class AbstractNotificationBuilder extends NotificationCompat.Bui this.context = context; this.privacy = privacy; - setChannelId(NotificationChannels.MESSAGES); + setChannelId(NotificationChannels.getMessagesChannel(context)); setLed(); } diff --git a/src/org/thoughtcrime/securesms/notifications/NotificationChannels.java b/src/org/thoughtcrime/securesms/notifications/NotificationChannels.java index 0cd8e593b3..359bab5858 100644 --- a/src/org/thoughtcrime/securesms/notifications/NotificationChannels.java +++ b/src/org/thoughtcrime/securesms/notifications/NotificationChannels.java @@ -1,25 +1,51 @@ package org.thoughtcrime.securesms.notifications; +import android.annotation.TargetApi; import android.app.NotificationChannel; +import android.app.NotificationChannelGroup; import android.app.NotificationManager; import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.media.AudioAttributes; +import android.net.Uri; import android.os.Build; +import android.provider.Settings; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; +import android.text.TextUtils; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.Address; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.logging.Log; import java.util.Arrays; public class NotificationChannels { - public static String MESSAGES = "messages"; - public static String CALLS = "calls"; - public static String FAILURES = "failures"; - public static String APP_UPDATES = "app_updates"; - public static String BACKUPS = "backups"; - public static String LOCKED_STATUS = "locked_status"; - public static String OTHER = "other"; + private static final String TAG = NotificationChannels.class.getSimpleName(); + + private static final int VERSION_MESSAGES_CATEGORY = 2; + + private static final int VERSION = 2; + + private static final String CATEGORY_MESSAGES = "messages"; + private static final String CONTACT_PREFIX = "contact_"; + private static final String MESSAGES_PREFIX = "messages_"; + + public static final String CALLS = "calls_v2"; + public static final String FAILURES = "failures"; + public static final String APP_UPDATES = "app_updates"; + public static final String BACKUPS = "backups_v2"; + public static final String LOCKED_STATUS = "locked_status_v2"; + public static final String OTHER = "other_v2"; /** * Ensures all of the notification channels are created. No harm in repeat calls. Call is safely @@ -30,14 +56,193 @@ public class NotificationChannels { return; } - NotificationChannel messages = new NotificationChannel(MESSAGES, context.getString(R.string.NotificationChannel_messages), NotificationManager.IMPORTANCE_HIGH); + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + if (notificationManager == null) { + Log.w(TAG, "Unable to retrieve notification manager. Can't setup channels."); + return; + } + + int oldVersion = TextSecurePreferences.getNotificationChannelVersion(context); + if (oldVersion != VERSION) { + onUpgrade(notificationManager, oldVersion, VERSION); + TextSecurePreferences.setNotificationChannelVersion(context, VERSION); + } + + onCreate(context, notificationManager); + } + + /** + * @return The channel ID for the default messages channel. Prefer + * {@link Recipient#getNotificationChannel(Context)} if you know the recipient. + */ + public static @NonNull String getMessagesChannel(@NonNull Context context) { + return getMessagesChannelId(TextSecurePreferences.getNotificationMessagesChannelVersion(context)); + } + + /** + * @return Whether or not notification channels are supported. + */ + public static boolean supported() { + return Build.VERSION.SDK_INT >= 26; + } + + public static String getChannelDisplayNameFor(@Nullable String systemName, @Nullable String profileName, @NonNull Address address) { + return TextUtils.isEmpty(systemName) ? (TextUtils.isEmpty(profileName) ? address.serialize() : profileName) : systemName; + } + + /** + * Creates a channel for the specified recipient. + * @return The channel ID for the newly-created channel. + */ + public static String createChannelFor(@NonNull Context context, @NonNull Recipient recipient) { + VibrateState vibrateState = recipient.getMessageVibrate(); + boolean vibrationEnabled = vibrateState == VibrateState.DEFAULT ? TextSecurePreferences.isNotificationVibrateEnabled(context) : vibrateState == VibrateState.ENABLED; + String displayName = getChannelDisplayNameFor(recipient.getName(), recipient.getProfileName(), recipient.getAddress()); + + return createChannelFor(context, recipient.getAddress(), displayName, recipient.getMessageRingtone(), vibrationEnabled); + } + + /** + * More verbose version of {@link #createChannelFor(Context, Recipient)}. + */ + public static String createChannelFor(@NonNull Context context, + @NonNull Address address, + @NonNull String displayName, + @Nullable Uri messageSound, + boolean vibrationEnabled) + { + if (!supported()) { + return getMessagesChannel(context); + } + + String channelId = generateChannelIdFor(address); + NotificationChannel channel = new NotificationChannel(channelId, displayName, NotificationManager.IMPORTANCE_HIGH); + + setLedPreference(channel, TextSecurePreferences.getNotificationLedColor(context)); + channel.setGroup(CATEGORY_MESSAGES); + channel.enableVibration(vibrationEnabled); + channel.setSound(messageSound, new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) + .build()); + + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + if (notificationManager == null) { + Log.w(TAG, "Unable to retrieve notification manager. Cannot create channel for recipient."); + return channelId; + } + + notificationManager.createNotificationChannel(channel); + + return channelId; + } + + /** + * Deletes the channel generated for the provided recipient. Safe to call even if there was never + * a channel made for that recipient. + */ + public static void deleteChannelFor(@NonNull Context context, @NonNull Recipient recipient) { + if (!supported()) { + return; + } + + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + if (notificationManager == null) { + Log.w(TAG, "Unable to retrieve notification manager. Cannot delete channel."); + return; + } + + String channel = recipient.getNotificationChannel(context); + + if (!TextUtils.isEmpty(channel) && !getMessagesChannel(context).equals(channel)) { + notificationManager.deleteNotificationChannel(channel); + } + } + + /** + * Navigates the user to the system settings for the desired notification channel. + */ + public static void openChannelSettings(@NonNull Context context, @NonNull String channelId) { + if (!supported()) { + return; + } + + Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS); + intent.putExtra(Settings.EXTRA_CHANNEL_ID, channelId); + intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName()); + context.startActivity(intent); + } + + /** + * Updates the LED color for message notifications and all contact-specific message notification + * channels. Performs database operations and should therefore be invoked on a background thread. + */ + @WorkerThread + public static void updateMessagesLedColor(@NonNull Context context, @NonNull String color) { + if (!supported()) { + return; + } + + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + if (notificationManager == null) { + Log.w(TAG, "Unable to retrieve notification manager. Cannot update led color."); + return; + } + + updateMessageChannelLedColor(context, notificationManager, color); + updateAllRecipientChannelLedColors(context, notificationManager, color); + } + + /** + * Updates the name of an existing channel to match the recipient's current name. Will have no + * effect if the recipient doesn't have an existing valid channel. + */ + public static void updateContactChannelName(@NonNull Context context, @NonNull Recipient recipient) { + if (!supported() || !recipient.hasCustomNotifications()) { + return; + } + + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + if (notificationManager == null) { + Log.w(TAG, "Unable to retrieve notification manager. Cannot update channel name."); + return; + } + + if (notificationManager.getNotificationChannel(recipient.getNotificationChannel(context)) == null) { + Log.w(TAG, "Tried to update the name of a channel, but that channel doesn't exist."); + return; + } + + NotificationChannel channel = new NotificationChannel(recipient.getNotificationChannel(context), + getChannelDisplayNameFor(recipient.getName(), recipient.getProfileName(), recipient.getAddress()), + NotificationManager.IMPORTANCE_HIGH); + channel.setGroup(CATEGORY_MESSAGES); + notificationManager.createNotificationChannel(channel); + } + + @TargetApi(26) + private static void onCreate(@NonNull Context context, @NonNull NotificationManager notificationManager) { + NotificationChannelGroup messagesGroup = new NotificationChannelGroup(CATEGORY_MESSAGES, context.getResources().getString(R.string.NotificationChannel_group_messages)); + notificationManager.createNotificationChannelGroup(messagesGroup); + + NotificationChannel messages = new NotificationChannel(getMessagesChannel(context), context.getString(R.string.NotificationChannel_messages), NotificationManager.IMPORTANCE_HIGH); NotificationChannel calls = new NotificationChannel(CALLS, context.getString(R.string.NotificationChannel_calls), NotificationManager.IMPORTANCE_LOW); NotificationChannel failures = new NotificationChannel(FAILURES, context.getString(R.string.NotificationChannel_failures), NotificationManager.IMPORTANCE_HIGH); NotificationChannel backups = new NotificationChannel(BACKUPS, context.getString(R.string.NotificationChannel_backups), NotificationManager.IMPORTANCE_LOW); NotificationChannel lockedStatus = new NotificationChannel(LOCKED_STATUS, context.getString(R.string.NotificationChannel_locked_status), NotificationManager.IMPORTANCE_LOW); NotificationChannel other = new NotificationChannel(OTHER, context.getString(R.string.NotificationChannel_other), NotificationManager.IMPORTANCE_LOW); - NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + messages.setGroup(CATEGORY_MESSAGES); + messages.enableVibration(TextSecurePreferences.isNotificationVibrateEnabled(context)); + messages.setSound(TextSecurePreferences.getNotificationRingtone(context), new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) + .build()); + setLedPreference(messages, TextSecurePreferences.getNotificationLedColor(context)); + + calls.setShowBadge(false); + backups.setShowBadge(false); + lockedStatus.setShowBadge(false); + other.setShowBadge(false); + notificationManager.createNotificationChannels(Arrays.asList(messages, calls, failures, backups, lockedStatus, other)); if (BuildConfig.PLAY_STORE_DISABLED) { @@ -48,7 +253,90 @@ public class NotificationChannels { } } - public static boolean supported() { - return Build.VERSION.SDK_INT >= 26; + @TargetApi(26) + private static void onUpgrade(@NonNull NotificationManager notificationManager, int oldVersion, int newVersion) { + Log.i(TAG, "Upgrading channels from " + oldVersion + " to " + newVersion); + + if (oldVersion < VERSION_MESSAGES_CATEGORY) { + notificationManager.deleteNotificationChannel("messages"); + notificationManager.deleteNotificationChannel("calls"); + notificationManager.deleteNotificationChannel("locked_status"); + notificationManager.deleteNotificationChannel("backups"); + notificationManager.deleteNotificationChannel("other"); + } + } + + @TargetApi(26) + private static void setLedPreference(@NonNull NotificationChannel channel, @NonNull String ledColor) { + if ("none".equals(ledColor)) { + channel.enableLights(false); + } else { + channel.enableLights(true); + channel.setLightColor(Color.parseColor(ledColor)); + } + } + + + private static @NonNull String generateChannelIdFor(@NonNull Address address) { + return CONTACT_PREFIX + address.serialize() + "_" + System.currentTimeMillis(); + } + + @TargetApi(26) + private static @NonNull NotificationChannel copyChannel(@NonNull NotificationChannel original, @NonNull String id) { + NotificationChannel copy = new NotificationChannel(id, original.getName(), original.getImportance()); + + copy.setGroup(original.getGroup()); + copy.setSound(original.getSound(), original.getAudioAttributes()); + copy.setBypassDnd(original.canBypassDnd()); + copy.enableVibration(original.shouldVibrate()); + copy.setVibrationPattern(original.getVibrationPattern()); + copy.setLockscreenVisibility(original.getLockscreenVisibility()); + copy.setShowBadge(original.canShowBadge()); + copy.setLightColor(original.getLightColor()); + copy.enableLights(original.shouldShowLights()); + + return copy; + } + + private static String getMessagesChannelId(int version) { + return MESSAGES_PREFIX + version; + } + + @TargetApi(26) + private static void updateMessageChannelLedColor(@NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull String color) { + int existingVersion = TextSecurePreferences.getNotificationMessagesChannelVersion(context); + NotificationChannel existingChannel = notificationManager.getNotificationChannel(getMessagesChannelId(existingVersion)); + + notificationManager.deleteNotificationChannel(existingChannel.getId()); + + int newVersion = existingVersion + 1; + NotificationChannel newChannel = copyChannel(existingChannel, getMessagesChannelId(newVersion)); + + setLedPreference(newChannel, color); + notificationManager.createNotificationChannel(newChannel); + + TextSecurePreferences.setNotificationMessagesChannelVersion(context, newVersion); + } + + @WorkerThread + @TargetApi(26) + private static void updateAllRecipientChannelLedColors(@NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull String color) { + RecipientDatabase database = DatabaseFactory.getRecipientDatabase(context); + + try (RecipientDatabase.RecipientReader recipients = database.getRecipientsWithNotificationChannels()) { + Recipient recipient; + while ((recipient = recipients.getNext()) != null) { + NotificationChannel existingChannel = notificationManager.getNotificationChannel(recipient.getNotificationChannel(context)); + notificationManager.deleteNotificationChannel(existingChannel.getId()); + + NotificationChannel newChannel = copyChannel(existingChannel, generateChannelIdFor(recipient.getAddress())); + newChannel.setGroup(CATEGORY_MESSAGES); + setLedPreference(newChannel, color); + + database.setNotificationChannel(recipient, newChannel.getId()); + + notificationManager.createNotificationChannel(newChannel); + } + } } } diff --git a/src/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/src/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index 5394e13cdd..34221cb7d0 100644 --- a/src/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/src/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -60,6 +60,8 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil } public void setThread(@NonNull Recipient recipient) { + setChannelId(recipient.getNotificationChannel(context)); + if (privacy.isDisplayContact()) { setContentTitle(recipient.toShortString()); diff --git a/src/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java b/src/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java index ae49264fa1..b7fe00aad4 100644 --- a/src/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java +++ b/src/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java @@ -19,10 +19,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.preferences.widgets.SignalListPreference; -import org.thoughtcrime.securesms.preferences.widgets.SignalPreference; import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.w3c.dom.Text; import static android.app.Activity.RESULT_OK; @@ -31,16 +28,38 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme @SuppressWarnings("unused") private static final String TAG = NotificationsPreferenceFragment.class.getSimpleName(); + private static final String PREF_SYSTEM_SETTINGS = "pref_key_system_notification_settings"; + @Override public void onCreate(Bundle paramBundle) { super.onCreate(paramBundle); + Preference ledBlinkPref = this.findPreference(TextSecurePreferences.LED_BLINK_PREF); + Preference messageTonePref = this.findPreference(TextSecurePreferences.RINGTONE_PREF); + Preference vibratePref = this.findPreference(TextSecurePreferences.VIBRATE_PREF); + Preference systemPref = this.findPreference(PREF_SYSTEM_SETTINGS); + + if (NotificationChannels.supported()) { + ledBlinkPref.setVisible(false); + messageTonePref.setVisible(false); + vibratePref.setVisible(false); + + systemPref.setOnPreferenceClickListener(p -> { + NotificationChannels.openChannelSettings(getContext(), NotificationChannels.getMessagesChannel(getContext())); + return true; + }); + } else { + systemPref.setVisible(false); + + ledBlinkPref.setOnPreferenceChangeListener(new ListSummaryListener()); + messageTonePref.setOnPreferenceChangeListener(new RingtoneSummaryListener()); + + initializeListSummary((ListPreference) ledBlinkPref); + initializeRingtoneSummary(messageTonePref); + } + this.findPreference(TextSecurePreferences.LED_COLOR_PREF) - .setOnPreferenceChangeListener(new ListSummaryListener()); - this.findPreference(TextSecurePreferences.LED_BLINK_PREF) - .setOnPreferenceChangeListener(new ListSummaryListener()); - this.findPreference(TextSecurePreferences.RINGTONE_PREF) - .setOnPreferenceChangeListener(new RingtoneSummaryListener()); + .setOnPreferenceChangeListener(new LedColorChangeListener()); this.findPreference(TextSecurePreferences.REPEAT_ALERTS_PREF) .setOnPreferenceChangeListener(new ListSummaryListener()); this.findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF) @@ -83,17 +102,14 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme }); initializeListSummary((ListPreference) findPreference(TextSecurePreferences.LED_COLOR_PREF)); - initializeListSummary((ListPreference) findPreference(TextSecurePreferences.LED_BLINK_PREF)); initializeListSummary((ListPreference) findPreference(TextSecurePreferences.REPEAT_ALERTS_PREF)); initializeListSummary((ListPreference) findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)); if (NotificationChannels.supported()) { - ((SignalListPreference) this.findPreference(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF)).disableDialog(); - this.findPreference(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF) .setOnPreferenceClickListener(preference -> { Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS); - intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.MESSAGES); + intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.getMessagesChannel(getContext())); intent.putExtra(Settings.EXTRA_APP_PACKAGE, getContext().getPackageName()); startActivity(intent); return true; @@ -102,7 +118,6 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme initializeListSummary((ListPreference) findPreference(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF)); } - initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF)); initializeCallRingtoneSummary(findPreference(TextSecurePreferences.CALL_RINGTONE_PREF)); initializeCallVibrateSummary((SwitchPreferenceCompat)findPreference(TextSecurePreferences.CALL_VIBRATE_PREF)); } @@ -201,6 +216,22 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme return super.onPreferenceChange(preference, value); } + } + @SuppressLint("StaticFieldLeak") + private class LedColorChangeListener extends ListSummaryListener { + @Override + public boolean onPreferenceChange(Preference preference, Object value) { + if (NotificationChannels.supported()) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... voids) { + NotificationChannels.updateMessagesLedColor(getActivity(), (String) value); + return null; + } + }.execute(); + } + return super.onPreferenceChange(preference, value); + } } } diff --git a/src/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java b/src/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java index 643e70bd2e..5423085371 100644 --- a/src/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java +++ b/src/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java @@ -5,6 +5,7 @@ import android.content.Context; import android.os.Build; import android.support.annotation.RequiresApi; import android.support.v7.preference.ListPreference; +import android.support.v7.preference.Preference; import android.support.v7.preference.PreferenceViewHolder; import android.util.AttributeSet; import android.util.TypedValue; @@ -16,9 +17,9 @@ import org.thoughtcrime.securesms.util.ViewUtil; public class SignalListPreference extends ListPreference { - private TextView rightSummary; - private CharSequence summary; - private boolean dialogDisabled; + private TextView rightSummary; + private CharSequence summary; + private OnPreferenceClickListener clickListener; @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) public SignalListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { @@ -65,13 +66,15 @@ public class SignalListPreference extends ListPreference { } } - public void disableDialog() { - dialogDisabled = true; + @Override + public void setOnPreferenceClickListener(OnPreferenceClickListener onPreferenceClickListener) { + super.setOnPreferenceClickListener(onPreferenceClickListener); + this.clickListener = onPreferenceClickListener; } @Override protected void onClick() { - if (!dialogDisabled) { + if (clickListener == null || !clickListener.onPreferenceClick(this)) { super.onClick(); } } diff --git a/src/org/thoughtcrime/securesms/recipients/Recipient.java b/src/org/thoughtcrime/securesms/recipients/Recipient.java index e18f97b7bb..e7a81ec816 100644 --- a/src/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/src/org/thoughtcrime/securesms/recipients/Recipient.java @@ -17,6 +17,7 @@ */ package org.thoughtcrime.securesms.recipients; +import android.app.NotificationChannel; import android.content.Context; import android.graphics.drawable.Drawable; import android.net.Uri; @@ -43,6 +44,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.recipients.RecipientProvider.RecipientDetails; import org.thoughtcrime.securesms.util.FutureTaskListener; import org.thoughtcrime.securesms.util.ListenableFutureTask; @@ -90,6 +92,7 @@ public class Recipient implements RecipientModifiedListener { private @Nullable String profileName; private @Nullable String profileAvatar; private boolean profileSharing; + private String notificationChannel; @SuppressWarnings("ConstantConditions") @@ -135,6 +138,7 @@ public class Recipient implements RecipientModifiedListener { this.seenInviteReminder = stale.seenInviteReminder; this.defaultSubscriptionId = stale.defaultSubscriptionId; this.registered = stale.registered; + this.notificationChannel = stale.notificationChannel; this.profileKey = stale.profileKey; this.profileName = stale.profileName; this.profileAvatar = stale.profileAvatar; @@ -158,6 +162,7 @@ public class Recipient implements RecipientModifiedListener { this.seenInviteReminder = details.get().seenInviteReminder; this.defaultSubscriptionId = details.get().defaultSubscriptionId; this.registered = details.get().registered; + this.notificationChannel = details.get().notificationChannel; this.profileKey = details.get().profileKey; this.profileName = details.get().profileName; this.profileAvatar = details.get().profileAvatar; @@ -187,6 +192,7 @@ public class Recipient implements RecipientModifiedListener { Recipient.this.seenInviteReminder = result.seenInviteReminder; Recipient.this.defaultSubscriptionId = result.defaultSubscriptionId; Recipient.this.registered = result.registered; + Recipient.this.notificationChannel = result.notificationChannel; Recipient.this.profileKey = result.profileKey; Recipient.this.profileName = result.profileName; Recipient.this.profileAvatar = result.profileAvatar; @@ -233,6 +239,7 @@ public class Recipient implements RecipientModifiedListener { this.seenInviteReminder = details.seenInviteReminder; this.defaultSubscriptionId = details.defaultSubscriptionId; this.registered = details.registered; + this.notificationChannel = details.notificationChannel; this.profileKey = details.profileKey; this.profileName = details.profileName; this.profileAvatar = details.profileAvatar; @@ -581,6 +588,30 @@ public class Recipient implements RecipientModifiedListener { if (notify) notifyListeners(); } + public synchronized @NonNull String getNotificationChannel(@NonNull Context context) { + if (!NotificationChannels.supported() || notificationChannel == null) { + return NotificationChannels.getMessagesChannel(context); + } + return notificationChannel; + } + + public void setNotificationChannel(@Nullable String value) { + boolean notify = false; + + synchronized (this) { + if (!Util.equals(this.notificationChannel, value)) { + this.notificationChannel = value; + notify = true; + } + } + + if (notify) notifyListeners(); + } + + public synchronized boolean hasCustomNotifications() { + return NotificationChannels.supported() && notificationChannel != null; + } + public synchronized @Nullable byte[] getProfileKey() { return profileKey; } diff --git a/src/org/thoughtcrime/securesms/recipients/RecipientProvider.java b/src/org/thoughtcrime/securesms/recipients/RecipientProvider.java index 468932bbfd..5398662ffb 100644 --- a/src/org/thoughtcrime/securesms/recipients/RecipientProvider.java +++ b/src/org/thoughtcrime/securesms/recipients/RecipientProvider.java @@ -174,6 +174,7 @@ class RecipientProvider { @Nullable final String profileAvatar; final boolean profileSharing; final boolean systemContact; + @Nullable final String notificationChannel; RecipientDetails(@Nullable String name, @Nullable Long groupAvatarId, boolean systemContact, @Nullable RecipientSettings settings, @@ -200,6 +201,7 @@ class RecipientProvider { this.profileAvatar = settings != null ? settings.getProfileAvatar() : null; this.profileSharing = settings != null && settings.isProfileSharing(); this.systemContact = systemContact; + this.notificationChannel = settings != null ? settings.getNotificationChannel() : null; if (name == null && settings != null) this.name = settings.getSystemDisplayName(); else this.name = name; diff --git a/src/org/thoughtcrime/securesms/util/DirectoryHelper.java b/src/org/thoughtcrime/securesms/util/DirectoryHelper.java index 2b615363fd..e60cbebf21 100644 --- a/src/org/thoughtcrime/securesms/util/DirectoryHelper.java +++ b/src/org/thoughtcrime/securesms/util/DirectoryHelper.java @@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.notifications.MessageNotifier; +import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.push.AccountManagerFactory; import org.thoughtcrime.securesms.recipients.Recipient; @@ -182,6 +183,14 @@ public class DirectoryHelper { handle.finish(); } + if (NotificationChannels.supported()) { + try (RecipientDatabase.RecipientReader recipients = DatabaseFactory.getRecipientDatabase(context).getRecipientsWithNotificationChannels()) { + Recipient recipient; + while ((recipient = recipients.getNext()) != null) { + NotificationChannels.updateContactChannelName(context, recipient); + } + } + } } catch (RemoteException | OperationApplicationException e) { Log.w(TAG, e); } diff --git a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java index 90fc664a16..1867e93690 100644 --- a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -54,7 +54,7 @@ public class TextSecurePreferences { private static final String LAST_EXPERIENCE_VERSION_PREF = "last_experience_version_code"; private static final String EXPERIENCE_DISMISSED_PREF = "experience_dismissed"; public static final String RINGTONE_PREF = "pref_key_ringtone"; - private static final String VIBRATE_PREF = "pref_key_vibrate"; + public static final String VIBRATE_PREF = "pref_key_vibrate"; private static final String NOTIFICATION_PREF = "pref_key_enable_notifications"; public static final String LED_COLOR_PREF = "pref_led_color"; public static final String LED_BLINK_PREF = "pref_led_blink"; @@ -159,6 +159,9 @@ public class TextSecurePreferences { private static final String LOG_ENCRYPTED_SECRET = "pref_log_encrypted_secret"; private static final String LOG_UNENCRYPTED_SECRET = "pref_log_unencrypted_secret"; + private static final String NOTIFICATION_CHANNEL_VERSION = "pref_notification_channel_version"; + private static final String NOTIFICATION_MESSAGES_CHANNEL_VERSION = "pref_notification_messages_channel_version"; + public static boolean isScreenLockEnabled(@NonNull Context context) { return getBooleanPreference(context, SCREEN_LOCK, false); } @@ -960,6 +963,22 @@ public class TextSecurePreferences { return getStringPreference(context, LOG_UNENCRYPTED_SECRET, null); } + public static int getNotificationChannelVersion(Context context) { + return getIntegerPreference(context, NOTIFICATION_CHANNEL_VERSION, 1); + } + + public static void setNotificationChannelVersion(Context context, int version) { + setIntegerPrefrence(context, NOTIFICATION_CHANNEL_VERSION, version); + } + + public static int getNotificationMessagesChannelVersion(Context context) { + return getIntegerPreference(context, NOTIFICATION_MESSAGES_CHANNEL_VERSION, 1); + } + + public static void setNotificationMessagesChannelVersion(Context context, int version) { + setIntegerPrefrence(context, NOTIFICATION_MESSAGES_CHANNEL_VERSION, version); + } + public static void setBooleanPreference(Context context, String key, boolean value) { PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply(); }