chaining the event process, notification creation and display logic into a NotificationRender

- extract the displaying into its own class to avoid leaking the entire notificationutils
- cancel/display notification actions are completely driven by the event or abscense of event from the eventList
- attempts to avoid redundant render passes by checking if the eventList has changed since the last render
This commit is contained in:
Adam Brown 2021-10-06 20:28:29 +01:00
parent 0f4ec65b7a
commit 3023cb4d39
10 changed files with 507 additions and 18 deletions

View file

@ -0,0 +1,45 @@
/*
* 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 android.content.Context
import androidx.core.app.NotificationManagerCompat
import timber.log.Timber
import javax.inject.Inject
class NotificationDisplayer @Inject constructor(context: Context) {
private val notificationManager = NotificationManagerCompat.from(context)
fun showNotificationMessage(tag: String?, id: Int, notification: Notification) {
notificationManager.notify(tag, id, notification)
}
fun cancelNotificationMessage(tag: String?, id: Int) {
notificationManager.cancel(tag, id)
}
fun cancelAllNotifications() {
// Keep this try catch (reported by GA)
try {
notificationManager.cancelAll()
} catch (e: Exception) {
Timber.e(e, "## cancelAllNotifications() failed")
}
}
}

View file

@ -28,7 +28,7 @@ class NotificationFactory @Inject constructor(
fun Map<String, List<NotifiableMessageEvent>>.toNotifications(myUserDisplayName: String, myUserAvatarUrl: String?): List<RoomNotification> {
return this.map { (roomId, events) ->
when {
events.hasNoEventsToDisplay() -> RoomNotification.EmptyRoom(roomId)
events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId)
else -> roomGroupMessageCreator.createRoomMessage(events, roomId, myUserDisplayName, myUserAvatarUrl)
}
}
@ -82,7 +82,7 @@ private fun List<RoomNotification>.mapToMeta() = filterIsInstance<RoomNotificati
private fun List<OneShotNotification>.mapToMeta() = filterIsInstance<OneShotNotification.Append>().map { it.meta }
sealed interface RoomNotification {
data class EmptyRoom(val roomId: String) : RoomNotification
data class Removed(val roomId: String) : RoomNotification
data class Message(val notification: Notification, val meta: Meta) : RoomNotification {
data class Meta(
val summaryLine: CharSequence,

View file

@ -0,0 +1,101 @@
/*
* Copyright 2019 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 androidx.annotation.WorkerThread
import im.vector.app.features.settings.VectorPreferences
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NotificationRenderer @Inject constructor(private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationDisplayer: NotificationDisplayer,
private val vectorPreferences: VectorPreferences,
private val notificationFactory: NotificationFactory) {
private var lastKnownEventList = -1
private var useCompleteNotificationFormat = vectorPreferences.useCompleteNotificationFormat()
@WorkerThread
fun render(currentRoomId: String?, myUserId: String, myUserDisplayName: String, myUserAvatarUrl: String?, eventList: MutableList<NotifiableEvent>) {
Timber.v("refreshNotificationDrawerBg()")
val newSettings = vectorPreferences.useCompleteNotificationFormat()
if (newSettings != useCompleteNotificationFormat) {
// Settings has changed, remove all current notifications
notificationDisplayer.cancelAllNotifications()
useCompleteNotificationFormat = newSettings
}
val notificationEvents = notifiableEventProcessor.modifyAndProcess(eventList, currentRoomId)
if (lastKnownEventList == notificationEvents.hashCode()) {
Timber.d("Skipping notification update due to event list not changing")
} else {
processEvents(notificationEvents, myUserId, myUserDisplayName, myUserAvatarUrl)
lastKnownEventList = notificationEvents.hashCode()
}
}
private fun processEvents(notificationEvents: ProcessedNotificationEvents, myUserId: String, myUserDisplayName: String, myUserAvatarUrl: String?) {
val (roomEvents, simpleEvents, invitationEvents) = notificationEvents
with(notificationFactory) {
val roomNotifications = roomEvents.toNotifications(myUserDisplayName, myUserAvatarUrl)
val invitationNotifications = invitationEvents.toNotifications(myUserId)
val simpleNotifications = simpleEvents.toNotifications(myUserId)
if (roomNotifications.isEmpty() && invitationNotifications.isEmpty() && simpleNotifications.isEmpty()) {
notificationDisplayer.cancelNotificationMessage(null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID)
} else {
val summaryNotification = createSummaryNotification(
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
useCompleteNotificationFormat = useCompleteNotificationFormat
)
roomNotifications.forEach { wrapper ->
when (wrapper) {
is RoomNotification.Removed -> notificationDisplayer.cancelNotificationMessage(wrapper.roomId, NotificationDrawerManager.ROOM_MESSAGES_NOTIFICATION_ID)
is RoomNotification.Message -> if (useCompleteNotificationFormat) {
Timber.d("Updating room messages notification ${wrapper.meta.roomId}")
notificationDisplayer.showNotificationMessage(wrapper.meta.roomId, NotificationDrawerManager.ROOM_MESSAGES_NOTIFICATION_ID, wrapper.notification)
}
}
}
invitationNotifications.forEach { wrapper ->
when (wrapper) {
is OneShotNotification.Removed -> notificationDisplayer.cancelNotificationMessage(wrapper.key, NotificationDrawerManager.ROOM_INVITATION_NOTIFICATION_ID)
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.d("Updating invitation notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(wrapper.meta.key, NotificationDrawerManager.ROOM_INVITATION_NOTIFICATION_ID, wrapper.notification)
}
}
}
simpleNotifications.forEach { wrapper ->
when (wrapper) {
is OneShotNotification.Removed -> notificationDisplayer.cancelNotificationMessage(wrapper.key, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID)
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.d("Updating simple notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(wrapper.meta.key, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID, wrapper.notification)
}
}
}
notificationDisplayer.showNotificationMessage(null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, summaryNotification)
}
}
}
}

View file

@ -22,25 +22,25 @@ import im.vector.app.R
import im.vector.app.core.resources.StringProvider
import javax.inject.Inject
/**
* ======== 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
*/
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>,

