-
-
Notifications
You must be signed in to change notification settings - Fork 676
[Android] Improve notification UI. #4842
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6eaf7c8
c4cf3fb
1ed100f
ee5d1e0
4229325
e32186c
abd605d
115c709
eaa8b40
938089b
c716b6c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,25 +3,28 @@ | |
| package com.zulipmobile.notifications | ||
|
|
||
| import android.annotation.SuppressLint | ||
| import android.app.Notification | ||
| import android.app.NotificationChannel | ||
| import android.app.NotificationManager | ||
| import android.app.PendingIntent | ||
| 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.os.Bundle | ||
| import android.provider.Settings | ||
| import android.text.TextUtils | ||
| import android.service.notification.StatusBarNotification | ||
| import android.util.Log | ||
| import androidx.core.app.NotificationManagerCompat | ||
| import androidx.core.app.NotificationCompat | ||
| import androidx.core.app.NotificationManagerCompat | ||
| import androidx.core.app.Person | ||
| import androidx.core.graphics.drawable.IconCompat | ||
| import com.facebook.react.ReactApplication | ||
| import me.leolin.shortcutbadger.ShortcutBadger | ||
|
|
||
| import com.zulipmobile.BuildConfig | ||
| import com.zulipmobile.R | ||
| import com.zulipmobile.ZLog | ||
| import me.leolin.shortcutbadger.ShortcutBadger | ||
|
|
||
| private val CHANNEL_ID = "default" | ||
| private val NOTIFICATION_ID = 435 | ||
|
|
@@ -33,6 +36,8 @@ val ACTION_CLEAR = "ACTION_CLEAR" | |
| val EXTRA_NOTIFICATION_DATA = "data" | ||
|
|
||
| fun createNotificationChannel(context: Context) { | ||
| val audioAttr: AudioAttributes = AudioAttributes.Builder() | ||
| .setUsage(AudioAttributes.USAGE_NOTIFICATION).build() | ||
| if (Build.VERSION.SDK_INT >= 26) { | ||
| val name = context.getString(R.string.notification_channel_name) | ||
|
|
||
|
|
@@ -43,6 +48,7 @@ fun createNotificationChannel(context: Context) { | |
| NotificationChannel(CHANNEL_ID, name, NotificationManagerCompat.IMPORTANCE_HIGH).apply { | ||
| enableLights(true) | ||
| enableVibration(true) | ||
| setSound(getNotificationSoundUri(), audioAttr) | ||
| } | ||
| NotificationManagerCompat.from(context).createNotificationChannel(channel) | ||
| } | ||
|
|
@@ -53,7 +59,7 @@ private fun logNotificationData(msg: String, data: Bundle) { | |
| Log.v(TAG, "$msg: $data") | ||
| } | ||
|
|
||
| internal fun onReceived(context: Context, conversations: ConversationMap, mapData: Map<String, String>) { | ||
| internal fun onReceived(context: Context, mapData: Map<String, String>) { | ||
| // TODO refactor to not need this; reflects a juxtaposition of FCM with old GCM interfaces. | ||
| val data = Bundle() | ||
| for ((key, value) in mapData) { | ||
|
|
@@ -70,124 +76,211 @@ internal fun onReceived(context: Context, conversations: ConversationMap, mapDat | |
| } | ||
|
|
||
| if (fcmMessage is MessageFcmMessage) { | ||
| addConversationToMap(fcmMessage, conversations) | ||
| updateNotification(context, conversations, fcmMessage) | ||
| updateNotification(context, fcmMessage) | ||
| } else if (fcmMessage is RemoveFcmMessage) { | ||
| removeMessagesFromMap(conversations, fcmMessage) | ||
| if (conversations.isEmpty()) { | ||
| NotificationManagerCompat.from(context).cancelAll() | ||
| } | ||
| removeNotification(context, fcmMessage) | ||
| } | ||
| } | ||
|
|
||
| private fun updateNotification( | ||
| context: Context, conversations: ConversationMap, fcmMessage: MessageFcmMessage) { | ||
| if (conversations.isEmpty()) { | ||
| NotificationManagerCompat.from(context).cancelAll() | ||
| return | ||
| fun removeNotification(context: Context, fcmMessage: RemoveFcmMessage) { | ||
| val statusBarNotifications = getActiveNotifications(context) ?: return | ||
| val groupKey = extractGroupKey(fcmMessage.identity) | ||
| // Find any conversations we can cancel the notification for. | ||
| // The API doesn't lend itself to removing individual messages as | ||
| // they're read, so we wait until we're ready to remove the whole | ||
| // conversation's notification. | ||
| // See: https://github.com/zulip/zulip-mobile/pull/4842#pullrequestreview-725817909 | ||
| for (statusBarNotification in statusBarNotifications) { | ||
| // Each statusBarNotification represents one Zulip conversation. | ||
| val notification = statusBarNotification.notification | ||
| val lastMessageId = notification.extras.getInt("lastZulipMessageId") | ||
| if (fcmMessage.messageIds.contains(lastMessageId)) { | ||
| // The latest Zulip message in this conversation was read. | ||
| // That's our cue to cancel the notification for the conversation. | ||
| NotificationManagerCompat.from(context).cancel(statusBarNotification.tag, statusBarNotification.id) | ||
| } | ||
| } | ||
| var counter = 0 | ||
| for (statusBarNotification in statusBarNotifications) { | ||
| if (statusBarNotification.notification.group == groupKey) { | ||
| counter++ | ||
| } | ||
| } | ||
| if (counter == 2) { | ||
| // counter will be 2 only when summary notification and last notification are | ||
| // present; in this case, we remove summary notification. | ||
| NotificationManagerCompat.from(context).cancel(groupKey, NOTIFICATION_ID) | ||
| } | ||
| val notification = getNotificationBuilder(context, conversations, fcmMessage).build() | ||
| NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) | ||
| } | ||
|
|
||
| private fun getNotificationSoundUri(context: Context): Uri { | ||
| // Note: Provide default notification sound until we found a good sound | ||
| // return Uri.parse("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${context.packageName}/${R.raw.zulip}") | ||
| return Settings.System.DEFAULT_NOTIFICATION_URI | ||
| } | ||
|
|
||
| private fun getNotificationBuilder( | ||
| context: Context, conversations: ConversationMap, fcmMessage: MessageFcmMessage): NotificationCompat.Builder { | ||
| val builder = NotificationCompat.Builder(context, CHANNEL_ID) | ||
|
|
||
| private fun createViewPendingIntent(fcmMessage: MessageFcmMessage, context: Context): PendingIntent { | ||
| val uri = Uri.fromParts("zulip", "msgid:${fcmMessage.zulipMessageId}", "") | ||
| val viewIntent = Intent(Intent.ACTION_VIEW, uri, context, NotificationIntentService::class.java) | ||
| viewIntent.putExtra(EXTRA_NOTIFICATION_DATA, fcmMessage.dataForOpen()) | ||
| val viewPendingIntent = PendingIntent.getService(context, 0, viewIntent, 0) | ||
| builder.setContentIntent(viewPendingIntent) | ||
| builder.setAutoCancel(true) | ||
| return PendingIntent.getService(context, 0, viewIntent, 0) | ||
| } | ||
|
|
||
| private fun createDismissAction(context: Context): NotificationCompat.Action { | ||
| val dismissIntent = Intent(context, NotificationIntentService::class.java) | ||
| dismissIntent.action = ACTION_CLEAR | ||
| val piDismiss = PendingIntent.getService(context, 0, dismissIntent, 0) | ||
| return NotificationCompat.Action( | ||
| android.R.drawable.ic_menu_close_clear_cancel, | ||
| "Clear", | ||
| piDismiss | ||
| ) | ||
| } | ||
|
|
||
| val totalMessagesCount = extractTotalMessagesCount(conversations) | ||
| /** | ||
| * @param context | ||
| * @param conversationKey Unique Key identifying a conversation, the current | ||
| * implementation assumes that each notification corresponds to 1 and only 1 | ||
| * conversation. | ||
| * | ||
| * A conversation can be any of: Topic in Stream, GroupPM or PM for a | ||
| * specific user in a specific realm. | ||
| */ | ||
| private fun getActiveNotification(context: Context, conversationKey: String): Notification? { | ||
| // activeNotifications are not available in NotificationCompatManager | ||
| // Hence we have to use instance of NotificationManager. | ||
| val notificationManager = | ||
| context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? | ||
|
|
||
| if (BuildConfig.DEBUG) { | ||
| builder.setSmallIcon(R.mipmap.ic_launcher) | ||
| } else { | ||
| builder.setSmallIcon(R.drawable.zulip_notification) | ||
| val activeStatusBarNotifications = notificationManager?.activeNotifications | ||
| if (activeStatusBarNotifications != null) { | ||
| for (statusBarNotification in activeStatusBarNotifications) { | ||
| if (statusBarNotification.tag == conversationKey) { | ||
| return statusBarNotification.notification | ||
| } | ||
| } | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| // This should agree with `BRAND_COLOR` in the JS code. | ||
| builder.setColor(Color.rgb(100, 146, 254)) | ||
|
|
||
| val nameList = extractNames(conversations) | ||
| private fun getActiveNotifications(context: Context): Array<StatusBarNotification>? = | ||
| (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager?)?.activeNotifications | ||
|
|
||
| if (conversations.size == 1 && nameList.size == 1) { | ||
| //Only one 1 notification therefore no using of big view styles | ||
| if (totalMessagesCount > 1) { | ||
| builder.setContentTitle("${fcmMessage.sender.fullName} ($totalMessagesCount)") | ||
| // TODO: Add a Text saying n messages in m conversations. (this will | ||
| // only be visible in API < 24) | ||
| private fun createSummaryNotification( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How did you find that it's required by Android even on newer versions? From the guide doc for this feature:
If we don't need this when on Android 7+ (aka API 24+), then it's probably time to drop support for older Android. We'd have to take a quick look at the numbers, but older versions were down to 3% a year ago, so by now they're probably down into the range where we can cheerfully drop support. And being able to keep this code simpler would definitely be a good reason to do it if so.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I tested a build without summary Notification, specifically without the notify call to summaryNotification, observation is that notification do occur but they don't group together, even in android 7+. It seems that this notify call triggers the actual grouping. Also while they don't appear completely in Android 7+, (since they get overridden by the summary notification built by the Android OS) they still have some visual influence on how the summary notification looks, for instance, setting
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Hmm fascinating -- thanks for determining that. Seems like a bug in the docs that they say it's optional, then. I guess in the example code says: val summaryNotification = NotificationCompat.Builder(this@MainActivity, CHANNEL_ID)
.setContentTitle(emailObject.getSummary())
//set content text to support devices running API level < 24
.setContentText("Two new messages")
// […]which makes it sound more like the actual situation you discovered, that the summary notification itself is required but the text is only for older devices. We did find when I looked recently that indeed Android 6 and older is down to a small enough sliver of our userbase that it makes sense to drop support for it as soon as there's something where supporting it would make things more complicated for us. So I think we won't go and add a summary text, and instead will just drop support and increase the minimum to Android 7. |
||
| context: Context, | ||
| fcmMessage: MessageFcmMessage, | ||
| groupKey: String | ||
| ): NotificationCompat.Builder { | ||
| val realmUri = fcmMessage.identity.realmUri.toString() | ||
| return NotificationCompat.Builder(context, CHANNEL_ID).apply { | ||
| color = context.getColor(R.color.brandColor) | ||
| if (BuildConfig.DEBUG) { | ||
| setSmallIcon(R.mipmap.ic_launcher) | ||
| } else { | ||
| builder.setContentTitle(fcmMessage.sender.fullName) | ||
| } | ||
| builder.setContentText(fcmMessage.content) | ||
| if (fcmMessage.recipient is Recipient.Stream) { | ||
| val (stream, topic) = fcmMessage.recipient | ||
| val displayTopic = "$stream > $topic" | ||
| builder.setSubText("Message on $displayTopic") | ||
| setSmallIcon(R.drawable.zulip_notification) | ||
| } | ||
| fetchBitmap(sizedURL(context, fcmMessage.sender.avatarURL, 64f)) | ||
| ?.let { builder.setLargeIcon(it) } | ||
| builder.setStyle(NotificationCompat.BigTextStyle().bigText(fcmMessage.content)) | ||
| } else { | ||
| val numConversations = context.resources.getQuantityString( | ||
| R.plurals.numConversations, conversations.size, conversations.size) | ||
| builder.setContentTitle("$totalMessagesCount messages in $numConversations") | ||
| builder.setContentText("Messages from ${TextUtils.join(",", nameList)}") | ||
| val inboxStyle = NotificationCompat.InboxStyle(builder) | ||
| inboxStyle.setSummaryText(numConversations) | ||
| buildNotificationContent(conversations, inboxStyle) | ||
| builder.setStyle(inboxStyle) | ||
| setStyle(NotificationCompat.InboxStyle() | ||
| .setSummaryText(realmUri) | ||
| ) | ||
| setGroup(groupKey) | ||
| setGroupSummary(true) | ||
| setAutoCancel(true) | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| ShortcutBadger.applyCount(context, totalMessagesCount) | ||
| } catch (e: Exception) { | ||
| ZLog.e(TAG, e) | ||
| private fun extractGroupKey(identity: Identity): String { | ||
| return "${identity.realmUri.toString()}|${identity.userId}" | ||
| } | ||
|
|
||
| private fun extractConversationKey(fcmMessage: MessageFcmMessage): String { | ||
| val groupKey = extractGroupKey(fcmMessage.identity) | ||
| val conversation = when (fcmMessage.recipient) { | ||
| is Recipient.Stream -> "stream:${fcmMessage.recipient.stream}\u0000${fcmMessage.recipient.topic}" | ||
| is Recipient.GroupPm -> "groupPM:${fcmMessage.recipient.pmUsers.toString()}" | ||
| is Recipient.Pm -> "private:${fcmMessage.sender.id}" | ||
| } | ||
| return "$groupKey|$conversation" | ||
| } | ||
|
|
||
| builder.setWhen(fcmMessage.timeMs) | ||
| builder.setShowWhen(true) | ||
| private fun updateNotification( | ||
| context: Context, fcmMessage: MessageFcmMessage) { | ||
| val selfUser = Person.Builder().setName(context.getString(R.string.selfUser)).build() | ||
| val sender = Person.Builder() | ||
| .setName(fcmMessage.sender.fullName) | ||
| .setIcon(IconCompat.createWithBitmap(fetchBitmap(fcmMessage.sender.avatarURL))) | ||
| .build() | ||
|
|
||
| val vPattern = longArrayOf(0, 100, 200, 100) | ||
| // NB the DEFAULT_VIBRATE flag below causes this to have no effect. | ||
| // TODO: choose a vibration pattern we like, and unset DEFAULT_VIBRATE. | ||
| builder.setVibrate(vPattern) | ||
| val title = when (fcmMessage.recipient) { | ||
| is Recipient.Stream -> "#${fcmMessage.recipient.stream} > ${fcmMessage.recipient.topic}" | ||
| // TODO use proper title for GroupPM, we will need | ||
| // to have a way to get names of PM users here. | ||
| is Recipient.GroupPm -> context.resources.getQuantityString( | ||
| R.plurals.group_pm, | ||
| fcmMessage.recipient.pmUsers.size - 2, | ||
| fcmMessage.sender.fullName, | ||
| fcmMessage.recipient.pmUsers.size - 2 | ||
| ) | ||
| is Recipient.Pm -> fcmMessage.sender.fullName | ||
| } | ||
| val isGroupConversation = when (fcmMessage.recipient) { | ||
| is Recipient.Stream -> true | ||
| is Recipient.GroupPm -> true | ||
| is Recipient.Pm -> false | ||
| } | ||
| val groupKey = extractGroupKey(fcmMessage.identity) | ||
| val conversationKey = extractConversationKey(fcmMessage) | ||
| val notification = getActiveNotification(context, conversationKey) | ||
|
|
||
| builder.setDefaults(NotificationCompat.DEFAULT_VIBRATE or NotificationCompat.DEFAULT_LIGHTS) | ||
| val messagingStyle = notification?.let { | ||
| NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(it) | ||
| } ?: NotificationCompat.MessagingStyle(selfUser) | ||
| messagingStyle | ||
| .setConversationTitle(title) | ||
| .setGroupConversation(isGroupConversation) | ||
gnprice marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .addMessage(fcmMessage.content, fcmMessage.timeMs, sender) | ||
|
|
||
| val dismissIntent = Intent(context, NotificationIntentService::class.java) | ||
| dismissIntent.action = ACTION_CLEAR | ||
| val piDismiss = PendingIntent.getService(context, 0, dismissIntent, 0) | ||
| val action = NotificationCompat.Action(android.R.drawable.ic_menu_close_clear_cancel, "Clear", piDismiss) | ||
| builder.addAction(action) | ||
| val messageCount = messagingStyle.messages.size | ||
|
|
||
| val builder = NotificationCompat.Builder(context, CHANNEL_ID) | ||
| builder.apply { | ||
| color = context.getColor(R.color.brandColor) | ||
| if (BuildConfig.DEBUG) { | ||
| setSmallIcon(R.mipmap.ic_launcher) | ||
| } else { | ||
| setSmallIcon(R.drawable.zulip_notification) | ||
| } | ||
| setAutoCancel(true) | ||
| setStyle(messagingStyle) | ||
| setGroup(groupKey) | ||
| setSound(getNotificationSoundUri()) | ||
| setContentIntent(createViewPendingIntent(fcmMessage, context)) | ||
| setNumber(messageCount) | ||
|
|
||
| val extraData = Bundle() | ||
| extraData.putInt("lastZulipMessageId", fcmMessage.zulipMessageId) | ||
| extras = extraData | ||
| } | ||
|
|
||
| val summaryNotification = createSummaryNotification(context, fcmMessage, groupKey) | ||
|
|
||
| NotificationManagerCompat.from(context).apply { | ||
| // We use `tag` param only, to uniquely identify notifications, | ||
| // and hence `id` param is provided as an arbitrary constant. | ||
| notify(groupKey, NOTIFICATION_ID, summaryNotification.build()) | ||
| notify(conversationKey, NOTIFICATION_ID, builder.build()) | ||
| } | ||
| } | ||
|
|
||
| val soundUri = getNotificationSoundUri(context) | ||
| builder.setSound(soundUri) | ||
| return builder | ||
| private fun getNotificationSoundUri(): Uri { | ||
| // Note: Provide default notification sound until we found a good sound | ||
| // return Uri.parse("${ContentResolver.SCHEME_ANDROID_RESOURCE}://${context.packageName}/${R.raw.zulip}") | ||
| return Settings.System.DEFAULT_NOTIFICATION_URI | ||
| } | ||
|
|
||
| internal fun onOpened(application: ReactApplication, conversations: ConversationMap, data: Bundle) { | ||
| internal fun onOpened(application: ReactApplication, data: Bundle) { | ||
| logNotificationData("notif opened", data) | ||
| notifyReact(application, data) | ||
| NotificationManagerCompat.from(application as Context).cancelAll() | ||
| clearConversations(conversations) | ||
| try { | ||
| ShortcutBadger.removeCount(application as Context) | ||
| } catch (e: Exception) { | ||
| ZLog.e(TAG, e) | ||
| } | ||
|
|
||
| } | ||
|
|
||
| internal fun onClear(context: Context, conversations: ConversationMap) { | ||
| clearConversations(conversations) | ||
| NotificationManagerCompat.from(context).cancelAll() | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Huh, odd that it isn't available in
NotificationCompatManager. Do you know what the story is there?I'd expect the compat library to cover all the functionality that's there in the latest Android SDK. So when a given method or property isn't there, my first thought is that that's a signal that there's some other preferred way to do the same thing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure about this, I couldn't find any reference for why this is the case.
On a side note it is also possible to get active notification via NotificationListenerService#getActiveNotifications so they could have nicely consolidated this for most of the android versions. This is how one can get
activeNotificationsin API < 23.