package org.thoughtcrime.securesms.notifications.v2 import android.annotation.TargetApi import android.app.Notification import android.app.PendingIntent import android.content.Context import android.graphics.Bitmap import android.graphics.Color import android.graphics.drawable.Icon import android.net.Uri import android.os.Build import android.text.TextUtils import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.core.app.NotificationCompat import androidx.core.app.Person import androidx.core.app.RemoteInput import androidx.core.graphics.drawable.IconCompat import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier import org.thoughtcrime.securesms.notifications.NotificationChannels import org.thoughtcrime.securesms.notifications.ReplyMethod import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientUtil import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.util.AvatarUtil import org.thoughtcrime.securesms.util.BubbleUtil import org.thoughtcrime.securesms.util.ConversationUtil import org.thoughtcrime.securesms.util.TextSecurePreferences private const val BIG_PICTURE_DIMEN = 500 /** * Wraps the compat and OS versions of the Notification builders so we can more easily access native * features in newer versions. Also provides some domain specific helpers. * * Note: All business logic should exist in the base builder or the models that drive the notifications * like NotificationConversation and NotificationItemV2. */ sealed class NotificationBuilder(protected val context: Context) { private val privacy: NotificationPrivacyPreference = TextSecurePreferences.getNotificationPrivacy(context) abstract fun setSmallIcon(@DrawableRes drawable: Int) abstract fun setColor(@ColorInt color: Int) abstract fun setCategory(category: String) abstract fun setGroup(group: String) abstract fun setGroupAlertBehavior(behavior: Int) abstract fun setChannelId(channelId: String) abstract fun setContentTitle(contentTitle: CharSequence) abstract fun setLargeIcon(largeIcon: Bitmap?) abstract fun setShortcutId(shortcutId: String) abstract fun setContentInfo(contentInfo: String) abstract fun setNumber(number: Int) abstract fun setContentText(contentText: CharSequence?) abstract fun setContentIntent(pendingIntent: PendingIntent?) abstract fun setDeleteIntent(deleteIntent: PendingIntent?) abstract fun setSortKey(sortKey: String) abstract fun setOnlyAlertOnce(onlyAlertOnce: Boolean) abstract fun addMessages(conversation: NotificationConversation) abstract fun setGroupSummary(isGroupSummary: Boolean) abstract fun setSubText(subText: String) abstract fun addMarkAsReadActionActual(state: NotificationStateV2) abstract fun setPriority(priority: Int) abstract fun setAlarms(recipient: Recipient?) abstract fun setTicker(ticker: CharSequence) abstract fun addTurnOffJoinedNotificationsAction(pendingIntent: PendingIntent) abstract fun setBubbleMetadata(conversation: NotificationConversation, bubbleState: BubbleUtil.BubbleState) abstract fun setAutoCancel(autoCancel: Boolean) abstract fun build(): Notification protected abstract fun addPersonActual(recipient: Recipient) protected abstract fun setWhen(timestamp: Long) protected abstract fun addActions(replyMethod: ReplyMethod, conversation: NotificationConversation) protected abstract fun addMessagesActual(state: NotificationStateV2) protected abstract fun setLights(@ColorInt color: Int, onTime: Int, offTime: Int) fun addPerson(recipient: Recipient) { if (privacy.isDisplayContact) { addPersonActual(recipient) } } fun setWhen(conversation: NotificationConversation) { if (conversation.getWhen() != 0L) { setWhen(conversation.getWhen()) } } fun setWhen(notificationItem: NotificationItemV2) { if (notificationItem.timestamp != 0L) { setWhen(notificationItem.timestamp) } } fun addReplyActions(conversation: NotificationConversation) { if (!privacy.isDisplayMessage || KeyCachingService.isLocked(context) || !RecipientUtil.isMessageRequestAccepted(context, conversation.recipient) ) { return } addActions(ReplyMethod.forRecipient(context, conversation.recipient), conversation) } fun addMarkAsReadAction(state: NotificationStateV2) { if (privacy.isDisplayMessage) { addMarkAsReadActionActual(state) } } fun addMessages(state: NotificationStateV2) { if (!privacy.isDisplayContact && !privacy.isDisplayMessage) { return } addMessagesActual(state) } fun setSummaryContentText(recipient: Recipient) { if (privacy.isDisplayContact) { setContentText(context.getString(R.string.MessageNotifier_most_recent_from_s, recipient.getDisplayName(context))) } recipient.notificationChannel?.let { channel -> setChannelId(channel) } } fun setLights() { val ledColor: String = TextSecurePreferences.getNotificationLedColor(context) if (ledColor != "none") { var blinkPattern = TextSecurePreferences.getNotificationLedPattern(context) if (blinkPattern == "custom") { blinkPattern = TextSecurePreferences.getNotificationLedPatternCustom(context) } val (onTime: Int, offTime: Int) = blinkPattern.parseBlinkPattern() setLights(Color.parseColor(ledColor), onTime, offTime) } } private fun String.parseBlinkPattern(): Pair { return split(",").let { parts -> parts[0].toInt() to parts[1].toInt() } } companion object { fun create(context: Context): NotificationBuilder { return if (Build.VERSION.SDK_INT >= 28) { NotificationBuilderOS(context) } else { NotificationBuilderCompat(context) } } } /** * Notification builder using solely androidx/compat libraries. */ class NotificationBuilderCompat(context: Context) : NotificationBuilder(context) { val builder: NotificationCompat.Builder = NotificationCompat.Builder(context, NotificationChannels.getMessagesChannel(context)) override fun addActions(replyMethod: ReplyMethod, conversation: NotificationConversation) { val markAsRead: PendingIntent = conversation.getMarkAsReadIntent(context) val markAsReadAction: NotificationCompat.Action = NotificationCompat.Action.Builder(R.drawable.check, context.getString(R.string.MessageNotifier_mark_read), markAsRead) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) .build() val extender: NotificationCompat.WearableExtender = NotificationCompat.WearableExtender() builder.addAction(markAsReadAction) extender.addAction(markAsReadAction) if (conversation.mostRecentNotification.canReply(context)) { val quickReply: PendingIntent = conversation.getQuickReplyIntent(context) val remoteReply: PendingIntent = conversation.getRemoteReplyIntent(context, replyMethod) val actionName: String = context.getString(R.string.MessageNotifier_reply) val label: String = context.getString(replyMethod.toLongDescription()) val replyAction: NotificationCompat.Action = if (Build.VERSION.SDK_INT >= 24) { NotificationCompat.Action.Builder(R.drawable.ic_reply_white_36dp, actionName, remoteReply) .addRemoteInput(RemoteInput.Builder(DefaultMessageNotifier.EXTRA_REMOTE_REPLY).setLabel(label).build()) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) .build() } else { NotificationCompat.Action(R.drawable.ic_reply_white_36dp, actionName, quickReply) } val wearableReplyAction = NotificationCompat.Action.Builder(R.drawable.ic_reply, actionName, remoteReply) .addRemoteInput(RemoteInput.Builder(DefaultMessageNotifier.EXTRA_REMOTE_REPLY).setLabel(label).build()) .build() builder.addAction(replyAction) extender.addAction(wearableReplyAction) } builder.extend(extender) } override fun addMarkAsReadActionActual(state: NotificationStateV2) { val markAllAsReadAction = NotificationCompat.Action(R.drawable.check, context.getString(R.string.MessageNotifier_mark_all_as_read), state.getMarkAsReadIntent(context)) builder.addAction(markAllAsReadAction) builder.extend(NotificationCompat.WearableExtender().addAction(markAllAsReadAction)) } override fun addTurnOffJoinedNotificationsAction(pendingIntent: PendingIntent) { val turnOffTheseNotifications = NotificationCompat.Action( R.drawable.check, context.getString(R.string.MessageNotifier_turn_off_these_notifications), pendingIntent ) builder.addAction(turnOffTheseNotifications) } override fun addMessages(conversation: NotificationConversation) { val bigPictureUri: Uri? = conversation.getSlideBigPictureUri(context) if (bigPictureUri != null) { builder.setStyle( NotificationCompat.BigPictureStyle() .bigPicture(bigPictureUri.toBitmap(context, BIG_PICTURE_DIMEN)) .setSummaryText(conversation.getContentText(context)) .bigLargeIcon(null) ) return } val messagingStyle: NotificationCompat.MessagingStyle = NotificationCompat.MessagingStyle(ConversationUtil.buildPersonCompat(context, Recipient.self())) messagingStyle.conversationTitle = conversation.getConversationTitle(context) messagingStyle.isGroupConversation = conversation.isGroup conversation.notificationItems.forEach { notificationItem -> val personBuilder: Person.Builder = Person.Builder() .setKey(ConversationUtil.getShortcutId(notificationItem.individualRecipient)) .setBot(false) .setName(notificationItem.getPersonName(context)) .setUri(notificationItem.getPersonUri(context)) .setIcon(notificationItem.getPersonIcon(context).toIconCompat()) val (dataUri: Uri?, mimeType: String?) = notificationItem.getThumbnailInfo() messagingStyle.addMessage(NotificationCompat.MessagingStyle.Message(notificationItem.getPrimaryText(context), notificationItem.timestamp, personBuilder.build()).setData(mimeType, dataUri)) } builder.setStyle(messagingStyle) } override fun addMessagesActual(state: NotificationStateV2) { if (Build.VERSION.SDK_INT >= 24) { return } val style: NotificationCompat.InboxStyle = NotificationCompat.InboxStyle() for (notificationItem: NotificationItemV2 in state.notificationItems) { val line: CharSequence? = notificationItem.getInboxLine(context) if (line != null) { style.addLine(line) } addPerson(notificationItem.individualRecipient) } builder.setStyle(style) } override fun setAlarms(recipient: Recipient?) { if (NotificationChannels.supported()) { return } val ringtone: Uri? = recipient?.messageRingtone val vibrate = recipient?.messageVibrate val defaultRingtone: Uri = TextSecurePreferences.getNotificationRingtone(context) val defaultVibrate: Boolean = TextSecurePreferences.isNotificationVibrateEnabled(context) if (ringtone == null && !TextUtils.isEmpty(defaultRingtone.toString())) { builder.setSound(defaultRingtone) } else if (ringtone != null && ringtone.toString().isNotEmpty()) { builder.setSound(ringtone) } if (vibrate == RecipientDatabase.VibrateState.ENABLED || vibrate == RecipientDatabase.VibrateState.DEFAULT && defaultVibrate) { builder.setDefaults(Notification.DEFAULT_VIBRATE) } } override fun setBubbleMetadata(conversation: NotificationConversation, bubbleState: BubbleUtil.BubbleState) { // Intentionally left blank } override fun setLights(@ColorInt color: Int, onTime: Int, offTime: Int) { builder.setLights(color, onTime, offTime) } override fun setSmallIcon(drawable: Int) { builder.setSmallIcon(drawable) } override fun setColor(@ColorInt color: Int) { builder.color = color } override fun setCategory(category: String) { builder.setCategory(category) } override fun setGroup(group: String) { builder.setGroup(group) } override fun setGroupAlertBehavior(behavior: Int) { builder.setGroupAlertBehavior(behavior) } override fun setChannelId(channelId: String) { builder.setChannelId(channelId) } override fun setContentTitle(contentTitle: CharSequence) { builder.setContentTitle(contentTitle) } override fun setLargeIcon(largeIcon: Bitmap?) { builder.setLargeIcon(largeIcon) } override fun setShortcutId(shortcutId: String) { builder.setShortcutId(shortcutId) } override fun setContentInfo(contentInfo: String) { builder.setContentInfo(contentInfo) } override fun setNumber(number: Int) { builder.setNumber(number) } override fun setContentText(contentText: CharSequence?) { builder.setContentText(contentText) } override fun setTicker(ticker: CharSequence) { builder.setTicker(ticker) } override fun setContentIntent(pendingIntent: PendingIntent?) { builder.setContentIntent(pendingIntent) } override fun setDeleteIntent(deleteIntent: PendingIntent?) { builder.setDeleteIntent(deleteIntent) } override fun setSortKey(sortKey: String) { builder.setSortKey(sortKey) } override fun setOnlyAlertOnce(onlyAlertOnce: Boolean) { builder.setOnlyAlertOnce(onlyAlertOnce) } override fun setPriority(priority: Int) { if (!NotificationChannels.supported()) { builder.priority = priority } } override fun setAutoCancel(autoCancel: Boolean) { builder.setAutoCancel(autoCancel) } override fun build(): Notification { return builder.build() } override fun addPersonActual(recipient: Recipient) { builder.addPerson(recipient.contactUri.toString()) } override fun setWhen(timestamp: Long) { builder.setWhen(timestamp) } override fun setGroupSummary(isGroupSummary: Boolean) { builder.setGroupSummary(isGroupSummary) } override fun setSubText(subText: String) { builder.setSubText(subText) } } /** * Notification builder using solely on device OS libraries. */ @TargetApi(28) class NotificationBuilderOS(context: Context) : NotificationBuilder(context) { val builder: Notification.Builder = Notification.Builder(context, NotificationChannels.getMessagesChannel(context)) override fun addActions(replyMethod: ReplyMethod, conversation: NotificationConversation) { val markAsRead: PendingIntent = conversation.getMarkAsReadIntent(context) val markAsReadAction: Notification.Action = Notification.Action.Builder(context.getIcon(R.drawable.check), context.getString(R.string.MessageNotifier_mark_read), markAsRead) .setSemanticAction(Notification.Action.SEMANTIC_ACTION_MARK_AS_READ) .build() val extender: Notification.WearableExtender = Notification.WearableExtender() builder.addAction(markAsReadAction) extender.addAction(markAsReadAction) if (conversation.mostRecentNotification.canReply(context)) { val remoteReply: PendingIntent = conversation.getRemoteReplyIntent(context, replyMethod) val actionName: String = context.getString(R.string.MessageNotifier_reply) val label: String = context.getString(replyMethod.toLongDescription()) val replyAction: Notification.Action = Notification.Action.Builder(context.getIcon(R.drawable.ic_reply_white_36dp), actionName, remoteReply) .addRemoteInput(android.app.RemoteInput.Builder(DefaultMessageNotifier.EXTRA_REMOTE_REPLY).setLabel(label).build()) .setSemanticAction(Notification.Action.SEMANTIC_ACTION_REPLY) .build() val wearableReplyAction = Notification.Action.Builder(context.getIcon(R.drawable.ic_reply), actionName, remoteReply) .addRemoteInput(android.app.RemoteInput.Builder(DefaultMessageNotifier.EXTRA_REMOTE_REPLY).setLabel(label).build()) .build() builder.addAction(replyAction) extender.addAction(wearableReplyAction) } builder.extend(extender) } override fun addMarkAsReadActionActual(state: NotificationStateV2) { val markAllAsReadAction: Notification.Action = Notification.Action.Builder( context.getIcon(R.drawable.check), context.getString(R.string.MessageNotifier_mark_all_as_read), state.getMarkAsReadIntent(context) ).build() builder.addAction(markAllAsReadAction) builder.extend(Notification.WearableExtender().addAction(markAllAsReadAction)) } override fun addTurnOffJoinedNotificationsAction(pendingIntent: PendingIntent) { val turnOffTheseNotifications: Notification.Action = Notification.Action.Builder( context.getIcon(R.drawable.check), context.getString(R.string.MessageNotifier_turn_off_these_notifications), pendingIntent ).build() builder.addAction(turnOffTheseNotifications) } override fun addMessages(conversation: NotificationConversation) { val bigPictureUri: Uri? = conversation.getSlideBigPictureUri(context) if (bigPictureUri != null) { builder.style = Notification.BigPictureStyle() .bigPicture(bigPictureUri.toBitmap(context, BIG_PICTURE_DIMEN)) .setSummaryText(conversation.getContentText(context)) .bigLargeIcon(null as Bitmap?) return } val messagingStyle: Notification.MessagingStyle = Notification.MessagingStyle(ConversationUtil.buildPerson(context, Recipient.self())) messagingStyle.conversationTitle = conversation.getConversationTitle(context) messagingStyle.isGroupConversation = conversation.isGroup conversation.notificationItems.forEach { notificationItem -> val personBuilder: android.app.Person.Builder = android.app.Person.Builder() .setKey(ConversationUtil.getShortcutId(notificationItem.individualRecipient)) .setBot(false) .setName(notificationItem.getPersonName(context)) .setUri(notificationItem.getPersonUri(context)) .setIcon(notificationItem.getPersonIcon(context).toIcon()) val (dataUri: Uri?, mimeType: String?) = notificationItem.getThumbnailInfo() messagingStyle.addMessage(Notification.MessagingStyle.Message(notificationItem.getPrimaryText(context), notificationItem.timestamp, personBuilder.build()).setData(mimeType, dataUri)) } builder.style = messagingStyle } override fun setBubbleMetadata(conversation: NotificationConversation, bubbleState: BubbleUtil.BubbleState) { if (Build.VERSION.SDK_INT < ConversationUtil.CONVERSATION_SUPPORT_VERSION) { return } val intent = PendingIntent.getActivity( context, 0, ConversationIntents.createBubbleIntent(context, conversation.recipient.id, conversation.threadId), 0 ) val bubbleMetadata = Notification.BubbleMetadata.Builder(intent, AvatarUtil.getIconForShortcut(context, conversation.recipient)) .setAutoExpandBubble(bubbleState === BubbleUtil.BubbleState.SHOWN) .setDesiredHeight(600) .setSuppressNotification(bubbleState === BubbleUtil.BubbleState.SHOWN) .build() builder.setBubbleMetadata(bubbleMetadata) } override fun addMessagesActual(state: NotificationStateV2) { // Intentionally left blank } override fun setLights(@ColorInt color: Int, onTime: Int, offTime: Int) { // Intentionally left blank } override fun setAlarms(recipient: Recipient?) { // Intentionally left blank } override fun setSmallIcon(drawable: Int) { builder.setSmallIcon(drawable) } override fun setColor(@ColorInt color: Int) { builder.setColor(color) } override fun setCategory(category: String) { builder.setCategory(category) } override fun setGroup(group: String) { builder.setGroup(group) } override fun setGroupAlertBehavior(behavior: Int) { builder.setGroupAlertBehavior(behavior) } override fun setChannelId(channelId: String) { builder.setChannelId(channelId) } override fun setContentTitle(contentTitle: CharSequence) { builder.setContentTitle(contentTitle) } override fun setLargeIcon(largeIcon: Bitmap?) { builder.setLargeIcon(largeIcon) } override fun setShortcutId(shortcutId: String) { builder.setShortcutId(shortcutId) } @Suppress("DEPRECATION") override fun setContentInfo(contentInfo: String) { builder.setContentInfo(contentInfo) } override fun setNumber(number: Int) { builder.setNumber(number) } override fun setContentText(contentText: CharSequence?) { builder.setContentText(contentText) } override fun setTicker(ticker: CharSequence) { builder.setTicker(ticker) } override fun setContentIntent(pendingIntent: PendingIntent?) { builder.setContentIntent(pendingIntent) } override fun setDeleteIntent(deleteIntent: PendingIntent?) { builder.setDeleteIntent(deleteIntent) } override fun setSortKey(sortKey: String) { builder.setSortKey(sortKey) } override fun setOnlyAlertOnce(onlyAlertOnce: Boolean) { builder.setOnlyAlertOnce(onlyAlertOnce) } override fun setPriority(priority: Int) { // Intentionally left blank } override fun setAutoCancel(autoCancel: Boolean) { builder.setAutoCancel(autoCancel) } override fun build(): Notification { return builder.build() } override fun addPersonActual(recipient: Recipient) { builder.addPerson(ConversationUtil.buildPerson(context, recipient)) } override fun setWhen(timestamp: Long) { builder.setWhen(timestamp) } override fun setGroupSummary(isGroupSummary: Boolean) { builder.setGroupSummary(isGroupSummary) } override fun setSubText(subText: String) { builder.setSubText(subText) } } } private fun Bitmap?.toIconCompat(): IconCompat? { return if (this != null) { IconCompat.createWithBitmap(this) } else { null } } @RequiresApi(23) private fun Bitmap?.toIcon(): Icon? { return if (this != null) { Icon.createWithBitmap(this) } else { null } } @RequiresApi(23) private fun Context.getIcon(@DrawableRes drawableRes: Int): Icon { return Icon.createWithResource(this, drawableRes) } @StringRes private fun ReplyMethod.toLongDescription(): Int { return when (this) { ReplyMethod.GroupMessage -> R.string.MessageNotifier_reply ReplyMethod.SecureMessage -> R.string.MessageNotifier_signal_message ReplyMethod.UnsecuredSmsMessage -> R.string.MessageNotifier_unsecured_sms } }