View file

@ -116,7 +116,7 @@ class NotificationFactoryTest {
val result = emptyRoom.toNotifications(MY_USER_ID, MY_AVATAR_URL)
result shouldBeEqualTo listOf(RoomNotification.EmptyRoom(
result shouldBeEqualTo listOf(RoomNotification.Removed(
roomId = A_ROOM_ID
))
}
@ -127,7 +127,7 @@ class NotificationFactoryTest {
val result = redactedRoom.toNotifications(MY_USER_ID, MY_AVATAR_URL)
result shouldBeEqualTo listOf(RoomNotification.EmptyRoom(
result shouldBeEqualTo listOf(RoomNotification.Removed(
roomId = A_ROOM_ID
))
}

View file

@ -0,0 +1,183 @@
/*
* 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 im.vector.app.test.fakes.FakeNotifiableEventProcessor
import im.vector.app.test.fakes.FakeNotificationDisplayer
import im.vector.app.test.fakes.FakeNotificationFactory
import im.vector.app.test.fakes.FakeVectorPreferences
import io.mockk.mockk
import org.junit.Test
private const val A_CURRENT_ROOM_ID = "current-room-id"
private const val MY_USER_ID = "my-user-id"
private const val MY_USER_DISPLAY_NAME = "display-name"
private const val MY_USER_AVATAR_URL = "avatar-url"
private const val AN_EVENT_ID = "event-id"
private const val A_ROOM_ID = "room-id"
private const val USE_COMPLETE_NOTIFICATION_FORMAT = true
private val AN_EVENT_LIST = mutableListOf<NotifiableEvent>()
private val A_PROCESSED_EVENTS = ProcessedNotificationEvents(emptyMap(), emptyMap(), emptyMap())
private val A_SUMMARY_NOTIFICATION = mockk<Notification>()
private val A_NOTIFICATION = mockk<Notification>()
private val MESSAGE_META = RoomNotification.Message.Meta(
summaryLine = "ignored", messageCount = 1, latestTimestamp = -1, roomId = A_ROOM_ID, shouldBing = false
)
private val ONE_SHOT_META = OneShotNotification.Append.Meta(key = "ignored", summaryLine = "ignored", isNoisy = false)
class NotificationRendererTest {
private val notifiableEventProcessor = FakeNotifiableEventProcessor()
private val notificationDisplayer = FakeNotificationDisplayer()
private val preferences = FakeVectorPreferences().also {
it.givenUseCompleteNotificationFormat(USE_COMPLETE_NOTIFICATION_FORMAT)
}
private val notificationFactory = FakeNotificationFactory()
private val notificationRenderer = NotificationRenderer(
notifiableEventProcessor = notifiableEventProcessor.instance,
notificationDisplayer = notificationDisplayer.instance,
vectorPreferences = preferences.instance,
notificationFactory = notificationFactory.instance
)
@Test
fun `given no notifications when rendering then cancels summary notification`() {
givenNoNotifications()
renderEventsAsNotifications()
notificationDisplayer.verifySummaryCancelled()
notificationDisplayer.verifyNoOtherInteractions()
}
@Test
fun `given a room message group notification is removed when rendering then remove the message notification and update summary`() {
givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)))
renderEventsAsNotifications()
notificationDisplayer.verifyInOrder {
cancelNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_MESSAGES_NOTIFICATION_ID)
showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION)
}
}
@Test
fun `given a room message group notification is added when rendering then show the message notification and update summary`() {
givenNotifications(roomNotifications = listOf(RoomNotification.Message(
A_NOTIFICATION,
MESSAGE_META
)))
renderEventsAsNotifications()
notificationDisplayer.verifyInOrder {
showNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_MESSAGES_NOTIFICATION_ID, A_NOTIFICATION)
showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION)
}
}
@Test
fun `given a simple notification is removed when rendering then remove the simple notification and update summary`() {
givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID)))
renderEventsAsNotifications()
notificationDisplayer.verifyInOrder {
cancelNotificationMessage(tag = AN_EVENT_ID, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID)
showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION)
}
}
@Test
fun `given a simple notification is added when rendering then show the simple notification and update summary`() {
givenNotifications(simpleNotifications = listOf(OneShotNotification.Append(
A_NOTIFICATION,
ONE_SHOT_META.copy(key = AN_EVENT_ID)
)))
renderEventsAsNotifications()
notificationDisplayer.verifyInOrder {
showNotificationMessage(tag = AN_EVENT_ID, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID, A_NOTIFICATION)
showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION)
}
}
@Test
fun `given an invitation notification is removed when rendering then remove the invitation notification and update summary`() {
givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID)))
renderEventsAsNotifications()
notificationDisplayer.verifyInOrder {
cancelNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_INVITATION_NOTIFICATION_ID)
showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION)
}
}
@Test
fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() {
givenNotifications(simpleNotifications = listOf(OneShotNotification.Append(
A_NOTIFICATION,
ONE_SHOT_META.copy(key = A_ROOM_ID)
)))
renderEventsAsNotifications()
notificationDisplayer.verifyInOrder {
showNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID, A_NOTIFICATION)
showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION)
}
}
private fun renderEventsAsNotifications() {
notificationRenderer.render(
currentRoomId = A_CURRENT_ROOM_ID,
myUserId = MY_USER_ID,
myUserDisplayName = MY_USER_DISPLAY_NAME,
myUserAvatarUrl = MY_USER_AVATAR_URL,
eventList = AN_EVENT_LIST
)
}
private fun givenNoNotifications() {
givenNotifications(emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_SUMMARY_NOTIFICATION)
}
private fun givenNotifications(roomNotifications: List<RoomNotification> = emptyList(),
invitationNotifications: List<OneShotNotification> = emptyList(),
simpleNotifications: List<OneShotNotification> = emptyList(),
useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT,
summaryNotification: Notification = A_SUMMARY_NOTIFICATION) {
notifiableEventProcessor.givenProcessedEventsFor(AN_EVENT_LIST, A_CURRENT_ROOM_ID, A_PROCESSED_EVENTS)
notificationFactory.givenNotificationsFor(
processedEvents = A_PROCESSED_EVENTS,
myUserId = MY_USER_ID,
myUserDisplayName = MY_USER_DISPLAY_NAME,
myUserAvatarUrl = MY_USER_AVATAR_URL,
useCompleteNotificationFormat = useCompleteNotificationFormat,
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
summaryNotification = summaryNotification
)
}
}

View file

@ -0,0 +1,32 @@
/*
* 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.NotifiableEvent
import im.vector.app.features.notifications.NotifiableEventProcessor
import im.vector.app.features.notifications.ProcessedNotificationEvents
import io.mockk.every
import io.mockk.mockk
class FakeNotifiableEventProcessor {
val instance = mockk<NotifiableEventProcessor>()
fun givenProcessedEventsFor(events: MutableList<NotifiableEvent>, currentRoomId: String?, processedEvents: ProcessedNotificationEvents) {
every { instance.modifyAndProcess(events, currentRoomId) } returns processedEvents
}
}

View file

@ -0,0 +1,42 @@
/*
* 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.NotificationDisplayer
import im.vector.app.features.notifications.NotificationDrawerManager
import io.mockk.confirmVerified
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyOrder
class FakeNotificationDisplayer {
val instance = mockk<NotificationDisplayer>(relaxed = true)
fun verifySummaryCancelled() {
verify { instance.cancelNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID) }
}
fun verifyNoOtherInteractions() {
confirmVerified(instance)
}
fun verifyInOrder(verifyBlock: NotificationDisplayer.() -> Unit) {
verifyOrder { verifyBlock(instance) }
verifyNoOtherInteractions()
}
}

View file

@ -0,0 +1,56 @@
/*
* 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.NotificationFactory
import im.vector.app.features.notifications.OneShotNotification
import im.vector.app.features.notifications.ProcessedNotificationEvents
import im.vector.app.features.notifications.RoomNotification
import io.mockk.every
import io.mockk.mockk
class FakeNotificationFactory {
val instance = mockk<NotificationFactory>()
fun givenNotificationsFor(processedEvents: ProcessedNotificationEvents,
myUserId: String,
myUserDisplayName: String,
myUserAvatarUrl: String?,
useCompleteNotificationFormat: Boolean,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
summaryNotification: Notification) {
with(instance) {
every { processedEvents.roomEvents.toNotifications(myUserDisplayName, myUserAvatarUrl) } returns roomNotifications
every { processedEvents.invitationEvents.toNotifications(myUserId) } returns invitationNotifications
every { processedEvents.simpleEvents.toNotifications(myUserId) } returns simpleNotifications
every {
createSummaryNotification(
roomNotifications,
invitationNotifications,
simpleNotifications,
useCompleteNotificationFormat
)
} returns summaryNotification
}
}
}

View file

@ -0,0 +1,30 @@
/*
* 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.settings.VectorPreferences
import io.mockk.every
import io.mockk.mockk
class FakeVectorPreferences {
val instance = mockk<VectorPreferences>()
fun givenUseCompleteNotificationFormat(value: Boolean) {
every { instance.useCompleteNotificationFormat() } returns value
}
}