creating the notifications separate to where they're displayed

- also handles when the event diff means the notifications should be removed
This commit is contained in:
Adam Brown 2021-10-06 19:03:22 +01:00
parent 7b0c483134
commit 0f4ec65b7a
7 changed files with 635 additions and 0 deletions

View File

@ -0,0 +1,106 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.notifications
import android.app.Notification
import javax.inject.Inject
class NotificationFactory @Inject constructor(
private val notificationUtils: NotificationUtils,
private val roomGroupMessageCreator: RoomGroupMessageCreator,
private val summaryGroupMessageCreator: SummaryGroupMessageCreator
) {
fun Map<String, List<NotifiableMessageEvent>>.toNotifications(myUserDisplayName: String, myUserAvatarUrl: String?): List<RoomNotification> {
return this.map { (roomId, events) ->
when {
events.hasNoEventsToDisplay() -> RoomNotification.EmptyRoom(roomId)
else -> roomGroupMessageCreator.createRoomMessage(events, roomId, myUserDisplayName, myUserAvatarUrl)
}
}
}
private fun List<NotifiableMessageEvent>.hasNoEventsToDisplay() = isEmpty() || all { it.canNotBeDisplayed() }
private fun NotifiableMessageEvent.canNotBeDisplayed() = hasBeenDisplayed || isRedacted
fun Map<String, InviteNotifiableEvent?>.toNotifications(myUserId: String): List<OneShotNotification> {
return this.map { (roomId, event) ->
when (event) {
null -> OneShotNotification.Removed(key = roomId)
else -> OneShotNotification.Append(
notificationUtils.buildRoomInvitationNotification(event, myUserId),
OneShotNotification.Append.Meta(key = roomId, summaryLine = event.description, isNoisy = event.noisy)
)
}
}
}
@JvmName("toNotificationsSimpleNotifiableEvent")
fun Map<String, SimpleNotifiableEvent?>.toNotifications(myUserId: String): List<OneShotNotification> {
return this.map { (eventId, event) ->
when (event) {
null -> OneShotNotification.Removed(key = eventId)
else -> OneShotNotification.Append(
notificationUtils.buildSimpleEventNotification(event, myUserId),
OneShotNotification.Append.Meta(key = eventId, summaryLine = event.description, isNoisy = event.noisy)
)
}
}
}
fun createSummaryNotification(roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean): Notification {
return summaryGroupMessageCreator.createSummaryNotification(
roomNotifications = roomNotifications.mapToMeta(),
invitationNotifications = invitationNotifications.mapToMeta(),
simpleNotifications = simpleNotifications.mapToMeta(),
useCompleteNotificationFormat = useCompleteNotificationFormat
)
}
}
private fun List<RoomNotification>.mapToMeta() = filterIsInstance<RoomNotification.Message>().map { it.meta }
@JvmName("mapToMetaOneShotNotification")
private fun List<OneShotNotification>.mapToMeta() = filterIsInstance<OneShotNotification.Append>().map { it.meta }
sealed interface RoomNotification {
data class EmptyRoom(val roomId: String) : RoomNotification
data class Message(val notification: Notification, val meta: Meta) : RoomNotification {
data class Meta(
val summaryLine: CharSequence,
val messageCount: Int,
val latestTimestamp: Long,
val roomId: String,
val shouldBing: Boolean
)
}
}
sealed interface OneShotNotification {
data class Removed(val key: String) : OneShotNotification
data class Append(val notification: Notification, val meta: Meta) : OneShotNotification {
data class Meta(
val key: String,
val summaryLine: CharSequence,
val isNoisy: Boolean
)
}
}

View File

