Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ npm-debug.log
# VS Code: default Java extension
/android/.project
/android/.settings/
/android/app/.project

# VS Code: debugger
/.vscode/.react/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,13 @@
import org.unimodules.core.interfaces.SingletonModule;

import com.zulipmobile.generated.BasePackageList;
import com.zulipmobile.notifications.ConversationMap;
import com.zulipmobile.notifications.FCMPushNotifications;
import com.zulipmobile.notifications.NotificationsPackage;
import com.zulipmobile.sharing.SharingPackage;

public class MainApplication extends Application implements ReactApplication {
private final ReactModuleRegistryProvider mModuleRegistryProvider = new ReactModuleRegistryProvider(new BasePackageList().getPackageList(), null);

private ConversationMap conversations;

public ConversationMap getConversations() {
return conversations;
}

private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
@Override
public boolean getUseDeveloperSupport() {
Expand Down Expand Up @@ -79,7 +72,6 @@ public void onCreate() {
FCMPushNotifications.createNotificationChannel(this);
SoLoader.init(this, /* native exopackage */ false);
initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
conversations = new ConversationMap();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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)
}
Expand All @@ -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) {
Expand All @@ -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?
Comment on lines +144 to +147
Copy link
Member

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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know what the story is there?

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 activeNotifications in API < 23.


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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notif [nfc]: Implement summary notification.

Summary Notification is only visible in API < 24. But are required
by Android regardless, without them no notification with a group
will be shown.

How did you find that it's required by Android even on newer versions?

From the guide doc for this feature:
https://developer.android.com/training/notify-user/group
it sounds more optional than that:

To support older versions, you can also add a summary notification […]

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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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?

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
summaryText shows as the header text in summary notification.

Copy link
Member

Choose a reason for hiding this comment

The 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.

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)
.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()
}
Loading