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.AsyncTask; 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 com.annimon.stream.Collectors; import com.annimon.stream.Stream; 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.ServiceUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.libsignal.logging.Log; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; public class NotificationChannels { 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 * ignored for API < 26. */ public static synchronized void create(@NonNull Context context) { if (!supported()) { return; } NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); int oldVersion = TextSecurePreferences.getNotificationChannelVersion(context); if (oldVersion != VERSION) { onUpgrade(notificationManager, oldVersion, VERSION); TextSecurePreferences.setNotificationChannelVersion(context, VERSION); } onCreate(context, notificationManager); AsyncTask.SERIAL_EXECUTOR.execute(() -> { ensureCustomChannelConsistency(context); }); } /** * Recreates all notification channels for contacts with custom notifications enabled. Should be * safe to call repeatedly. Needs to be executed on a background thread. */ @WorkerThread public static synchronized void restoreContactNotificationChannels(@NonNull Context context) { if (!NotificationChannels.supported()) { return; } RecipientDatabase db = DatabaseFactory.getRecipientDatabase(context); try (RecipientDatabase.RecipientReader reader = db.getRecipientsWithNotificationChannels()) { Recipient recipient; while ((recipient = reader.getNext()) != null) { NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); if (!channelExists(notificationManager.getNotificationChannel(recipient.getNotificationChannel()))) { String id = createChannelFor(context, recipient); db.setNotificationChannel(recipient, id); } } } ensureCustomChannelConsistency(context); } /** * @return The channel ID for the default messages channel. */ public static synchronized @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; } /** * @return A name suitable to be displayed as the notification channel title. */ public static @NonNull String getChannelDisplayNameFor(@NonNull Context context, @Nullable String systemName, @Nullable String profileName, @NonNull Address address) { if (!TextUtils.isEmpty(systemName)) { return systemName; } else if (!TextUtils.isEmpty(profileName)) { return profileName; } else if (!TextUtils.isEmpty(address.serialize())) { return address.serialize(); } else { return context.getString(R.string.NotificationChannel_missing_display_name); } } /** * Creates a channel for the specified recipient. * @return The channel ID for the newly-created channel. */ public static synchronized String createChannelFor(@NonNull Context context, @NonNull Recipient recipient) { VibrateState vibrateState = recipient.getMessageVibrate(); boolean vibrationEnabled = vibrateState == VibrateState.DEFAULT ? TextSecurePreferences.isNotificationVibrateEnabled(context) : vibrateState == VibrateState.ENABLED; Uri messageRingtone = recipient.getMessageRingtone() != null ? recipient.getMessageRingtone() : getMessageRingtone(context); String displayName = getChannelDisplayNameFor(context, recipient.getName(), recipient.getProfileName(), recipient.getAddress()); return createChannelFor(context, recipient.getAddress(), displayName, messageRingtone, vibrationEnabled); } /** * More verbose version of {@link #createChannelFor(Context, Recipient)}. */ public static synchronized @Nullable String createChannelFor(@NonNull Context context, @NonNull Address address, @NonNull String displayName, @Nullable Uri messageSound, boolean vibrationEnabled) { if (!supported()) { return null; } 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); if (messageSound != null) { channel.setSound(messageSound, new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) .build()); } NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); 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 synchronized void deleteChannelFor(@NonNull Context context, @NonNull Recipient recipient) { if (!supported()) { return; } NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); String channel = recipient.getNotificationChannel(); if (channel != null) { Log.i(TAG, "Deleting 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 synchronized void updateMessagesLedColor(@NonNull Context context, @NonNull String color) { if (!supported()) { return; } Log.i(TAG, "Updating LED color."); NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); updateMessageChannel(context, channel -> setLedPreference(channel, color)); updateAllRecipientChannelLedColors(context, notificationManager, color); ensureCustomChannelConsistency(context); } /** * @return The message ringtone set for the default message channel. */ public static synchronized @NonNull Uri getMessageRingtone(@NonNull Context context) { if (!supported()) { return Uri.EMPTY; } Uri sound = ServiceUtil.getNotificationManager(context).getNotificationChannel(getMessagesChannel(context)).getSound(); return sound == null ? Uri.EMPTY : sound; } public static synchronized @Nullable Uri getMessageRingtone(@NonNull Context context, @NonNull Recipient recipient) { if (!supported() || recipient.getNotificationChannel() == null) { return null; } NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); NotificationChannel channel = notificationManager.getNotificationChannel(recipient.getNotificationChannel()); if (!channelExists(channel)) { Log.w(TAG, "Recipient had no channel. Returning null."); return null; } return channel.getSound(); } /** * Update the message ringtone for the default message channel. */ public static synchronized void updateMessageRingtone(@NonNull Context context, @Nullable Uri uri) { if (!supported()) { return; } Log.i(TAG, "Updating default message ringtone with URI: " + String.valueOf(uri)); updateMessageChannel(context, channel -> { channel.setSound(uri == null ? Settings.System.DEFAULT_NOTIFICATION_URI : uri, getRingtoneAudioAttributes()); }); } /** * Updates the message ringtone for a specific recipient. If that recipient has no channel, this * does nothing. * * This has to update the database, and therefore should be run on a background thread. */ @WorkerThread public static synchronized void updateMessageRingtone(@NonNull Context context, @NonNull Recipient recipient, @Nullable Uri uri) { if (!supported() || recipient.getNotificationChannel() == null) { return; } Log.i(TAG, "Updating recipient message ringtone with URI: " + String.valueOf(uri)); String newChannelId = generateChannelIdFor(recipient.getAddress()); boolean success = updateExistingChannel(ServiceUtil.getNotificationManager(context), recipient.getNotificationChannel(), generateChannelIdFor(recipient.getAddress()), channel -> channel.setSound(uri == null ? Settings.System.DEFAULT_NOTIFICATION_URI : uri, getRingtoneAudioAttributes())); DatabaseFactory.getRecipientDatabase(context).setNotificationChannel(recipient, success ? newChannelId : null); ensureCustomChannelConsistency(context); } /** * @return The vibrate settings for the default message channel. */ public static synchronized boolean getMessageVibrate(@NonNull Context context) { if (!supported()) { return false; } return ServiceUtil.getNotificationManager(context).getNotificationChannel(getMessagesChannel(context)).shouldVibrate(); } /** * @return The vibrate setting for a specific recipient. If that recipient has no channel, this * will return the setting for the default message channel. */ public static synchronized boolean getMessageVibrate(@NonNull Context context, @NonNull Recipient recipient) { if (!supported()) { return getMessageVibrate(context); } NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); NotificationChannel channel = notificationManager.getNotificationChannel(recipient.getNotificationChannel()); if (!channelExists(channel)) { Log.w(TAG, "Recipient didn't have a channel. Returning message default."); return getMessageVibrate(context); } return channel.shouldVibrate(); } /** * Sets the vibrate property for the default message channel. */ public static synchronized void updateMessageVibrate(@NonNull Context context, boolean enabled) { if (!supported()) { return; } Log.i(TAG, "Updating default vibrate with value: " + enabled); updateMessageChannel(context, channel -> channel.enableVibration(enabled)); } /** * Updates the message ringtone for a specific recipient. If that recipient has no channel, this * does nothing. * * This has to update the database and should therefore be run on a background thread. */ @WorkerThread public static synchronized void updateMessageVibrate(@NonNull Context context, @NonNull Recipient recipient, VibrateState vibrateState) { if (!supported() || recipient.getNotificationChannel() == null) { return ; } Log.i(TAG, "Updating recipient vibrate with value: " + vibrateState); boolean enabled = vibrateState == VibrateState.DEFAULT ? getMessageVibrate(context) : vibrateState == VibrateState.ENABLED; String newChannelId = generateChannelIdFor(recipient.getAddress()); boolean success = updateExistingChannel(ServiceUtil.getNotificationManager(context), recipient.getNotificationChannel(), newChannelId, channel -> channel.enableVibration(enabled)); DatabaseFactory.getRecipientDatabase(context).setNotificationChannel(recipient, success ? newChannelId : null); ensureCustomChannelConsistency(context); } /** * 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 synchronized void updateContactChannelName(@NonNull Context context, @NonNull Recipient recipient) { if (!supported() || recipient.getNotificationChannel() == null) { return; } Log.i(TAG, "Updating contact channel name"); NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); if (notificationManager.getNotificationChannel(recipient.getNotificationChannel()) == 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(), getChannelDisplayNameFor(context, recipient.getName(), recipient.getProfileName(), recipient.getAddress()), NotificationManager.IMPORTANCE_HIGH); channel.setGroup(CATEGORY_MESSAGES); notificationManager.createNotificationChannel(channel); } @TargetApi(26) @WorkerThread public static synchronized void ensureCustomChannelConsistency(@NonNull Context context) { NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); RecipientDatabase db = DatabaseFactory.getRecipientDatabase(context); List customRecipients = new ArrayList<>(); Set customChannelIds = new HashSet<>(); Set existingChannelIds = Stream.of(notificationManager.getNotificationChannels()).map(NotificationChannel::getId).collect(Collectors.toSet()); try (RecipientDatabase.RecipientReader reader = db.getRecipientsWithNotificationChannels()) { Recipient recipient; while ((recipient = reader.getNext()) != null) { customRecipients.add(recipient); customChannelIds.add(recipient.getNotificationChannel()); } } for (NotificationChannel existingChannel : notificationManager.getNotificationChannels()) { if (existingChannel.getId().startsWith(CONTACT_PREFIX) && !customChannelIds.contains(existingChannel.getId())) { notificationManager.deleteNotificationChannel(existingChannel.getId()); } else if (existingChannel.getId().startsWith(MESSAGES_PREFIX) && !existingChannel.getId().equals(getMessagesChannel(context))) { notificationManager.deleteNotificationChannel(existingChannel.getId()); } } for (Recipient customRecipient : customRecipients) { if (!existingChannelIds.contains(customRecipient.getNotificationChannel())) { db.setNotificationChannel(customRecipient, null); } } } @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); messages.setGroup(CATEGORY_MESSAGES); messages.enableVibration(TextSecurePreferences.isNotificationVibrateEnabled(context)); messages.setSound(TextSecurePreferences.getNotificationRingtone(context), getRingtoneAudioAttributes()); 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) { NotificationChannel appUpdates = new NotificationChannel(APP_UPDATES, context.getString(R.string.NotificationChannel_app_updates), NotificationManager.IMPORTANCE_HIGH); notificationManager.createNotificationChannel(appUpdates); } else { notificationManager.deleteNotificationChannel(APP_UPDATES); } } @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; } @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) { assert recipient.getNotificationChannel() != null; String newChannelId = generateChannelIdFor(recipient.getAddress()); boolean success = updateExistingChannel(notificationManager, recipient.getNotificationChannel(), newChannelId, channel -> setLedPreference(channel, color)); database.setNotificationChannel(recipient, success ? newChannelId : null); } } ensureCustomChannelConsistency(context); } @TargetApi(26) private static void updateMessageChannel(@NonNull Context context, @NonNull ChannelUpdater updater) { NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); int existingVersion = TextSecurePreferences.getNotificationMessagesChannelVersion(context); int newVersion = existingVersion + 1; Log.i(TAG, "Updating message channel from version " + existingVersion + " to " + newVersion); if (updateExistingChannel(notificationManager, getMessagesChannelId(existingVersion), getMessagesChannelId(newVersion), updater)) { TextSecurePreferences.setNotificationMessagesChannelVersion(context, newVersion); } else { onCreate(context, notificationManager); } } @TargetApi(26) private static boolean updateExistingChannel(@NonNull NotificationManager notificationManager, @NonNull String channelId, @NonNull String newChannelId, @NonNull ChannelUpdater updater) { NotificationChannel existingChannel = notificationManager.getNotificationChannel(channelId); if (existingChannel == null) { Log.w(TAG, "Tried to update a channel, but it didn't exist."); return false; } notificationManager.deleteNotificationChannel(existingChannel.getId()); NotificationChannel newChannel = copyChannel(existingChannel, newChannelId); updater.update(newChannel); notificationManager.createNotificationChannel(newChannel); return true; } @TargetApi(21) private static AudioAttributes getRingtoneAudioAttributes() { return new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) .build(); } @TargetApi(26) private static boolean channelExists(@Nullable NotificationChannel channel) { return channel != null && !NotificationChannel.DEFAULT_CHANNEL_ID.equals(channel.getId()); } private interface ChannelUpdater { @TargetApi(26) void update(@NonNull NotificationChannel channel); } }