@ -0,0 +1,148 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.notifications
import android.graphics.Bitmap
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import im.vector.app.R
import im.vector.app.core.resources.StringProvider
import me.gujun.android.span.Span
import me.gujun.android.span.span
import timber.log.Timber
import javax.inject.Inject
class RoomGroupMessageCreator @Inject constructor(
private val iconLoader: IconLoader,
private val bitmapLoader: BitmapLoader,
private val stringProvider: StringProvider,
private val notificationUtils: NotificationUtils
) {
fun createRoomMessage(events: List<NotifiableMessageEvent>, roomId: String, userDisplayName: String, userAvatarUrl: String?): RoomNotification.Message {
val firstKnownRoomEvent = events[0]
val roomName = firstKnownRoomEvent.roomName ?: firstKnownRoomEvent.senderName ?: ""
val roomIsGroup = !firstKnownRoomEvent.roomIsDirect
val style = NotificationCompat.MessagingStyle(Person.Builder()
.setName(userDisplayName)
.setIcon(iconLoader.getUserIcon(userAvatarUrl))
.setKey(firstKnownRoomEvent.matrixID)
.build()
).also {
it.conversationTitle = roomName.takeIf { roomIsGroup }
it.isGroupConversation = roomIsGroup
it.addMessagesFromEvents(events)
}
val tickerText = if (roomIsGroup) {
stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description)
} else {
stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderName, events.last().description)
}
val lastMessageTimestamp = events.last().timestamp
val smartReplyErrors = events.filter { it.isSmartReplyError() }
val messageCount = (events.size - smartReplyErrors.size)
val meta = RoomNotification.Message.Meta(
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup),
messageCount = messageCount,
latestTimestamp = lastMessageTimestamp,
roomId = roomId,
shouldBing = events.any { it.noisy }
)
return RoomNotification.Message(
notificationUtils.buildMessagesListNotification(
style,
RoomEventGroupInfo(roomId, roomName, isDirect = !roomIsGroup).also {
it.hasSmartReplyError = smartReplyErrors.isNotEmpty()
it.shouldBing = meta.shouldBing
it.customSound = events.last().soundName
},
largeIcon = getRoomBitmap(events),
lastMessageTimestamp,
userDisplayName,
tickerText
),
meta
)
}
private fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List<NotifiableMessageEvent>) {
events.forEach { event ->
val senderPerson = Person.Builder()
.setName(event.senderName)
.setIcon(iconLoader.getUserIcon(event.senderAvatarPath))
.setKey(event.senderId)
.build()
when {
event.isSmartReplyError() -> addMessage(stringProvider.getString(R.string.notification_inline_reply_failed), event.timestamp, senderPerson)
else -> addMessage(event.body, event.timestamp, senderPerson)
}
}
}
private fun createRoomMessagesGroupSummaryLine(events: List<NotifiableMessageEvent>, roomName: String, roomIsDirect: Boolean): CharSequence {
return try {
when (events.size) {
1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect)
else -> {
stringProvider.getQuantityString(
R.plurals.notification_compat_summary_line_for_room,
events.size,
roomName,
events.size
)
}
}
} catch (e: Throwable) {
// String not found or bad format
Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER failed to resolve string")
roomName
}
}
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): Span {
return if (roomIsDirect) {
span {
span {
textStyle = "bold"
+String.format("%s: ", event.senderName)
}
+(event.description)
}
} else {
span {
span {
textStyle = "bold"
+String.format("%s: %s ", roomName, event.senderName)
}
+(event.description)
}
}
}
private fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? {
if (events.isEmpty()) return null
// Use the last event (most recent?)
val roomAvatarPath = events.last().roomAvatarPath ?: events.last().senderAvatarPath
return bitmapLoader.getRoomBitmap(roomAvatarPath)
}
}
private fun NotifiableMessageEvent.isSmartReplyError() = this.outGoingMessage && this.outGoingMessageFailed

View File

