package org.thoughtcrime.securesms.notifications; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationChannelGroup; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.drawable.Drawable; import android.media.AudioAttributes; import android.net.Uri; import android.os.Build; import android.text.TextUtils; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.app.RemoteInput; import androidx.core.app.TaskStackBuilder; import com.b44t.messenger.DcChat; import com.b44t.messenger.DcMsg; import com.bumptech.glide.load.engine.DiskCacheStrategy; import org.thoughtcrime.securesms.ConversationActivity; import org.thoughtcrime.securesms.ConversationListActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.connect.ApplicationDcContext; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.Prefs; import org.thoughtcrime.securesms.util.Util; import java.math.BigInteger; import java.security.MessageDigest; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.concurrent.TimeUnit; public class NotificationCenter { private static final String TAG = NotificationCenter.class.getSimpleName(); @NonNull private ApplicationDcContext dcContext; @NonNull private Context context; private volatile int visibleChatId = 0; private volatile long lastAudibleNotification = 0; private static final long MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(2); private final HashMap<Integer, ArrayList<String>> inboxes = new HashMap<>(); // contains the last lines of each chat public NotificationCenter(ApplicationDcContext dcContext) { this.dcContext = dcContext; this.context = dcContext.context.getApplicationContext(); } private @Nullable Uri effectiveSound(int chatId) { // chatId=0: return app-global setting @Nullable Uri chatRingtone = Prefs.getChatRingtone(context, chatId); if (chatRingtone!=null) { return chatRingtone; } else { @NonNull Uri appDefaultRingtone = Prefs.getNotificationRingtone(context); if (!TextUtils.isEmpty(appDefaultRingtone.toString())) { return appDefaultRingtone; } } return null; } private boolean effectiveVibrate(int chatId) { // chatId=0: return app-global setting Prefs.VibrateState vibrate = Prefs.getChatVibrate(context, chatId); if (vibrate == Prefs.VibrateState.ENABLED) { return true; } else if (vibrate == Prefs.VibrateState.DISABLED) { return false; } return Prefs.isNotificationVibrateEnabled(context); } private boolean requiresIndependentChannel(int chatId) { return Prefs.getChatRingtone(context, chatId) != null || Prefs.getChatVibrate(context, chatId) != Prefs.VibrateState.DEFAULT; } private int getLedArgb(String ledColor) { int argb; try { argb = Color.parseColor(ledColor); } catch (Exception e) { argb = Color.rgb(0xFF, 0xFF, 0xFF); } return argb; } private PendingIntent getOpenChatlistIntent() { Intent intent = new Intent(context, ConversationListActivity.class); intent.putExtra(ConversationListActivity.CLEAR_NOTIFICATIONS, true); return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); } private PendingIntent getOpenChatIntent(int chatId) { Intent intent = new Intent(context, ConversationActivity.class); intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, chatId); intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); return TaskStackBuilder.create(context) .addNextIntentWithParentStack(intent) .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); } private PendingIntent getRemoteReplyIntent(int chatId) { Intent intent = new Intent(RemoteReplyReceiver.REPLY_ACTION); intent.setClass(context, RemoteReplyReceiver.class); intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); intent.putExtra(RemoteReplyReceiver.CHAT_ID_EXTRA, chatId); intent.setPackage(context.getPackageName()); return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); } private PendingIntent getMarkAsReadIntent(int chatId, boolean markNoticed) { Intent intent = new Intent(markNoticed? MarkReadReceiver.MARK_NOTICED_ACTION : MarkReadReceiver.CANCEL_ACTION); intent.setClass(context, MarkReadReceiver.class); intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); intent.putExtra(MarkReadReceiver.CHAT_ID_EXTRA, chatId); intent.setPackage(context.getPackageName()); return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); } // Groups and Notification channel groups // -------------------------------------------------------------------------------------------- // this is just to further organize the appearance of channels in the settings UI private static final String CH_GRP_MSG = "chgrp_msg"; // this is to group together notifications as such, maybe including a summary, // see https://developer.android.com/training/notify-user/group.html private static final String GRP_MSG = "grp_msg"; // Notification IDs // -------------------------------------------------------------------------------------------- public static final int ID_PERMANTENT = 1; public static final int ID_MSG_SUMMARY = 2; public static final int ID_MSG_OFFSET = 0; // msgId is added - as msgId start at 10, there are no conflicts with lower numbers // Notification channels // -------------------------------------------------------------------------------------------- // Overview: // - since SDK 26 (Oreo), a NotificationChannel is a MUST for notifications // - NotificationChannels are defined by a channelId // and its user-editable settings have a higher precedence as the Notification.Builder setting // - once created, NotificationChannels cannot be modified programmatically // - NotificationChannels can be deleted, however, on re-creation with the same id, // it becomes un-deleted with the old user-defined settings // // How we use Notification channel: // - We include the delta-chat-notifications settings into the name of the channelId // - The chatId is included only, if there are separate sound- or vibration-settings for a chat // - This way, we have stable and few channelIds and the user // can edit the notifications in Delta Chat as well as in the system // channelIds: CH_MSG_* are used here, the other ones from outside (defined here to have some overview) public static final String CH_MSG_PREFIX = "ch_msg"; public static final String CH_MSG_VERSION = "4"; public static final String CH_PERMANENT = "dc_foreground_notification_ch"; private boolean notificationChannelsSupported() { return Build.VERSION.SDK_INT >= 26; } // full name is "ch_msgV_HASH" or "ch_msgV_HASH.CHATID" private String computeChannelId(String ledColor, boolean vibrate, @Nullable Uri ringtone, int chatId) { String channelId = CH_MSG_PREFIX; try { MessageDigest md = MessageDigest.getInstance("SHA-256"); md.update(ledColor.getBytes()); md.update(vibrate ? (byte) 1 : (byte) 0); md.update((ringtone != null ? ringtone.toString() : "").getBytes()); if (chatId!=0) { // for multi-account, force different channelIds for maybe the same chatIds in multiple accounts md.update(dcContext.getConfig("addr").getBytes()); } String hash = String.format("%X", new BigInteger(1, md.digest())).substring(0, 16); channelId = CH_MSG_PREFIX + CH_MSG_VERSION + "_" + hash; if (chatId!=0) { channelId += String.format(".%d", chatId); } } catch(Exception e) { Log.e(TAG, e.toString()); } return channelId; } // return chatId from "ch_msgV_HASH.CHATID" or 0 private int parseNotificationChannelChatId(String channelId) { try { int point = channelId.lastIndexOf("."); if (point>0) { return Integer.parseInt(channelId.substring(point + 1)); } } catch(Exception e) { } return 0; } private String getNotificationChannelGroup(NotificationManagerCompat notificationManager) { if (notificationChannelsSupported() && notificationManager.getNotificationChannelGroup(CH_GRP_MSG) == null) { NotificationChannelGroup chGrp = new NotificationChannelGroup(CH_GRP_MSG, context.getString(R.string.pref_chats)); notificationManager.createNotificationChannelGroup(chGrp); } return CH_GRP_MSG; } private String getNotificationChannel(NotificationManagerCompat notificationManager, DcChat dcChat) { int chatId = dcChat.getId(); String channelId = CH_MSG_PREFIX; if (notificationChannelsSupported()) { try { // get all values we'll use as settings for the NotificationChannel String ledColor = Prefs.getNotificationLedColor(context); boolean defaultVibrate = effectiveVibrate(chatId); @Nullable Uri ringtone = effectiveSound(chatId); boolean isIndependent = requiresIndependentChannel(chatId); // get channel id from these settings channelId = computeChannelId(ledColor, defaultVibrate, ringtone, isIndependent? chatId : 0); // user-visible name of the channel - // we just use the name of the chat or "Default" // (the name is shown in the context of the group "Chats" - that should be enough context) String name = context.getString(R.string.def); if (isIndependent) { name = dcChat.getName(); } // check if there is already a channel with the given name List<NotificationChannel> channels = notificationManager.getNotificationChannels(); boolean channelExists = false; for (int i = 0; i < channels.size(); i++) { String currChannelId = channels.get(i).getId(); if (currChannelId.startsWith(CH_MSG_PREFIX)) { // this is one of the message channels handled here ... if (currChannelId.equals(channelId)) { // ... this is the actually required channel, fine :) // update the name to reflect localize changes and chat renames channelExists = true; channels.get(i).setName(name); } else { // ... another message channel, delete if it is not in use. int currChatId = parseNotificationChannelChatId(currChannelId); if (!currChannelId.equals(computeChannelId(ledColor, effectiveVibrate(currChatId), effectiveSound(currChatId), currChatId))) { notificationManager.deleteNotificationChannel(currChannelId); } } } } // create a channel with the given settings; // we cannot change the settings, however, this is handled by using different values for chId if(!channelExists) { NotificationChannel channel = new NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_HIGH); channel.setDescription("Informs about new messages."); channel.setGroup(getNotificationChannelGroup(notificationManager)); channel.enableVibration(defaultVibrate); channel.setShowBadge(true); if (!ledColor.equals("none")) { channel.enableLights(true); channel.setLightColor(getLedArgb(ledColor)); } else { channel.enableLights(false); } if (ringtone != null && !TextUtils.isEmpty(ringtone.toString())) { channel.setSound(ringtone, new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) .build()); } notificationManager.createNotificationChannel(channel); } } catch(Exception e) { e.printStackTrace(); } } return channelId; } // add notifications & co. // -------------------------------------------------------------------------------------------- public void addNotification(int chatId, int msgId) { Util.runOnAnyBackgroundThread(() -> { DcChat dcChat = dcContext.getChat(chatId); if (!Prefs.isNotificationsEnabled(context) || dcChat.isMuted()) { return; } if (chatId == visibleChatId) { if (Prefs.isInChatNotifications(context)) { InChatSounds.getInstance(context).playIncomingSound(); } return; } if (dcChat.isDeviceTalk()) { // currently, we just never notify on device chat. // esp. on first start, this is annoying. return; } NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); // get notification text as a single line NotificationPrivacyPreference privacy = Prefs.getNotificationPrivacy(context); DcMsg dcMsg = dcContext.getMsg(msgId); String line = privacy.isDisplayMessage()? dcMsg.getSummarytext(2000) : context.getString(R.string.notify_new_message); if (dcChat.isGroup() && privacy.isDisplayContact()) { line = dcContext.getContact(dcMsg.getFromId()).getFirstName() + ": " + line; } // play signal? long now = System.currentTimeMillis(); boolean signal = (now - lastAudibleNotification) > MIN_AUDIBLE_PERIOD_MILLIS; if (signal) { lastAudibleNotification = now; } // create a basic notification // even without a name or message displayed, // it makes sense to use separate notification channels and to open the respective chat directly - // the user may eg. have chosen a different sound String notificationChannel = getNotificationChannel(notificationManager, dcChat); NotificationCompat.Builder builder = new NotificationCompat.Builder(context, notificationChannel) .setSmallIcon(R.drawable.icon_notification) .setColor(context.getResources().getColor(R.color.delta_primary)) .setPriority(Prefs.getNotificationPriority(context)) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setGroup(GRP_MSG) .setOnlyAlertOnce(!signal) .setContentText(line) .setDeleteIntent(getMarkAsReadIntent(chatId, false)) .setContentIntent(getOpenChatIntent(chatId)); if (privacy.isDisplayContact()) { builder.setContentTitle(dcChat.getName()); } // if privacy allows, for better accessibility, // prepend the sender in the ticker also for one-to-one chats (for group-chats, this is already done) String tickerLine = line; if (!dcChat.isGroup() && privacy.isDisplayContact()) { line = dcContext.getContact(dcMsg.getFromId()).getFirstName() + ": " + line; } builder.setTicker(tickerLine); // set sound, vibrate, led for systems that do not have notification channels if (!notificationChannelsSupported()) { if (signal) { Uri sound = effectiveSound(chatId); if (sound != null) { builder.setSound(sound); } boolean vibrate = effectiveVibrate(chatId); if (vibrate) { builder.setDefaults(Notification.DEFAULT_VIBRATE); } } String ledColor = Prefs.getNotificationLedColor(context); if (!ledColor.equals("none")) { builder.setLights(getLedArgb(ledColor),500, 2000); } } // set avatar Recipient recipient = new Recipient(context, dcChat, null); if (privacy.isDisplayContact()) { try { Drawable drawable; ContactPhoto contactPhoto = recipient.getContactPhoto(context); if (contactPhoto != null) { drawable = GlideApp.with(context.getApplicationContext()) .load(contactPhoto) .diskCacheStrategy(DiskCacheStrategy.NONE) .circleCrop() .submit(context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height)) .get(); } else { drawable = recipient.getFallbackContactPhoto().asDrawable(context, recipient.getFallbackAvatarColor(context)); } if (drawable != null) { int wh = context.getResources().getDimensionPixelSize(R.dimen.contact_photo_target_size); Bitmap bitmap = BitmapUtil.createFromDrawable(drawable, wh, wh); if (bitmap != null) { builder.setLargeIcon(bitmap); } } } catch (Exception e) { Log.w(TAG, e); } } // add buttons that allow some actions without opening Delta Chat. // if privacy options are enabled, the buttons are not added. if (privacy.isDisplayContact() && privacy.isDisplayMessage() && !Prefs.isScreenLockEnabled(context)) { try { PendingIntent inNotificationReplyIntent = getRemoteReplyIntent(chatId); PendingIntent markReadIntent = getMarkAsReadIntent(chatId, true); NotificationCompat.Action markAsReadAction = new NotificationCompat.Action(R.drawable.check, context.getString(R.string.notify_mark_read), markReadIntent); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { NotificationCompat.Action replyAction = new NotificationCompat.Action.Builder(R.drawable.ic_reply_white_36dp, context.getString(R.string.notify_reply_button), inNotificationReplyIntent) .addRemoteInput(new RemoteInput.Builder(RemoteReplyReceiver.EXTRA_REMOTE_REPLY) .setLabel(context.getString(R.string.notify_reply_button)).build()) .build(); builder.addAction(replyAction); } NotificationCompat.Action wearableReplyAction = new NotificationCompat.Action.Builder(R.drawable.ic_reply, context.getString(R.string.notify_reply_button), inNotificationReplyIntent) .addRemoteInput(new RemoteInput.Builder(RemoteReplyReceiver.EXTRA_REMOTE_REPLY) .setLabel(context.getString(R.string.notify_reply_button)).build()) .build(); builder.addAction(markAsReadAction); builder.extend(new NotificationCompat.WearableExtender().addAction(markAsReadAction).addAction(wearableReplyAction)); } catch(Exception e) { Log.w(TAG, e); } } // create a tiny inbox (gets visible if the notification is expanded) if (privacy.isDisplayContact() && privacy.isDisplayMessage()) { try { NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(); synchronized (inboxes) { ArrayList<String> lines = inboxes.get(chatId); if (lines == null) { lines = new ArrayList<>(); inboxes.put(chatId, lines); } lines.add(line); for (int l = lines.size() - 1; l >= 0; l--) { inboxStyle.addLine(lines.get(l)); } } builder.setStyle(inboxStyle); } catch(Exception e) { Log.w(TAG, e); } } // messages count, some os make some use of that // - do not use setSubText() as this is displayed together with setContentInfo() eg. on Lollipop // - setNumber() may overwrite setContentInfo(), should be called last // weird stuff. int cnt = dcContext.getFreshMsgCount(chatId); builder.setContentInfo(String.valueOf(cnt)); builder.setNumber(cnt); // add notification, we use one notification per chat, // esp. older android are not that great at grouping notificationManager.notify(ID_MSG_OFFSET + chatId, builder.build()); // group notifications together in a summary, this is possible since SDK 24 (Android 7) // https://developer.android.com/training/notify-user/group.html // in theory, this won't be needed due to setGroup(), however, in practise, it is needed up to at least Android 10. if (Build.VERSION.SDK_INT >= 24) { NotificationCompat.Builder summary = new NotificationCompat.Builder(context, notificationChannel) .setGroup(GRP_MSG) .setGroupSummary(true) .setSmallIcon(R.drawable.icon_notification) .setColor(context.getResources().getColor(R.color.delta_primary)) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setContentTitle("Delta Chat") // content title would only be used on SDK <24 .setContentText("New messages") // content text would only be used on SDK <24 .setContentIntent(getOpenChatlistIntent()); notificationManager.notify(ID_MSG_SUMMARY, summary.build()); } }); } public void removeNotifications(int chatId) { boolean removeSummary = false; synchronized (inboxes) { inboxes.remove(chatId); removeSummary = inboxes.isEmpty(); } try { NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); notificationManager.cancel(ID_MSG_OFFSET + chatId); if (removeSummary) { notificationManager.cancel(ID_MSG_SUMMARY); } } catch (Exception e) { Log.w(TAG, e); } } public void removeAllNotifiations() { synchronized (inboxes) { inboxes.clear(); } NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); notificationManager.cancelAll(); } public void updateVisibleChat(int chatId) { Util.runOnAnyBackgroundThread(() -> { visibleChatId = chatId; if (chatId!=0) { removeNotifications(chatId); } }); } public void maybePlaySendSound(DcChat dcChat) { if (Prefs.isInChatNotifications(context) && Prefs.isNotificationsEnabled(context) && !dcChat.isMuted()) { InChatSounds.getInstance(context).playSendSound(); } } }