@ -0,0 +1,140 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.notifications
import android.app.Notification
import androidx.core.app.NotificationCompat
import im.vector.app.R
import im.vector.app.core.resources.StringProvider
import javax.inject.Inject
class SummaryGroupMessageCreator @Inject constructor(
private val stringProvider: StringProvider,
private val notificationUtils: NotificationUtils
) {
/**
* ======== Build summary notification =========
* On Android 7.0 (API level 24) and higher, the system automatically builds a summary for
* your group using snippets of text from each notification. The user can expand this
* notification to see each separate notification.
* To support older versions, which cannot show a nested group of notifications,
* you must create an extra notification that acts as the summary.
* This appears as the only notification and the system hides all the others.
* So this summary should include a snippet from all the other notifications,
* which the user can tap to open your app.
* The behavior of the group summary may vary on some device types such as wearables.
* To ensure the best experience on all devices and versions, always include a group summary when you create a group
* https://developer.android.com/training/notify-user/group
*/
fun createSummaryNotification(roomNotifications: List<RoomNotification.Message.Meta>,
invitationNotifications: List<OneShotNotification.Append.Meta>,
simpleNotifications: List<OneShotNotification.Append.Meta>,
useCompleteNotificationFormat: Boolean): Notification {
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->
roomNotifications.forEach { style.addLine(it.summaryLine) }
invitationNotifications.forEach { style.addLine(it.summaryLine) }
simpleNotifications.forEach { style.addLine(it.summaryLine) }
}
val summaryIsNoisy = roomNotifications.any { it.shouldBing }
|| invitationNotifications.any { it.isNoisy }
|| simpleNotifications.any { it.isNoisy }
val messageCount = roomNotifications.fold(initial = 0) { acc, current -> acc + current.messageCount }
val lastMessageTimestamp1 = roomNotifications.last().latestTimestamp
// FIXME roomIdToEventMap.size is not correct, this is the number of rooms
val nbEvents = roomNotifications.size + simpleNotifications.size
val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents)
summaryInboxStyle.setBigContentTitle(sumTitle)
// TODO get latest event?
.setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents))
return if (useCompleteNotificationFormat
) {
notificationUtils.buildSummaryListNotification(
summaryInboxStyle,
sumTitle,
noisy = summaryIsNoisy,
lastMessageTimestamp = lastMessageTimestamp1
)
} else {
processSimpleGroupSummary(summaryIsNoisy, messageCount,
simpleNotifications.size, invitationNotifications.size,
roomNotifications.size, lastMessageTimestamp1)
}
}
private fun processSimpleGroupSummary(summaryIsNoisy: Boolean,
messageEventsCount: Int,
simpleEventsCount: Int,
invitationEventsCount: Int,
roomCount: Int,
lastMessageTimestamp: Long): Notification {
// Add the simple events as message (?)
val messageNotificationCount = messageEventsCount + simpleEventsCount
val privacyTitle = if (invitationEventsCount > 0) {
val invitationsStr = stringProvider.getQuantityString(R.plurals.notification_invitations, invitationEventsCount, invitationEventsCount)
if (messageNotificationCount > 0) {
// Invitation and message
val messageStr = stringProvider.getQuantityString(R.plurals.room_new_messages_notification,
messageNotificationCount, messageNotificationCount)
if (roomCount > 1) {
// In several rooms
val roomStr = stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages_in_room_rooms,
roomCount, roomCount)
stringProvider.getString(
R.string.notification_unread_notified_messages_in_room_and_invitation,
messageStr,
roomStr,
invitationsStr
)
} else {
// In one room
stringProvider.getString(
R.string.notification_unread_notified_messages_and_invitation,
messageStr,
invitationsStr
)
}
} else {
// Only invitation
invitationsStr
}
} else {
// No invitation, only messages
val messageStr = stringProvider.getQuantityString(R.plurals.room_new_messages_notification,
messageNotificationCount, messageNotificationCount)
if (roomCount > 1) {
// In several rooms
val roomStr = stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages_in_room_rooms, roomCount, roomCount)
stringProvider.getString(R.string.notification_unread_notified_messages_in_room, messageStr, roomStr)
} else {
// In one room
messageStr
}
}
return notificationUtils.buildSummaryListNotification(
style = null,
compatSummary = privacyTitle,
noisy = summaryIsNoisy,
lastMessageTimestamp = lastMessageTimestamp
)
}
}

View File

@ -0,0 +1,138 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.notifications
import im.vector.app.test.fakes.FakeNotificationUtils
import im.vector.app.test.fakes.FakeRoomGroupMessageCreator
import im.vector.app.test.fakes.FakeSummaryGroupMessageCreator
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
private const val MY_USER_ID = "user-id"
private const val A_ROOM_ID = "room-id"
private const val AN_EVENT_ID = "event-id"
private val MY_AVATAR_URL: String? = null
private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID)
private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID)
private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)
class NotificationFactoryTest {
private val notificationUtils = FakeNotificationUtils()
private val roomGroupMessageCreator = FakeRoomGroupMessageCreator()
private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator()
private val notificationFactory = NotificationFactory(
notificationUtils.instance,
roomGroupMessageCreator.instance,
summaryGroupMessageCreator.instance
)
@Test
fun `given a room invitation when mapping to notification then is Append`() = testWith(notificationFactory) {
val expectedNotification = notificationUtils.givenBuildRoomInvitationNotificationFor(AN_INVITATION_EVENT, MY_USER_ID)
val roomInvitation = mapOf(A_ROOM_ID to AN_INVITATION_EVENT)
val result = roomInvitation.toNotifications(MY_USER_ID)
result shouldBeEqualTo listOf(OneShotNotification.Append(
notification = expectedNotification,
meta = OneShotNotification.Append.Meta(
key = A_ROOM_ID,
summaryLine = AN_INVITATION_EVENT.description,
isNoisy = AN_INVITATION_EVENT.noisy
))
)
}
@Test
fun `given a missing event in room invitation when mapping to notification then is Removed`() = testWith(notificationFactory) {
val missingEventRoomInvitation: Map<String, InviteNotifiableEvent?> = mapOf(A_ROOM_ID to null)
val result = missingEventRoomInvitation.toNotifications(MY_USER_ID)
result shouldBeEqualTo listOf(OneShotNotification.Removed(
key = A_ROOM_ID
))
}
@Test
fun `given a simple event when mapping to notification then is Append`() = testWith(notificationFactory) {
val expectedNotification = notificationUtils.givenBuildSimpleInvitationNotificationFor(A_SIMPLE_EVENT, MY_USER_ID)
val roomInvitation = mapOf(AN_EVENT_ID to A_SIMPLE_EVENT)
val result = roomInvitation.toNotifications(MY_USER_ID)
result shouldBeEqualTo listOf(OneShotNotification.Append(
notification = expectedNotification,
meta = OneShotNotification.Append.Meta(
key = AN_EVENT_ID,
summaryLine = A_SIMPLE_EVENT.description,
isNoisy = A_SIMPLE_EVENT.noisy
))
)
}
@Test
fun `given a missing simple event when mapping to notification then is Removed`() = testWith(notificationFactory) {
val missingEventRoomInvitation: Map<String, SimpleNotifiableEvent?> = mapOf(AN_EVENT_ID to null)
val result = missingEventRoomInvitation.toNotifications(MY_USER_ID)
result shouldBeEqualTo listOf(OneShotNotification.Removed(
key = AN_EVENT_ID
))
}
@Test
fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationFactory) {
val events = listOf(A_MESSAGE_EVENT)
val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor(events, A_ROOM_ID, MY_USER_ID, MY_AVATAR_URL)
val roomWithMessage = mapOf(A_ROOM_ID to events)
val result = roomWithMessage.toNotifications(MY_USER_ID, MY_AVATAR_URL)
result shouldBeEqualTo listOf(expectedNotification)
}
@Test
fun `given a room with no events to display when mapping to notification then is Empty`() = testWith(notificationFactory) {
val emptyRoom: Map<String, List<NotifiableMessageEvent>> = mapOf(A_ROOM_ID to emptyList())
val result = emptyRoom.toNotifications(MY_USER_ID, MY_AVATAR_URL)
result shouldBeEqualTo listOf(RoomNotification.EmptyRoom(
roomId = A_ROOM_ID
))
}
@Test
fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationFactory) {
val redactedRoom = mapOf(A_ROOM_ID to listOf(A_MESSAGE_EVENT.copy().apply { isRedacted = true }))
val result = redactedRoom.toNotifications(MY_USER_ID, MY_AVATAR_URL)
result shouldBeEqualTo listOf(RoomNotification.EmptyRoom(
roomId = A_ROOM_ID
))
}
}
fun <T> testWith(receiver: T, block: T.() -> Unit) {
receiver.block()
}

View File

@ -0,0 +1,41 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.test.fakes
import android.app.Notification
import im.vector.app.features.notifications.InviteNotifiableEvent
import im.vector.app.features.notifications.NotificationUtils
import im.vector.app.features.notifications.SimpleNotifiableEvent
import io.mockk.every
import io.mockk.mockk
class FakeNotificationUtils {
val instance = mockk<NotificationUtils>()
fun givenBuildRoomInvitationNotificationFor(event: InviteNotifiableEvent, myUserId: String): Notification {
val mockNotification = mockk<Notification>()
every { instance.buildRoomInvitationNotification(event, myUserId) } returns mockNotification
return mockNotification
}
fun givenBuildSimpleInvitationNotificationFor(event: SimpleNotifiableEvent, myUserId: String): Notification {
val mockNotification = mockk<Notification>()
every { instance.buildSimpleEventNotification(event, myUserId) } returns mockNotification
return mockNotification
}
}

View File

@ -0,0 +1,37 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.test.fakes
import im.vector.app.features.notifications.NotifiableMessageEvent
import im.vector.app.features.notifications.RoomGroupMessageCreator
import im.vector.app.features.notifications.RoomNotification
import io.mockk.every
import io.mockk.mockk
class FakeRoomGroupMessageCreator {
val instance = mockk<RoomGroupMessageCreator>()
fun givenCreatesRoomMessageFor(events: List<NotifiableMessageEvent>,
roomId: String,
userDisplayName: String,
userAvatarUrl: String?): RoomNotification.Message {
val mockMessage = mockk<RoomNotification.Message>()
every { instance.createRoomMessage(events, roomId, userDisplayName, userAvatarUrl) } returns mockMessage
return mockMessage
}
}

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.test.fakes
import im.vector.app.features.notifications.SummaryGroupMessageCreator
import io.mockk.mockk
class FakeSummaryGroupMessageCreator {
val instance = mockk<SummaryGroupMessageCreator>()
}