Notifications: simplify the flow by removing persistence (#2924)

* Notifications: simplify the flow by removing persistence. 
* Bump of minSdk to `24` (Android 7).
* Add migration to remove `notification.bin` file
This commit is contained in:
Jorge Martin Espinosa 2024-05-29 10:03:23 +02:00 committed by GitHub
parent edc589b494
commit 801f0b955d
62 changed files with 2028 additions and 2618 deletions

View file

@ -20,6 +20,9 @@ object NotificationConfig {
// TODO EAx Implement and set to true at some point // TODO EAx Implement and set to true at some point
const val SUPPORT_MARK_AS_READ_ACTION = false const val SUPPORT_MARK_AS_READ_ACTION = false
// TODO EAx Implement and set to true at some point
const val SUPPORT_JOIN_DECLINE_INVITE = false
// TODO EAx Implement and set to true at some point // TODO EAx Implement and set to true at some point
const val SUPPORT_QUICK_REPLY_ACTION = false const val SUPPORT_QUICK_REPLY_ACTION = false
} }

3
changelog.d/2924.misc Normal file
View file

@ -0,0 +1,3 @@
Simplify notifications by removing the custom persistence layer.
Bump minSdk to 24 (Android 7).

View file

@ -72,15 +72,10 @@ class CallForegroundService : Service() {
startForeground(1, notification) startForeground(1, notification)
} }
@Suppress("DEPRECATION")
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE)
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
stopForeground(true)
}
} }
override fun onBind(intent: Intent?): IBinder? { override fun onBind(intent: Intent?): IBinder? {

View file

@ -112,7 +112,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
trigger = JoinedRoom.Trigger.Invite, trigger = JoinedRoom.Trigger.Invite,
) )
.onSuccess { .onSuccess {
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
} }
.map { roomId } .map { roomId }
} }
@ -122,7 +122,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
suspend { suspend {
client.getRoom(roomId)?.use { client.getRoom(roomId)?.use {
it.leave().getOrThrow() it.leave().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
} }
roomId roomId
}.runCatchingUpdatingState(declinedAction) }.runCatchingUpdatingState(declinedAction)

View file

@ -16,8 +16,6 @@
package io.element.android.features.login.impl.oidc.webview package io.element.android.features.login.impl.oidc.webview
import android.annotation.TargetApi
import android.os.Build
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
@ -25,7 +23,6 @@ import android.webkit.WebViewClient
class OidcWebViewClient( class OidcWebViewClient(
private val eventListener: WebViewEventListener, private val eventListener: WebViewEventListener,
) : WebViewClient() { ) : WebViewClient() {
@TargetApi(Build.VERSION_CODES.N)
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
return shouldOverrideUrl(request.url.toString()) return shouldOverrideUrl(request.url.toString())
} }
@ -36,7 +33,6 @@ class OidcWebViewClient(
} }
private fun shouldOverrideUrl(url: String): Boolean { private fun shouldOverrideUrl(url: String): Boolean {
// Timber.d("shouldOverrideUrl: $url")
return eventListener.shouldOverrideUrlLoading(url) return eventListener.shouldOverrideUrlLoading(url)
} }
} }

View file

@ -40,6 +40,7 @@ dependencies {
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.sessionStorage.implMemory) testImplementation(projects.libraries.sessionStorage.implMemory)

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2024 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 io.element.android.features.migration.impl.migrations
import android.content.Context
import com.squareup.anvil.annotations.ContributesMultibinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
/**
* Remove notifications.bin file, used to store notification data locally.
*/
@ContributesMultibinding(AppScope::class)
class AppMigration04 @Inject constructor(
@ApplicationContext private val context: Context,
) : AppMigration {
companion object {
internal const val NOTIFICATION_FILE_NAME = "notifications.bin"
}
override val order: Int = 4
override suspend fun migrate() {
runCatching { context.getDatabasePath(NOTIFICATION_FILE_NAME).delete() }
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2024 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 io.element.android.features.migration.impl.migrations
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AppMigration04Test {
@Test
fun `test migration`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
// Create fake temporary file at the path to be deleted
val file = context.getDatabasePath(AppMigration04.NOTIFICATION_FILE_NAME)
file.parentFile?.mkdirs()
file.createNewFile()
assertThat(file.exists()).isTrue()
val migration = AppMigration04(context)
migration.migrate()
// Check that the file has been deleted
assertThat(file.exists()).isFalse()
}
}

View file

@ -16,11 +16,9 @@
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import android.content.Context
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import javax.inject.Inject import javax.inject.Inject
@ -31,9 +29,9 @@ interface SystemNotificationsEnabledProvider {
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultSystemNotificationsEnabledProvider @Inject constructor( class DefaultSystemNotificationsEnabledProvider @Inject constructor(
@ApplicationContext private val context: Context, private val notificationManager: NotificationManagerCompat,
) : SystemNotificationsEnabledProvider { ) : SystemNotificationsEnabledProvider {
override fun notificationsEnabled(): Boolean { override fun notificationsEnabled(): Boolean {
return NotificationManagerCompat.from(context).areNotificationsEnabled() return notificationManager.areNotificationsEnabled()
} }
} }

View file

@ -58,17 +58,16 @@ class AndroidMediaPreProcessorTest {
val data = result.getOrThrow() val data = result.getOrThrow()
assertThat(data.file.path).endsWith("image.png") assertThat(data.file.path).endsWith("image.png")
val info = data as MediaUploadInfo.Image val info = data as MediaUploadInfo.Image
// Computing thumbnailFile is failing with Robolectric assertThat(info.thumbnailFile).isNotNull()
assertThat(info.thumbnailFile).isNull()
assertThat(info.imageInfo).isEqualTo( assertThat(info.imageInfo).isEqualTo(
ImageInfo( ImageInfo(
height = 1_178, height = 1_178,
width = 1_818, width = 1_818,
mimetype = MimeTypes.Png, mimetype = MimeTypes.Png,
size = 114_867, size = 114_867,
thumbnailInfo = null, ThumbnailInfo(height = 294, width = 454, mimetype = "image/jpeg", size = 4567),
thumbnailSource = null, thumbnailSource = null,
blurhash = null, blurhash = "K13]7q%zWC00R4of%\$baad"
) )
) )
assertThat(file.exists()).isTrue() assertThat(file.exists()).isTrue()
@ -88,7 +87,6 @@ class AndroidMediaPreProcessorTest {
val data = result.getOrThrow() val data = result.getOrThrow()
assertThat(data.file.path).endsWith("image.png") assertThat(data.file.path).endsWith("image.png")
val info = data as MediaUploadInfo.Image val info = data as MediaUploadInfo.Image
// Computing thumbnailFile is failing with Robolectric
assertThat(info.thumbnailFile).isNull() assertThat(info.thumbnailFile).isNull()
assertThat(info.imageInfo).isEqualTo( assertThat(info.imageInfo).isEqualTo(
ImageInfo( ImageInfo(

View file

@ -21,5 +21,5 @@ import io.element.android.libraries.matrix.api.core.SessionId
interface NotificationDrawerManager { interface NotificationDrawerManager {
fun clearMembershipNotificationForSession(sessionId: SessionId) fun clearMembershipNotificationForSession(sessionId: SessionId)
fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId, doRender: Boolean) fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId)
} }

View file

@ -52,6 +52,7 @@ dependencies {
implementation(projects.libraries.network) implementation(projects.libraries.network)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.troubleshoot.api) implementation(projects.libraries.troubleshoot.api)
api(projects.libraries.pushproviders.api) api(projects.libraries.pushproviders.api)
@ -63,8 +64,8 @@ dependencies {
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.mockk) testImplementation(libs.test.mockk)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.coil.test) testImplementation(libs.coil.test)

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2024 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 io.element.android.libraries.push.impl.di
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
@Module
@ContributesTo(AppScope::class)
object PushModule {
@Provides
fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat {
return NotificationManagerCompat.from(context)
}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2024 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 io.element.android.libraries.push.impl.notifications
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import javax.inject.Inject
interface ActiveNotificationsProvider {
fun getAllNotifications(): List<StatusBarNotification>
fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification>
fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification>
fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
fun getSummaryNotification(sessionId: SessionId): StatusBarNotification?
fun count(sessionId: SessionId): Int
}
@ContributesBinding(AppScope::class)
class DefaultActiveNotificationsProvider @Inject constructor(
private val notificationManager: NotificationManagerCompat,
private val notificationIdProvider: NotificationIdProvider,
) : ActiveNotificationsProvider {
override fun getAllNotifications(): List<StatusBarNotification> {
return notificationManager.activeNotifications
}
override fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification> {
return notificationManager.activeNotifications.filter { it.groupKey == sessionId.value }
}
override fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification> {
val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId }
}
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
val notificationId = notificationIdProvider.getRoomMessagesNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value }
}
override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value }
}
override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? {
val summaryId = notificationIdProvider.getSummaryNotificationId(sessionId)
return getNotificationsForSession(sessionId).find { it.id == summaryId }
}
override fun count(sessionId: SessionId): Int {
return getNotificationsForSession(sessionId).size
}
}

View file

@ -16,14 +16,13 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.androidutils.throttler.FirstThrottler import androidx.annotation.VisibleForTesting
import io.element.android.libraries.core.cache.CircularCache import androidx.core.app.NotificationManagerCompat
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -33,45 +32,36 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.api.currentSessionId import io.element.android.services.appnavstate.api.currentSessionId
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
private val loggerTag = LoggerTag("DefaultNotificationDrawerManager", LoggerTag.NotificationLoggerTag) private val loggerTag = LoggerTag("DefaultNotificationDrawerManager", LoggerTag.NotificationLoggerTag)
/** /**
* The NotificationDrawerManager receives notification events as they arrived (from event stream or fcm) and * The NotificationDrawerManager receives notification events as they arrive (from event stream or fcm) and
* organise them in order to display them in the notification drawer. * organise them in order to display them in the notification drawer.
* Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning. * Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning.
*/ */
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class DefaultNotificationDrawerManager @Inject constructor( class DefaultNotificationDrawerManager @Inject constructor(
private val notifiableEventProcessor: NotifiableEventProcessor, private val notificationManager: NotificationManagerCompat,
private val notificationRenderer: NotificationRenderer, private val notificationRenderer: NotificationRenderer,
private val notificationEventPersistence: NotificationEventPersistence, private val notificationIdProvider: NotificationIdProvider,
private val filteredEventDetector: FilteredEventDetector,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
private val buildMeta: BuildMeta,
private val matrixClientProvider: MatrixClientProvider, private val matrixClientProvider: MatrixClientProvider,
private val imageLoaderHolder: ImageLoaderHolder, private val imageLoaderHolder: ImageLoaderHolder,
private val activeNotificationsProvider: ActiveNotificationsProvider,
) : NotificationDrawerManager { ) : NotificationDrawerManager {
private var appNavigationStateObserver: Job? = null private var appNavigationStateObserver: Job? = null
/**
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
*/
private val notificationState by lazy { createInitialNotificationState() }
private val firstThrottler = FirstThrottler(200)
// TODO EAx add a setting per user for this // TODO EAx add a setting per user for this
private var useCompleteNotificationFormat = true private var useCompleteNotificationFormat = true
@ -84,7 +74,8 @@ class DefaultNotificationDrawerManager @Inject constructor(
} }
// For test only // For test only
fun destroy() { @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun destroy() {
appNavigationStateObserver?.cancel() appNavigationStateObserver?.cancel()
} }
@ -105,7 +96,6 @@ class DefaultNotificationDrawerManager @Inject constructor(
clearMessagesForRoom( clearMessagesForRoom(
sessionId = navigationState.parentSpace.parentSession.sessionId, sessionId = navigationState.parentSpace.parentSession.sessionId,
roomId = navigationState.roomId, roomId = navigationState.roomId,
doRender = true,
) )
} }
is NavigationState.Thread -> { is NavigationState.Thread -> {
@ -119,95 +109,71 @@ class DefaultNotificationDrawerManager @Inject constructor(
currentAppNavigationState = navigationState currentAppNavigationState = navigationState
} }
private fun createInitialNotificationState(): NotificationState {
val queuedEvents = notificationEventPersistence.loadEvents(factory = { rawEvents ->
NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25))
})
val renderedEvents = queuedEvents.rawEvents().map { ProcessedEvent(ProcessedEvent.Type.KEEP, it) }.toMutableList()
return NotificationState(queuedEvents, renderedEvents)
}
private fun NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
if (buildMeta.lowPrivacyLoggingEnabled) {
Timber.tag(loggerTag.value).d("onNotifiableEventReceived(): $notifiableEvent")
} else {
Timber.tag(loggerTag.value).d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
}
if (filteredEventDetector.shouldBeIgnored(notifiableEvent)) {
Timber.tag(loggerTag.value).d("onNotifiableEventReceived(): ignore the event")
return
}
add(notifiableEvent)
}
/** /**
* Should be called as soon as a new event is ready to be displayed. * Should be called as soon as a new event is ready to be displayed, filtering out notifications that shouldn't be displayed.
* The notification corresponding to this event will not be displayed until
* #refreshNotificationDrawer() is called.
* Events might be grouped and there might not be one notification per event! * Events might be grouped and there might not be one notification per event!
*/ */
fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
updateEvents(doRender = true) { if (notifiableEvent.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value)) {
it.onNotifiableEventReceived(notifiableEvent) return
} }
renderEvents(listOf(notifiableEvent))
} }
/** /**
* Clear all known events and refresh the notification drawer. * Clear all known message events for a [sessionId].
*/ */
fun clearAllMessagesEvents(sessionId: SessionId, doRender: Boolean) { fun clearAllMessagesEvents(sessionId: SessionId) {
updateEvents(doRender = doRender) { notificationManager.cancel(null, notificationIdProvider.getRoomMessagesNotificationId(sessionId))
it.clearMessagesForSession(sessionId) clearSummaryNotificationIfNeeded(sessionId)
}
} }
/** /**
* Clear all notifications related to the session and refresh the notification drawer. * Clear all notifications related to the session.
*/ */
fun clearAllEvents(sessionId: SessionId) { fun clearAllEvents(sessionId: SessionId) {
updateEvents(doRender = true) { activeNotificationsProvider.getNotificationsForSession(sessionId)
it.clearAllForSession(sessionId) .forEach { notificationManager.cancel(it.tag, it.id) }
}
} }
/** /**
* Should be called when the application is currently opened and showing timeline for the given roomId. * Should be called when the application is currently opened and showing timeline for the given [roomId].
* Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room. * Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room.
* Can also be called when a notification for this room is dismissed by the user. * Can also be called when a notification for this room is dismissed by the user.
*/ */
fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId, doRender: Boolean) { fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
updateEvents(doRender = doRender) { notificationManager.cancel(roomId.value, notificationIdProvider.getRoomMessagesNotificationId(sessionId))
it.clearMessagesForRoom(sessionId, roomId) clearSummaryNotificationIfNeeded(sessionId)
}
} }
override fun clearMembershipNotificationForSession(sessionId: SessionId) { override fun clearMembershipNotificationForSession(sessionId: SessionId) {
updateEvents(doRender = true) { activeNotificationsProvider.getMembershipNotificationForSession(sessionId)
it.clearMembershipNotificationForSession(sessionId) .forEach { notificationManager.cancel(it.tag, it.id) }
} clearSummaryNotificationIfNeeded(sessionId)
} }
/** /**
* Clear invitation notification for the provided room. * Clear invitation notification for the provided room.
*/ */
override fun clearMembershipNotificationForRoom( override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
sessionId: SessionId, activeNotificationsProvider.getMembershipNotificationForRoom(sessionId, roomId)
roomId: RoomId, .forEach { notificationManager.cancel(it.tag, it.id) }
doRender: Boolean, clearSummaryNotificationIfNeeded(sessionId)
) {
updateEvents(doRender = doRender) {
it.clearMembershipNotificationForRoom(sessionId, roomId)
}
} }
/** /**
* Clear the notifications for a single event. * Clear the notifications for a single event.
*/ */
fun clearEvent(sessionId: SessionId, eventId: EventId, doRender: Boolean) { fun clearEvent(sessionId: SessionId, eventId: EventId) {
updateEvents(doRender = doRender) { val id = notificationIdProvider.getRoomEventNotificationId(sessionId)
it.clearEvent(sessionId, eventId) notificationManager.cancel(eventId.value, id)
clearSummaryNotificationIfNeeded(sessionId)
}
private fun clearSummaryNotificationIfNeeded(sessionId: SessionId) {
val summaryNotification = activeNotificationsProvider.getSummaryNotification(sessionId)
if (summaryNotification != null && activeNotificationsProvider.count(sessionId) == 1) {
notificationManager.cancel(null, summaryNotification.id)
} }
} }
@ -215,69 +181,19 @@ class DefaultNotificationDrawerManager @Inject constructor(
* Should be called when the application is currently opened and showing timeline for the given threadId. * Should be called when the application is currently opened and showing timeline for the given threadId.
* Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room. * Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
*/ */
@Suppress("UNUSED_PARAMETER")
private fun onEnteringThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) { private fun onEnteringThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
updateEvents(doRender = true) { // TODO maybe we'll have to embed more data in the tag to get a threadId
it.clearMessagesForThread(sessionId, roomId, threadId) // Do nothing for now
}
} }
private fun updateEvents( private suspend fun renderEvents(eventsToRender: List<NotifiableEvent>) {
doRender: Boolean,
action: (NotificationEventQueue) -> Unit,
) {
notificationState.updateQueuedEvents { queuedEvents, _ ->
action(queuedEvents)
}
coroutineScope.refreshNotificationDrawer(doRender)
}
private fun CoroutineScope.refreshNotificationDrawer(doRender: Boolean) = launch {
// Implement last throttler
val canHandle = firstThrottler.canHandle()
Timber.tag(loggerTag.value).v("refreshNotificationDrawer($doRender), delay: ${canHandle.waitMillis()} ms")
withContext(dispatchers.io) {
delay(canHandle.waitMillis())
try {
refreshNotificationDrawerBg(doRender)
} catch (throwable: Throwable) {
// It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer
Timber.tag(loggerTag.value).w(throwable, "refreshNotificationDrawerBg failure")
}
}
}
private suspend fun refreshNotificationDrawerBg(doRender: Boolean) {
Timber.tag(loggerTag.value).v("refreshNotificationDrawerBg($doRender)")
val eventsToRender = notificationState.updateQueuedEvents { queuedEvents, renderedEvents ->
notifiableEventProcessor.process(queuedEvents.rawEvents(), renderedEvents).also {
queuedEvents.clearAndAdd(it.onlyKeptEvents())
}
}
if (notificationState.hasAlreadyRendered(eventsToRender)) {
Timber.tag(loggerTag.value).d("Skipping notification update due to event list not changing")
} else {
notificationState.clearAndAddRenderedEvents(eventsToRender)
if (doRender) {
renderEvents(eventsToRender)
}
persistEvents()
}
}
private fun persistEvents() {
notificationState.queuedEvents { queuedEvents ->
notificationEventPersistence.persistEvents(queuedEvents)
}
}
private suspend fun renderEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
// Group by sessionId // Group by sessionId
val eventsForSessions = eventsToRender.groupBy { val eventsForSessions = eventsToRender.groupBy {
it.event.sessionId it.sessionId
} }
eventsForSessions.forEach { (sessionId, notifiableEvents) -> for ((sessionId, notifiableEvents) in eventsForSessions) {
val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow() val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow()
val imageLoader = imageLoaderHolder.get(client) val imageLoader = imageLoaderHolder.get(client)
val userFromCache = client.userProfile.value val userFromCache = client.userProfile.value
@ -285,27 +201,29 @@ class DefaultNotificationDrawerManager @Inject constructor(
// We have an avatar and a display name, use it // We have an avatar and a display name, use it
userFromCache userFromCache
} else { } else {
tryOrNull( client.getSafeUserProfile()
onError = { Timber.tag(loggerTag.value).e(it, "Unable to retrieve info for user ${sessionId.value}") },
operation = {
client.getUserProfile().getOrNull()
?.let {
// displayName cannot be empty else NotificationCompat.MessagingStyle() will crash
if (it.displayName.isNullOrEmpty()) {
it.copy(displayName = sessionId.value)
} else {
it
}
}
}
) ?: MatrixUser(
userId = sessionId,
displayName = sessionId.value,
avatarUrl = null
)
} }
notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents, imageLoader) notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents, imageLoader)
} }
} }
private suspend fun MatrixClient.getSafeUserProfile(): MatrixUser {
return tryOrNull(
onError = { Timber.tag(loggerTag.value).e(it, "Unable to retrieve info for user ${sessionId.value}") },
operation = {
val profile = getUserProfile().getOrNull()
// displayName cannot be empty else NotificationCompat.MessagingStyle() will crash
if (profile?.displayName.isNullOrEmpty()) {
profile?.copy(displayName = sessionId.value)
} else {
profile
}
}
) ?: MatrixUser(
userId = sessionId,
displayName = sessionId.value,
avatarUrl = null
)
}
} }

View file

@ -1,94 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.file.EncryptedFileFactory
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import timber.log.Timber
import java.io.File
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import javax.inject.Inject
private const val ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY = "im.vector.notifications.cache"
private const val FILE_NAME = "notifications.bin"
private val loggerTag = LoggerTag("NotificationEventPersistence", LoggerTag.NotificationLoggerTag)
@ContributesBinding(AppScope::class)
class DefaultNotificationEventPersistence @Inject constructor(
@ApplicationContext private val context: Context,
) : NotificationEventPersistence {
private val file by lazy {
deleteLegacyFileIfAny()
context.getDatabasePath(FILE_NAME)
}
private val encryptedFile by lazy {
EncryptedFileFactory(context).create(file)
}
override fun loadEvents(factory: (List<NotifiableEvent>) -> NotificationEventQueue): NotificationEventQueue {
val rawEvents: ArrayList<NotifiableEvent>? = file
.takeIf { it.exists() }
?.let {
try {
encryptedFile.openFileInput().use { fis ->
ObjectInputStream(fis).use { ois ->
@Suppress("UNCHECKED_CAST")
ois.readObject() as? ArrayList<NotifiableEvent>
}
}.also {
Timber.tag(loggerTag.value).d("Deserializing ${it?.size} NotifiableEvent(s)")
}
} catch (e: Throwable) {
Timber.tag(loggerTag.value).e(e, "## Failed to load cached notification info")
null
}
}
return factory(rawEvents.orEmpty())
}
override fun persistEvents(queuedEvents: NotificationEventQueue) {
Timber.tag(loggerTag.value).d("Serializing ${queuedEvents.rawEvents().size} NotifiableEvent(s)")
// Always delete file before writing, or encryptedFile.openFileOutput() will throw
file.safeDelete()
if (queuedEvents.isEmpty()) return
try {
encryptedFile.openFileOutput().use { fos ->
ObjectOutputStream(fos).use { oos ->
oos.writeObject(queuedEvents.rawEvents())
}
}
} catch (e: Throwable) {
Timber.tag(loggerTag.value).e(e, "## Failed to save cached notification info")
}
}
private fun deleteLegacyFileIfAny() {
tryOrNull {
File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY).delete()
}
}
}

View file

@ -1,57 +0,0 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import javax.inject.Inject
class FilteredEventDetector @Inject constructor(
// private val activeSessionDataSource: ActiveSessionDataSource
) {
/**
* Returns true if the given event should be ignored.
* Used to skip notifications if a non expected message is received.
*/
fun shouldBeIgnored(@Suppress("UNUSED_PARAMETER") notifiableEvent: NotifiableEvent): Boolean {
/* TODO EAx
val session = activeSessionDataSource.currentValue?.orNull() ?: return false
if (notifiableEvent is NotifiableMessageEvent) {
val room = session.getRoom(notifiableEvent.roomId) ?: return false
val timelineEvent = room.getTimelineEvent(notifiableEvent.eventId) ?: return false
return timelineEvent.shouldBeIgnored()
}
*/
return false
}
/*
/**
* Whether the timeline event should be ignored.
*/
private fun TimelineEvent.shouldBeIgnored(): Boolean {
if (root.isVoiceMessage()) {
val audioEvent = root.asMessageAudioEvent()
// if the event is a voice message related to a voice broadcast, only show the event on the first chunk.
return audioEvent.isVoiceBroadcast() && audioEvent?.sequence != 1
}
return false
}
*/
}

View file

@ -1,77 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationStateService
import timber.log.Timber
import javax.inject.Inject
private typealias ProcessedEvents = List<ProcessedEvent<NotifiableEvent>>
private val loggerTag = LoggerTag("NotifiableEventProcessor", LoggerTag.NotificationLoggerTag)
class NotifiableEventProcessor @Inject constructor(
private val outdatedDetector: OutdatedEventDetector,
private val appNavigationStateService: AppNavigationStateService,
) {
fun process(
queuedEvents: List<NotifiableEvent>,
renderedEvents: ProcessedEvents,
): ProcessedEvents {
val appState = appNavigationStateService.appNavigationState.value
val processedEvents = queuedEvents.map {
val type = when (it) {
is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP
is NotifiableMessageEvent -> when {
it.shouldIgnoreEventInRoom(appState) -> {
ProcessedEvent.Type.REMOVE
.also { Timber.tag(loggerTag.value).d("notification message removed due to currently viewing the same room or thread") }
}
outdatedDetector.isMessageOutdated(it) -> ProcessedEvent.Type.REMOVE
.also { Timber.tag(loggerTag.value).d("notification message removed due to being read") }
else -> ProcessedEvent.Type.KEEP
}
is SimpleNotifiableEvent -> when (it.type) {
EventType.REDACTION -> ProcessedEvent.Type.REMOVE
else -> ProcessedEvent.Type.KEEP
}
is FallbackNotifiableEvent -> when {
it.shouldIgnoreEventInRoom(appState) -> {
ProcessedEvent.Type.REMOVE
.also { Timber.tag(loggerTag.value).d("notification fallback removed due to currently viewing the same room or thread") }
}
else -> ProcessedEvent.Type.KEEP
}
}
ProcessedEvent(type, it)
}
val removedEventsDiff = renderedEvents.filter { renderedEvent ->
queuedEvents.none { it.eventId == renderedEvent.event.eventId }
}.map { ProcessedEvent(ProcessedEvent.Type.REMOVE, it.event) }
return removedEventsDiff + processedEvents
}
}

View file

@ -19,11 +19,17 @@ package io.element.android.libraries.push.impl.notifications
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import io.element.android.features.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -33,87 +39,69 @@ private val loggerTag = LoggerTag("NotificationBroadcastReceiver", LoggerTag.Not
* Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.). * Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.).
*/ */
class NotificationBroadcastReceiver : BroadcastReceiver() { class NotificationBroadcastReceiver : BroadcastReceiver() {
@Inject lateinit var appCoroutineScope: CoroutineScope
@Inject lateinit var matrixClientProvider: MatrixClientProvider
@Inject lateinit var sessionPreferencesStore: SessionPreferencesStoreFactory
@Inject lateinit var defaultNotificationDrawerManager: DefaultNotificationDrawerManager @Inject lateinit var defaultNotificationDrawerManager: DefaultNotificationDrawerManager
@Inject lateinit var actionIds: NotificationActionIds @Inject lateinit var actionIds: NotificationActionIds
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent == null || context == null) return if (intent == null || context == null) return
context.bindings<NotificationBroadcastReceiverBindings>().inject(this)
val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return
val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId) val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId)
val eventId = intent.getStringExtra(KEY_EVENT_ID)?.let(::EventId) val eventId = intent.getStringExtra(KEY_EVENT_ID)?.let(::EventId)
context.bindings<NotificationBroadcastReceiverBindings>().inject(this)
Timber.tag(loggerTag.value).d("onReceive: ${intent.action} ${intent.data} for: ${roomId?.value}/${eventId?.value}") Timber.tag(loggerTag.value).d("onReceive: ${intent.action} ${intent.data} for: ${roomId?.value}/${eventId?.value}")
when (intent.action) { when (intent.action) {
actionIds.smartReply -> actionIds.smartReply ->
handleSmartReply(intent, context) handleSmartReply(intent, context)
actionIds.dismissRoom -> if (roomId != null) { actionIds.dismissRoom -> if (roomId != null) {
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId, doRender = false) defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
} }
actionIds.dismissSummary -> actionIds.dismissSummary ->
defaultNotificationDrawerManager.clearAllMessagesEvents(sessionId, doRender = false) defaultNotificationDrawerManager.clearAllMessagesEvents(sessionId)
actionIds.dismissInvite -> if (roomId != null) { actionIds.dismissInvite -> if (roomId != null) {
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = false) defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
} }
actionIds.dismissEvent -> if (eventId != null) { actionIds.dismissEvent -> if (eventId != null) {
defaultNotificationDrawerManager.clearEvent(sessionId, eventId, doRender = false) defaultNotificationDrawerManager.clearEvent(sessionId, eventId)
} }
actionIds.markRoomRead -> if (roomId != null) { actionIds.markRoomRead -> if (roomId != null) {
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId, doRender = true) defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
handleMarkAsRead(sessionId, roomId) handleMarkAsRead(sessionId, roomId)
} }
actionIds.join -> if (roomId != null) { actionIds.join -> if (roomId != null) {
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = true) defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
handleJoinRoom(sessionId, roomId) handleJoinRoom(sessionId, roomId)
} }
actionIds.reject -> if (roomId != null) { actionIds.reject -> if (roomId != null) {
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = true) defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
handleRejectRoom(sessionId, roomId) handleRejectRoom(sessionId, roomId)
} }
} }
} }
@Suppress("UNUSED_PARAMETER") private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) { val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
/* client.joinRoom(roomId)
activeSessionHolder.getSafeActiveSession()?.let { session ->
val room = session.getRoom(roomId)
if (room != null) {
session.coroutineScope.launch {
tryOrNull {
session.roomService().joinRoom(room.roomId)
analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom(JoinedRoom.Trigger.Notification))
}
}
}
}
*/
} }
@Suppress("UNUSED_PARAMETER") private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) { val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
/* client.getRoom(roomId)?.leave()
activeSessionHolder.getSafeActiveSession()?.let { session ->
session.coroutineScope.launch {
tryOrNull { session.roomService().leaveRoom(roomId) }
}
}
*/
} }
@Suppress("UNUSED_PARAMETER") private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) { val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
/* val isRenderReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isRenderReadReceiptsEnabled().first()
activeSessionHolder.getActiveSession().let { session -> val receiptType = if (isRenderReadReceiptsEnabled) {
val room = session.getRoom(roomId) ReceiptType.READ
if (room != null) { } else {
session.coroutineScope.launch { ReceiptType.READ_PRIVATE
tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) }
}
}
} }
client.getRoom(roomId)?.markAsRead(receiptType = receiptType)
*/
} }
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")

View file

@ -0,0 +1,239 @@
/*
* 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 io.element.android.libraries.push.impl.notifications
import android.app.Notification
import android.graphics.Typeface
import android.text.style.StyleSpan
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import coil.ImageLoader
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
interface NotificationDataFactory {
suspend fun toNotifications(
messages: List<NotifiableMessageEvent>,
currentUser: MatrixUser,
imageLoader: ImageLoader,
): List<RoomNotification>
@JvmName("toNotificationInvites")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(invites: List<InviteNotifiableEvent>): List<OneShotNotification>
@JvmName("toNotificationSimpleEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(simpleEvents: List<SimpleNotifiableEvent>): List<OneShotNotification>
fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification>
fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): SummaryNotification
}
@ContributesBinding(AppScope::class)
class DefaultNotificationDataFactory @Inject constructor(
private val notificationCreator: NotificationCreator,
private val roomGroupMessageCreator: RoomGroupMessageCreator,
private val summaryGroupMessageCreator: SummaryGroupMessageCreator,
private val activeNotificationsProvider: ActiveNotificationsProvider,
private val stringProvider: StringProvider,
) : NotificationDataFactory {
override suspend fun toNotifications(
messages: List<NotifiableMessageEvent>,
currentUser: MatrixUser,
imageLoader: ImageLoader,
): List<RoomNotification> {
val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() }
.groupBy { it.roomId }
return messagesToDisplay.map { (roomId, events) ->
val roomName = events.lastOrNull()?.roomName ?: roomId.value
val isDirect = events.lastOrNull()?.roomIsDirect ?: false
val notification = roomGroupMessageCreator.createRoomMessage(
currentUser = currentUser,
events = events,
roomId = roomId,
imageLoader = imageLoader,
existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId),
)
RoomNotification(
notification = notification,
roomId = roomId,
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDirect),
messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy }
)
}
}
private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted
private fun getExistingNotificationForMessages(sessionId: SessionId, roomId: RoomId): Notification? {
return activeNotificationsProvider.getMessageNotificationsForRoom(sessionId, roomId).firstOrNull()?.notification
}
@JvmName("toNotificationInvites")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(invites: List<InviteNotifiableEvent>): List<OneShotNotification> {
return invites.map { event ->
OneShotNotification(
key = event.roomId.value,
notification = notificationCreator.createRoomInvitationNotification(event),
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
)
}
}
@JvmName("toNotificationSimpleEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(simpleEvents: List<SimpleNotifiableEvent>): List<OneShotNotification> {
return simpleEvents.map { event ->
OneShotNotification(
key = event.eventId.value,
notification = notificationCreator.createSimpleEventNotification(event),
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
)
}
}
override fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification> {
return fallback.map { event ->
OneShotNotification(
key = event.eventId.value,
notification = notificationCreator.createFallbackNotification(event),
summaryLine = event.description.orEmpty(),
isNoisy = false,
timestamp = event.timestamp
)
}
}
override fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): SummaryNotification {
return when {
roomNotifications.isEmpty() && invitationNotifications.isEmpty() && simpleNotifications.isEmpty() -> SummaryNotification.Removed
else -> SummaryNotification.Update(
summaryGroupMessageCreator.createSummaryNotification(
currentUser = currentUser,
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
useCompleteNotificationFormat = useCompleteNotificationFormat
)
)
}
}
private fun createRoomMessagesGroupSummaryLine(events: List<NotifiableMessageEvent>, roomName: String, roomIsDirect: Boolean): CharSequence {
return 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
)
}
}
}
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence {
return if (roomIsDirect) {
buildSpannedString {
event.senderDisambiguatedDisplayName?.let {
inSpans(StyleSpan(Typeface.BOLD)) {
append(it)
append(": ")
}
}
append(event.description)
}
} else {
buildSpannedString {
inSpans(StyleSpan(Typeface.BOLD)) {
append(roomName)
append(": ")
event.senderDisambiguatedDisplayName?.let {
append(it)
append(" ")
}
}
append(event.description)
}
}
}
}
data class RoomNotification(
val notification: Notification,
val roomId: RoomId,
val summaryLine: CharSequence,
val messageCount: Int,
val latestTimestamp: Long,
val shouldBing: Boolean,
) {
fun isDataEqualTo(other: RoomNotification): Boolean {
return notification == other.notification &&
roomId == other.roomId &&
summaryLine.toString() == other.summaryLine.toString() &&
messageCount == other.messageCount &&
latestTimestamp == other.latestTimestamp &&
shouldBing == other.shouldBing
}
}
data class OneShotNotification(
val notification: Notification,
val key: String,
val summaryLine: CharSequence,
val isNoisy: Boolean,
val timestamp: Long,
)
sealed interface SummaryNotification {
data object Removed : SummaryNotification
data class Update(val notification: Notification) : SummaryNotification
}

View file

@ -22,16 +22,25 @@ import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class NotificationDisplayer @Inject constructor( interface NotificationDisplayer {
@ApplicationContext private val context: Context, fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean
) { fun cancelNotificationMessage(tag: String?, id: Int)
private val notificationManager = NotificationManagerCompat.from(context) fun displayDiagnosticNotification(notification: Notification): Boolean
fun dismissDiagnosticNotification()
}
fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean { @ContributesBinding(AppScope::class)
class DefaultNotificationDisplayer @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationManager: NotificationManagerCompat
) : NotificationDisplayer {
override fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
Timber.w("Not allowed to notify.") Timber.w("Not allowed to notify.")
return false return false
@ -40,20 +49,11 @@ class NotificationDisplayer @Inject constructor(
return true return true
} }
fun cancelNotificationMessage(tag: String?, id: Int) { override fun cancelNotificationMessage(tag: String?, id: Int) {
notificationManager.cancel(tag, id) notificationManager.cancel(tag, id)
} }
fun cancelAllNotifications() { override fun displayDiagnosticNotification(notification: Notification): Boolean {
// Keep this try catch (reported by GA)
try {
notificationManager.cancelAll()
} catch (e: Exception) {
Timber.e(e, "## cancelAllNotifications() failed")
}
}
fun displayDiagnosticNotification(notification: Notification): Boolean {
return showNotificationMessage( return showNotificationMessage(
tag = "DIAGNOSTIC", tag = "DIAGNOSTIC",
id = NOTIFICATION_ID_DIAGNOSTIC, id = NOTIFICATION_ID_DIAGNOSTIC,
@ -61,33 +61,17 @@ class NotificationDisplayer @Inject constructor(
) )
} }
fun dismissDiagnosticNotification() { override fun dismissDiagnosticNotification() {
cancelNotificationMessage( cancelNotificationMessage(
tag = "DIAGNOSTIC", tag = "DIAGNOSTIC",
id = NOTIFICATION_ID_DIAGNOSTIC id = NOTIFICATION_ID_DIAGNOSTIC
) )
} }
/**
* Cancel the foreground notification service.
*/
fun cancelNotificationForegroundService() {
notificationManager.cancel(NOTIFICATION_ID_FOREGROUND_SERVICE)
}
companion object { companion object {
/* ========================================================================================== /* ==========================================================================================
* IDs for notifications * IDs for notifications
* ========================================================================================== */ * ========================================================================================== */
/**
* Identifier of the foreground notification used to keep the application alive
* when it runs in background.
* This notification, which is not removable by the end user, displays what
* the application is doing while in background.
*/
private const val NOTIFICATION_ID_FOREGROUND_SERVICE = 61
private const val NOTIFICATION_ID_DIAGNOSTIC = 888 private const val NOTIFICATION_ID_DIAGNOSTIC = 888
} }
} }

View file

@ -1,24 +0,0 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
interface NotificationEventPersistence {
fun loadEvents(factory: (List<NotifiableEvent>) -> NotificationEventQueue): NotificationEventQueue
fun persistEvents(queuedEvents: NotificationEventQueue)
}

View file

@ -1,186 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.core.cache.CircularCache
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import timber.log.Timber
data class NotificationEventQueue(
private val queue: MutableList<NotifiableEvent>,
/**
* An in memory FIFO cache of the seen events.
* Acts as a notification debouncer to stop already dismissed push notifications from
* displaying again when the /sync response is delayed.
* TODO Should be per session, so the key must be Pair<SessionId, EventId>.
*/
private val seenEventIds: CircularCache<EventId>
) {
fun markRedacted(eventIds: List<EventId>) {
eventIds.forEach { redactedId ->
queue.replace(redactedId) {
when (it) {
is InviteNotifiableEvent -> it.copy(isRedacted = true)
is NotifiableMessageEvent -> it.copy(isRedacted = true)
is SimpleNotifiableEvent -> it.copy(isRedacted = true)
is FallbackNotifiableEvent -> it.copy(isRedacted = true)
}
}
}
}
// TODO EAx call this
fun syncRoomEvents(roomsLeft: Collection<RoomId>, roomsJoined: Collection<RoomId>) {
if (roomsLeft.isNotEmpty() || roomsJoined.isNotEmpty()) {
queue.removeAll {
when (it) {
is NotifiableMessageEvent -> roomsLeft.contains(it.roomId)
is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId)
is SimpleNotifiableEvent -> false
is FallbackNotifiableEvent -> roomsLeft.contains(it.roomId)
}
}
}
}
fun isEmpty() = queue.isEmpty()
fun clearAndAdd(events: List<NotifiableEvent>) {
queue.clear()
queue.addAll(events)
}
fun clear() {
queue.clear()
}
fun add(notifiableEvent: NotifiableEvent) {
val existing = findExistingById(notifiableEvent)
val edited = findEdited(notifiableEvent)
when {
existing != null -> {
if (existing.canBeReplaced) {
// Use the event coming from the event stream as it may contains more info than
// the fcm one (like type/content/clear text) (e.g when an encrypted message from
// FCM should be update with clear text after a sync)
// In this case the message has already been notified, and might have done some noise
// So we want the notification to be updated even if it has already been displayed
// Use setOnlyAlertOnce to ensure update notification does not interfere with sound
// from first notify invocation as outlined in:
// https://developer.android.com/training/notify-user/build-notification#Updating
replace(replace = existing, with = notifiableEvent)
} else {
// keep the existing one, do not replace
}
}
edited != null -> {
// Replace the existing notification with the new content
replace(replace = edited, with = notifiableEvent)
}
seenEventIds.contains(notifiableEvent.eventId) -> {
// we've already seen the event, lets skip
Timber.d("onNotifiableEventReceived(): skipping event, already seen")
}
else -> {
seenEventIds.put(notifiableEvent.eventId)
queue.add(notifiableEvent)
}
}
}
private fun findExistingById(notifiableEvent: NotifiableEvent): NotifiableEvent? {
return queue.firstOrNull { it.sessionId == notifiableEvent.sessionId && it.eventId == notifiableEvent.eventId }
}
private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? {
return notifiableEvent.editedEventId?.let { editedId ->
queue.firstOrNull {
it.eventId == editedId || it.editedEventId == editedId
}
}
}
private fun replace(replace: NotifiableEvent, with: NotifiableEvent) {
queue.remove(replace)
queue.add(
when (with) {
is InviteNotifiableEvent -> with.copy(isUpdated = true)
is NotifiableMessageEvent -> with.copy(isUpdated = true)
is SimpleNotifiableEvent -> with.copy(isUpdated = true)
is FallbackNotifiableEvent -> with.copy(isUpdated = true)
}
)
}
fun clearEvent(sessionId: SessionId, eventId: EventId) {
val isFallback = queue.firstOrNull { it.sessionId == sessionId && it.eventId == eventId } is FallbackNotifiableEvent
if (isFallback) {
Timber.d("Removing all the fallbacks")
queue.removeAll { it.sessionId == sessionId && it is FallbackNotifiableEvent }
} else {
queue.removeAll { it.sessionId == sessionId && it.eventId == eventId }
}
}
fun clearMembershipNotificationForSession(sessionId: SessionId) {
Timber.d("clearMemberShipOfSession $sessionId")
queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId }
}
fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
Timber.d("clearMemberShipOfRoom $sessionId, $roomId")
queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId }
}
fun clearMessagesForSession(sessionId: SessionId) {
Timber.d("clearMessagesForSession $sessionId")
queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId }
}
fun clearAllForSession(sessionId: SessionId) {
Timber.d("clearAllForSession $sessionId")
queue.removeAll { it.sessionId == sessionId }
}
fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
Timber.d("clearMessageEventOfRoom $sessionId, $roomId")
queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId }
}
fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
Timber.d("clearMessageEventOfThread $sessionId, $roomId, $threadId")
queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId && it.threadId == threadId }
}
fun rawEvents(): List<NotifiableEvent> = queue
}
private fun MutableList<NotifiableEvent>.replace(eventId: EventId, block: (NotifiableEvent) -> NotifiableEvent) {
val indexToReplace = indexOfFirst { it.eventId == eventId }
if (indexToReplace == -1) {
return
}
set(indexToReplace, block(get(indexToReplace)))
}

View file

@ -1,172 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications
import android.app.Notification
import coil.ImageLoader
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import javax.inject.Inject
private typealias ProcessedMessageEvents = List<ProcessedEvent<NotifiableMessageEvent>>
class NotificationFactory @Inject constructor(
private val notificationCreator: NotificationCreator,
private val roomGroupMessageCreator: RoomGroupMessageCreator,
private val summaryGroupMessageCreator: SummaryGroupMessageCreator
) {
suspend fun Map<RoomId, ProcessedMessageEvents>.toNotifications(
currentUser: MatrixUser,
imageLoader: ImageLoader,
): List<RoomNotification> {
return map { (roomId, events) ->
when {
events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId)
else -> {
val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted }
roomGroupMessageCreator.createRoomMessage(
currentUser = currentUser,
events = messageEvents,
roomId = roomId,
imageLoader = imageLoader,
)
}
}
}
}
private fun ProcessedMessageEvents.hasNoEventsToDisplay() = isEmpty() || all {
it.type == ProcessedEvent.Type.REMOVE || it.event.canNotBeDisplayed()
}
private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted
@JvmName("toNotificationsInviteNotifiableEvent")
fun List<ProcessedEvent<InviteNotifiableEvent>>.toNotifications(): List<OneShotNotification> {
return map { (processed, event) ->
when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId.value)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationCreator.createRoomInvitationNotification(event),
OneShotNotification.Append.Meta(
key = event.roomId.value,
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
)
)
}
}
}
@JvmName("toNotificationsSimpleNotifiableEvent")
fun List<ProcessedEvent<SimpleNotifiableEvent>>.toNotifications(): List<OneShotNotification> {
return map { (processed, event) ->
when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationCreator.createSimpleEventNotification(event),
OneShotNotification.Append.Meta(
key = event.eventId.value,
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
)
)
}
}
}
fun List<ProcessedEvent<FallbackNotifiableEvent>>.toNotifications(): List<OneShotNotification> {
return map { (processed, event) ->
when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationCreator.createFallbackNotification(event),
OneShotNotification.Append.Meta(
key = event.eventId.value,
summaryLine = event.description.orEmpty(),
isNoisy = false,
timestamp = event.timestamp
)
)
}
}
}
fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): SummaryNotification {
val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta }
val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val simpleMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val fallbackMeta = fallbackNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
return when {
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
else -> SummaryNotification.Update(
summaryGroupMessageCreator.createSummaryNotification(
currentUser = currentUser,
roomNotifications = roomMeta,
invitationNotifications = invitationMeta,
simpleNotifications = simpleMeta,
fallbackNotifications = fallbackMeta,
useCompleteNotificationFormat = useCompleteNotificationFormat
)
)
}
}
}
sealed interface RoomNotification {
data class Removed(val roomId: RoomId) : RoomNotification
data class Message(val notification: Notification, val meta: Meta) : RoomNotification {
data class Meta(
val roomId: RoomId,
val summaryLine: CharSequence,
val messageCount: Int,
val latestTimestamp: Long,
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,
val timestamp: Long,
)
}
}
sealed interface SummaryNotification {
data object Removed : SummaryNotification
data class Update(val notification: Notification) : SummaryNotification
}

View file

@ -18,7 +18,6 @@ package io.element.android.libraries.push.impl.notifications
import coil.ImageLoader import coil.ImageLoader
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
@ -33,20 +32,20 @@ private val loggerTag = LoggerTag("NotificationRenderer", LoggerTag.Notification
class NotificationRenderer @Inject constructor( class NotificationRenderer @Inject constructor(
private val notificationIdProvider: NotificationIdProvider, private val notificationIdProvider: NotificationIdProvider,
private val notificationDisplayer: NotificationDisplayer, private val notificationDisplayer: NotificationDisplayer,
private val notificationFactory: NotificationFactory, private val notificationDataFactory: NotificationDataFactory,
) { ) {
suspend fun render( suspend fun render(
currentUser: MatrixUser, currentUser: MatrixUser,
useCompleteNotificationFormat: Boolean, useCompleteNotificationFormat: Boolean,
eventsToProcess: List<ProcessedEvent<NotifiableEvent>>, eventsToProcess: List<NotifiableEvent>,
imageLoader: ImageLoader, imageLoader: ImageLoader,
) { ) {
val groupedEvents = eventsToProcess.groupByType() val groupedEvents = eventsToProcess.groupByType()
with(notificationFactory) { with(notificationDataFactory) {
val roomNotifications = groupedEvents.roomEvents.toNotifications(currentUser, imageLoader) val roomNotifications = toNotifications(groupedEvents.roomEvents, currentUser, imageLoader)
val invitationNotifications = groupedEvents.invitationEvents.toNotifications() val invitationNotifications = toNotifications(groupedEvents.invitationEvents)
val simpleNotifications = groupedEvents.simpleEvents.toNotifications() val simpleNotifications = toNotifications(groupedEvents.simpleEvents)
val fallbackNotifications = groupedEvents.fallbackEvents.toNotifications() val fallbackNotifications = toNotifications(groupedEvents.fallbackEvents)
val summaryNotification = createSummaryNotification( val summaryNotification = createSummaryNotification(
currentUser = currentUser, currentUser = currentUser,
roomNotifications = roomNotifications, roomNotifications = roomNotifications,
@ -65,101 +64,43 @@ class NotificationRenderer @Inject constructor(
) )
} }
roomNotifications.forEach { wrapper -> roomNotifications.forEach { notificationData ->
when (wrapper) { notificationDisplayer.showNotificationMessage(
is RoomNotification.Removed -> { tag = notificationData.roomId.value,
Timber.tag(loggerTag.value).d("Removing room messages notification ${wrapper.roomId}") id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
notificationDisplayer.cancelNotificationMessage( notification = notificationData.notification
tag = wrapper.roomId.value,
id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId)
)
}
is RoomNotification.Message -> if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating room messages notification ${wrapper.meta.roomId}")
notificationDisplayer.showNotificationMessage(
tag = wrapper.meta.roomId.value,
id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
}
invitationNotifications.forEach { wrapper ->
when (wrapper) {
is OneShotNotification.Removed -> {
Timber.tag(loggerTag.value).d("Removing invitation notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(
tag = wrapper.key,
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId)
)
}
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating invitation notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(
tag = wrapper.meta.key,
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
}
simpleNotifications.forEach { wrapper ->
when (wrapper) {
is OneShotNotification.Removed -> {
Timber.tag(loggerTag.value).d("Removing simple notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(
tag = wrapper.key,
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId)
)
}
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating simple notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(
tag = wrapper.meta.key,
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
}
/*
fallbackNotifications.forEach { wrapper ->
when (wrapper) {
is OneShotNotification.Removed -> {
Timber.tag(loggerTag.value).d("Removing fallback notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(
tag = wrapper.key,
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId)
)
}
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating fallback notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(
tag = wrapper.meta.key,
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
}
*/
val removedFallback = fallbackNotifications.filterIsInstance<OneShotNotification.Removed>()
val appendFallback = fallbackNotifications.filterIsInstance<OneShotNotification.Append>()
if (appendFallback.isEmpty() && removedFallback.isNotEmpty()) {
Timber.tag(loggerTag.value).d("Removing global fallback notification")
notificationDisplayer.cancelNotificationMessage(
tag = "FALLBACK",
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId)
) )
} else if (appendFallback.isNotEmpty()) { }
invitationNotifications.forEach { notificationData ->
if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.key}")
notificationDisplayer.showNotificationMessage(
tag = notificationData.key,
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
notification = notificationData.notification
)
}
}
simpleNotifications.forEach { notificationData ->
if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.key}")
notificationDisplayer.showNotificationMessage(
tag = notificationData.key,
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId),
notification = notificationData.notification
)
}
}
// Show only the first fallback notification
if (fallbackNotifications.isNotEmpty()) {
Timber.tag(loggerTag.value).d("Showing fallback notification") Timber.tag(loggerTag.value).d("Showing fallback notification")
notificationDisplayer.showNotificationMessage( notificationDisplayer.showNotificationMessage(
tag = "FALLBACK", tag = "FALLBACK",
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId), id = notificationIdProvider.getFallbackNotificationId(currentUser.userId),
notification = appendFallback.first().notification notification = fallbackNotifications.first().notification
) )
} }
@ -174,39 +115,30 @@ class NotificationRenderer @Inject constructor(
} }
} }
} }
fun cancelAllNotifications() {
notificationDisplayer.cancelAllNotifications()
}
} }
private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotificationEvents { private fun List<NotifiableEvent>.groupByType(): GroupedNotificationEvents {
val roomIdToEventMap: MutableMap<RoomId, MutableList<ProcessedEvent<NotifiableMessageEvent>>> = LinkedHashMap() val roomEvents: MutableList<NotifiableMessageEvent> = mutableListOf()
val simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList() val simpleEvents: MutableList<SimpleNotifiableEvent> = mutableListOf()
val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = ArrayList() val invitationEvents: MutableList<InviteNotifiableEvent> = mutableListOf()
val fallbackEvents: MutableList<ProcessedEvent<FallbackNotifiableEvent>> = ArrayList() val fallbackEvents: MutableList<FallbackNotifiableEvent> = mutableListOf()
forEach { forEach { event ->
when (val event = it.event) { when (event) {
is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType()) is InviteNotifiableEvent -> invitationEvents.add(event.castedToEventType())
is NotifiableMessageEvent -> { is NotifiableMessageEvent -> roomEvents.add(event.castedToEventType())
val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() } is SimpleNotifiableEvent -> simpleEvents.add(event.castedToEventType())
roomEvents.add(it.castedToEventType()) is FallbackNotifiableEvent -> fallbackEvents.add(event.castedToEventType())
}
is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType())
is FallbackNotifiableEvent -> {
fallbackEvents.add(it.castedToEventType())
}
} }
} }
return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents, fallbackEvents) return GroupedNotificationEvents(roomEvents, simpleEvents, invitationEvents, fallbackEvents)
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun <T : NotifiableEvent> ProcessedEvent<NotifiableEvent>.castedToEventType(): ProcessedEvent<T> = this as ProcessedEvent<T> private fun <T : NotifiableEvent> NotifiableEvent.castedToEventType(): T = this as T
data class GroupedNotificationEvents( data class GroupedNotificationEvents(
val roomEvents: Map<RoomId, List<ProcessedEvent<NotifiableMessageEvent>>>, val roomEvents: List<NotifiableMessageEvent>,
val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>, val simpleEvents: List<SimpleNotifiableEvent>,
val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>, val invitationEvents: List<InviteNotifiableEvent>,
val fallbackEvents: List<ProcessedEvent<FallbackNotifiableEvent>>, val fallbackEvents: List<FallbackNotifiableEvent>,
) )

View file

@ -1,59 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
class NotificationState(
/**
* The notifiable events queued for rendering or currently rendered.
*
* This is our source of truth for notifications, any changes to this list will be rendered as notifications.
* When events are removed the previously rendered notifications will be cancelled.
* When adding or updating, the notifications will be notified.
*
* Events are unique by their properties, we should be careful not to insert multiple events with the same event-id.
*/
private val queuedEvents: NotificationEventQueue,
/**
* The last known rendered notifiable events.
* We keep track of them in order to know which events have been removed from the eventList
* allowing us to cancel any notifications previous displayed by now removed events
*/
private val renderedEvents: MutableList<ProcessedEvent<NotifiableEvent>>,
) {
fun <T> updateQueuedEvents(
action: (NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T
): T {
return synchronized(queuedEvents) {
action(queuedEvents, renderedEvents)
}
}
fun clearAndAddRenderedEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
renderedEvents.clear()
renderedEvents.addAll(eventsToRender)
}
fun hasAlreadyRendered(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) = renderedEvents == eventsToRender
fun queuedEvents(block: (NotificationEventQueue) -> Unit) {
synchronized(queuedEvents) {
block(queuedEvents)
}
}
}

View file

@ -1,44 +0,0 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import javax.inject.Inject
class OutdatedEventDetector @Inject constructor(
// / private val activeSessionDataSource: ActiveSessionDataSource
) {
/**
* Returns true if the given event is outdated.
* Used to clean up notifications if a displayed message has been read on an
* other device.
*/
fun isMessageOutdated(@Suppress("UNUSED_PARAMETER") notifiableEvent: NotifiableEvent): Boolean {
/* TODO EAx
val session = activeSessionDataSource.currentValue?.orNull() ?: return false
if (notifiableEvent is NotifiableMessageEvent) {
val eventID = notifiableEvent.eventId
val roomID = notifiableEvent.roomId
val room = session.getRoom(roomID) ?: return false
return room.readService().isEventRead(eventID)
}
*/
return false
}
}

View file

@ -1,31 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications
data class ProcessedEvent<T>(
val type: Type,
val event: T
) {
enum class Type {
KEEP,
REMOVE
}
}
fun <T> List<ProcessedEvent<T>>.onlyKeptEvents() = mapNotNull { processedEvent ->
processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP }
}

View file

@ -16,48 +16,46 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Typeface
import android.text.style.StyleSpan
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import coil.ImageLoader import coil.ImageLoader
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.factories.isSmartReplyError
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject import javax.inject.Inject
class RoomGroupMessageCreator @Inject constructor( interface RoomGroupMessageCreator {
private val bitmapLoader: NotificationBitmapLoader,
private val stringProvider: StringProvider,
private val notificationCreator: NotificationCreator
) {
suspend fun createRoomMessage( suspend fun createRoomMessage(
currentUser: MatrixUser, currentUser: MatrixUser,
events: List<NotifiableMessageEvent>, events: List<NotifiableMessageEvent>,
roomId: RoomId, roomId: RoomId,
imageLoader: ImageLoader, imageLoader: ImageLoader,
): RoomNotification.Message { existingNotification: Notification?,
): Notification
}
@ContributesBinding(AppScope::class)
class DefaultRoomGroupMessageCreator @Inject constructor(
private val bitmapLoader: NotificationBitmapLoader,
private val stringProvider: StringProvider,
private val notificationCreator: NotificationCreator,
) : RoomGroupMessageCreator {
override suspend fun createRoomMessage(
currentUser: MatrixUser,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
imageLoader: ImageLoader,
existingNotification: Notification?,
): Notification {
val lastKnownRoomEvent = events.last() val lastKnownRoomEvent = events.last()
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderDisambiguatedDisplayName ?: "Room name (${roomId.value.take(8)}…)" val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderDisambiguatedDisplayName ?: "Room name (${roomId.value.take(8)}…)"
val roomIsGroup = !lastKnownRoomEvent.roomIsDirect val roomIsGroup = !lastKnownRoomEvent.roomIsDirect
val style = NotificationCompat.MessagingStyle(
Person.Builder()
.setName(currentUser.displayName?.annotateForDebug(50))
.setIcon(bitmapLoader.getUserIcon(currentUser.avatarUrl, imageLoader))
.setKey(lastKnownRoomEvent.sessionId.value)
.build()
).also {
it.conversationTitle = roomName.takeIf { roomIsGroup }?.annotateForDebug(51)
it.isGroupConversation = roomIsGroup
it.addMessagesFromEvents(events, imageLoader)
}
val tickerText = if (roomIsGroup) { val tickerText = if (roomIsGroup) {
stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderDisambiguatedDisplayName, events.last().description) stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderDisambiguatedDisplayName, events.last().description)
@ -69,112 +67,28 @@ class RoomGroupMessageCreator @Inject constructor(
val lastMessageTimestamp = events.last().timestamp val lastMessageTimestamp = events.last().timestamp
val smartReplyErrors = events.filter { it.isSmartReplyError() } val smartReplyErrors = events.filter { it.isSmartReplyError() }
val messageCount = events.size - smartReplyErrors.size return notificationCreator.createMessagesListNotification(
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(
notificationCreator.createMessagesListNotification(
style,
RoomEventGroupInfo( RoomEventGroupInfo(
sessionId = currentUser.userId, sessionId = currentUser.userId,
roomId = roomId, roomId = roomId,
roomDisplayName = roomName, roomDisplayName = roomName,
isDirect = !roomIsGroup, isDirect = !roomIsGroup,
hasSmartReplyError = smartReplyErrors.isNotEmpty(), hasSmartReplyError = smartReplyErrors.isNotEmpty(),
shouldBing = meta.shouldBing, shouldBing = events.any { it.noisy },
customSound = events.last().soundName, customSound = events.last().soundName,
isUpdated = events.last().isUpdated, isUpdated = events.last().isUpdated,
), ),
threadId = lastKnownRoomEvent.threadId, threadId = lastKnownRoomEvent.threadId,
largeIcon = largeBitmap, largeIcon = largeBitmap,
lastMessageTimestamp, lastMessageTimestamp = lastMessageTimestamp,
tickerText tickerText = tickerText,
), currentUser = currentUser,
meta existingNotification = existingNotification,
imageLoader = imageLoader,
events = events,
) )
} }
private suspend fun NotificationCompat.MessagingStyle.addMessagesFromEvents(
events: List<NotifiableMessageEvent>,
imageLoader: ImageLoader,
) {
events.forEach { event ->
val senderPerson = if (event.outGoingMessage) {
null
} else {
Person.Builder()
.setName(event.senderDisambiguatedDisplayName?.annotateForDebug(70))
.setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath, imageLoader))
.setKey(event.senderId.value)
.build()
}
when {
event.isSmartReplyError() -> addMessage(
stringProvider.getString(R.string.notification_inline_reply_failed),
event.timestamp,
senderPerson
)
else -> {
val message = NotificationCompat.MessagingStyle.Message(
event.body?.annotateForDebug(71),
event.timestamp,
senderPerson
).also { message ->
event.imageUri?.let {
message.setData("image/", it)
}
}
addMessage(message)
}
}
}
}
private fun createRoomMessagesGroupSummaryLine(events: List<NotifiableMessageEvent>, roomName: String, roomIsDirect: Boolean): CharSequence {
return 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
)
}
}
}
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence {
return if (roomIsDirect) {
buildSpannedString {
event.senderDisambiguatedDisplayName?.let {
inSpans(StyleSpan(Typeface.BOLD)) {
append(it)
append(": ")
}
}
append(event.description)
}
} else {
buildSpannedString {
inSpans(StyleSpan(Typeface.BOLD)) {
append(roomName)
append(": ")
event.senderDisambiguatedDisplayName?.let {
append(it)
append(" ")
}
}
append(event.description)
}
}
}
private suspend fun getRoomBitmap( private suspend fun getRoomBitmap(
events: List<NotifiableMessageEvent>, events: List<NotifiableMessageEvent>,
imageLoader: ImageLoader, imageLoader: ImageLoader,
@ -184,5 +98,3 @@ class RoomGroupMessageCreator @Inject constructor(
?.let { bitmapLoader.getRoomBitmap(it, imageLoader) } ?.let { bitmapLoader.getRoomBitmap(it, imageLoader) }
} }
} }
private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed

View file

@ -18,6 +18,8 @@ package io.element.android.libraries.push.impl.notifications
import android.app.Notification import android.app.Notification
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
@ -25,30 +27,37 @@ import io.element.android.libraries.push.impl.notifications.factories.Notificati
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject import javax.inject.Inject
interface SummaryGroupMessageCreator {
fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): Notification
}
/** /**
* ======== Build summary notification ========= * ======== Build summary notification =========
* On Android 7.0 (API level 24) and higher, the system automatically builds a summary for * 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 * your group using snippets of text from each notification. The user can expand this
* notification to see each separate notification. * 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. * 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 * 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 * https://developer.android.com/training/notify-user/group
*/ */
class SummaryGroupMessageCreator @Inject constructor( @ContributesBinding(AppScope::class)
class DefaultSummaryGroupMessageCreator @Inject constructor(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val notificationCreator: NotificationCreator, private val notificationCreator: NotificationCreator,
) { ) : SummaryGroupMessageCreator {
fun createSummaryNotification( override fun createSummaryNotification(
currentUser: MatrixUser, currentUser: MatrixUser,
roomNotifications: List<RoomNotification.Message.Meta>, roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification.Append.Meta>, invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification.Append.Meta>, simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification.Append.Meta>, fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean useCompleteNotificationFormat: Boolean
): Notification { ): Notification {
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style -> val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->

View file

@ -36,10 +36,9 @@ import javax.inject.Inject
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class NotificationChannels @Inject constructor( class NotificationChannels @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val notificationManager: NotificationManagerCompat,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
) { ) {
private val notificationManager = NotificationManagerCompat.from(context)
init { init {
createNotificationChannels() createNotificationChannels()
} }

View file

@ -22,26 +22,80 @@ import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.MessagingStyle
import androidx.core.app.Person
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import coil.ImageLoader
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.NotificationConfig
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject import javax.inject.Inject
class NotificationCreator @Inject constructor( interface NotificationCreator {
/**
* Create a notification for a Room.
*/
suspend fun createMessagesListNotification(
roomInfo: RoomEventGroupInfo,
threadId: ThreadId?,
largeIcon: Bitmap?,
lastMessageTimestamp: Long,
tickerText: String,
currentUser: MatrixUser,
existingNotification: Notification?,
imageLoader: ImageLoader,
events: List<NotifiableMessageEvent>,
): Notification
fun createRoomInvitationNotification(
inviteNotifiableEvent: InviteNotifiableEvent
): Notification
fun createSimpleEventNotification(
simpleNotifiableEvent: SimpleNotifiableEvent,
): Notification
fun createFallbackNotification(
fallbackNotifiableEvent: FallbackNotifiableEvent,
): Notification
/**
* Create the summary notification.
*/
fun createSummaryListNotification(
currentUser: MatrixUser,
style: NotificationCompat.InboxStyle?,
compatSummary: String,
noisy: Boolean,
lastMessageTimestamp: Long
): Notification
fun createDiagnosticNotification(): Notification
}
@ContributesBinding(AppScope::class)
class DefaultNotificationCreator @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val notificationChannels: NotificationChannels, private val notificationChannels: NotificationChannels,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
@ -49,17 +103,23 @@ class NotificationCreator @Inject constructor(
private val pendingIntentFactory: PendingIntentFactory, private val pendingIntentFactory: PendingIntentFactory,
private val markAsReadActionFactory: MarkAsReadActionFactory, private val markAsReadActionFactory: MarkAsReadActionFactory,
private val quickReplyActionFactory: QuickReplyActionFactory, private val quickReplyActionFactory: QuickReplyActionFactory,
) { private val bitmapLoader: NotificationBitmapLoader,
private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
private val rejectInvitationActionFactory: RejectInvitationActionFactory
) : NotificationCreator {
/** /**
* Create a notification for a Room. * Create a notification for a Room.
*/ */
fun createMessagesListNotification( override suspend fun createMessagesListNotification(
messageStyle: NotificationCompat.MessagingStyle,
roomInfo: RoomEventGroupInfo, roomInfo: RoomEventGroupInfo,
threadId: ThreadId?, threadId: ThreadId?,
largeIcon: Bitmap?, largeIcon: Bitmap?,
lastMessageTimestamp: Long, lastMessageTimestamp: Long,
tickerText: String tickerText: String,
currentUser: MatrixUser,
existingNotification: Notification?,
imageLoader: ImageLoader,
events: List<NotifiableMessageEvent>,
): Notification { ): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Build the pending intent for when the notification is clicked // Build the pending intent for when the notification is clicked
@ -71,17 +131,39 @@ class NotificationCreator @Inject constructor(
val smallIcon = CommonDrawables.ic_notification_small val smallIcon = CommonDrawables.ic_notification_small
val channelId = notificationChannels.getChannelIdForMessage(roomInfo.shouldBing) val channelId = notificationChannels.getChannelIdForMessage(roomInfo.shouldBing)
return NotificationCompat.Builder(context, channelId) val builder = if (existingNotification != null) {
NotificationCompat.Builder(context, existingNotification)
} else {
NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(roomInfo.isUpdated)
// A category allows groups of notifications to be ranked and filtered per user or system settings.
// For example, alarm notifications should display before promo notifications, or message from known contact
// that can be displayed in not disturb mode if white listed (the later will need compat28.x)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
// ID of the corresponding shortcut, for conversation features under API 30+
.setShortcutId(roomInfo.roomId.value)
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID
.setGroup(roomInfo.sessionId.value)
// In order to avoid notification making sound twice (due to the summary notification)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
// Remove notification after opening it or using an action
.setAutoCancel(true)
}
val messagingStyle = existingNotification?.let {
MessagingStyle.extractMessagingStyleFromNotification(it)
} ?: messagingStyleFromCurrentUser(roomInfo.sessionId, currentUser, imageLoader, roomInfo.roomDisplayName, !roomInfo.isDirect)
messagingStyle.addMessagesFromEvents(events, imageLoader)
return builder
.setNumber(events.size)
.setOnlyAlertOnce(roomInfo.isUpdated) .setOnlyAlertOnce(roomInfo.isUpdated)
.setWhen(lastMessageTimestamp) .setWhen(lastMessageTimestamp)
// MESSAGING_STYLE sets title and content for API 16 and above devices. // MESSAGING_STYLE sets title and content for API 16 and above devices.
.setStyle(messageStyle) .setStyle(messagingStyle)
// A category allows groups of notifications to be ranked and filtered per user or system settings. // Not needed anymore?
// For example, alarm notifications should display before promo notifications, or message from known contact
// that can be displayed in not disturb mode if white listed (the later will need compat28.x)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
// ID of the corresponding shortcut, for conversation features under API 30+
.setShortcutId(roomInfo.roomId.value)
// Title for API < 16 devices. // Title for API < 16 devices.
.setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1)) .setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1))
// Content for API < 16 devices. // Content for API < 16 devices.
@ -90,15 +172,10 @@ class NotificationCreator @Inject constructor(
.setSubText( .setSubText(
stringProvider.getQuantityString( stringProvider.getQuantityString(
R.plurals.notification_new_messages_for_room, R.plurals.notification_new_messages_for_room,
messageStyle.messages.size, messagingStyle.messages.size,
messageStyle.messages.size messagingStyle.messages.size
).annotateForDebug(3) ).annotateForDebug(3)
) )
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID
.setGroup(roomInfo.sessionId.value)
// In order to avoid notification making sound twice (due to the summary notification)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon) .setSmallIcon(smallIcon)
// Set primary color (important for Wear 2.0 Notifications). // Set primary color (important for Wear 2.0 Notifications).
.setColor(accentColor) .setColor(accentColor)
@ -118,7 +195,8 @@ class NotificationCreator @Inject constructor(
} else { } else {
priority = NotificationCompat.PRIORITY_LOW priority = NotificationCompat.PRIORITY_LOW
} }
// Clear existing actions since we might be updating an existing notification
clearActions()
// Add actions and notification intents // Add actions and notification intents
// Mark room as read // Mark room as read
addAction(markAsReadActionFactory.create(roomInfo)) addAction(markAsReadActionFactory.create(roomInfo))
@ -134,11 +212,11 @@ class NotificationCreator @Inject constructor(
} }
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)) setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
} }
.setTicker(tickerText.annotateForDebug(4)) .setTicker(tickerText)
.build() .build()
} }
fun createRoomInvitationNotification( override fun createRoomInvitationNotification(
inviteNotifiableEvent: InviteNotifiableEvent inviteNotifiableEvent: InviteNotifiableEvent
): Notification { ): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
@ -152,10 +230,11 @@ class NotificationCreator @Inject constructor(
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon) .setSmallIcon(smallIcon)
.setColor(accentColor) .setColor(accentColor)
// TODO removed for now, will be added back later
// .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
// .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
.apply { .apply {
if (NotificationConfig.SUPPORT_JOIN_DECLINE_INVITE) {
addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
}
// Build the pending intent for when the notification is clicked // Build the pending intent for when the notification is clicked
setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId)) setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId))
@ -182,7 +261,7 @@ class NotificationCreator @Inject constructor(
.build() .build()
} }
fun createSimpleEventNotification( override fun createSimpleEventNotification(
simpleNotifiableEvent: SimpleNotifiableEvent, simpleNotifiableEvent: SimpleNotifiableEvent,
): Notification { ): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
@ -212,12 +291,11 @@ class NotificationCreator @Inject constructor(
} else { } else {
priority = NotificationCompat.PRIORITY_LOW priority = NotificationCompat.PRIORITY_LOW
} }
setAutoCancel(true)
} }
.build() .build()
} }
fun createFallbackNotification( override fun createFallbackNotification(
fallbackNotifiableEvent: FallbackNotifiableEvent, fallbackNotifiableEvent: FallbackNotifiableEvent,
): Notification { ): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
@ -244,17 +322,14 @@ class NotificationCreator @Inject constructor(
fallbackNotifiableEvent.eventId fallbackNotifiableEvent.eventId
) )
) )
.apply { .setPriority(NotificationCompat.PRIORITY_LOW)
priority = NotificationCompat.PRIORITY_LOW
setAutoCancel(true)
}
.build() .build()
} }
/** /**
* Create the summary notification. * Create the summary notification.
*/ */
fun createSummaryListNotification( override fun createSummaryListNotification(
currentUser: MatrixUser, currentUser: MatrixUser,
style: NotificationCompat.InboxStyle?, style: NotificationCompat.InboxStyle?,
compatSummary: String, compatSummary: String,
@ -298,7 +373,7 @@ class NotificationCreator @Inject constructor(
.build() .build()
} }
fun createDiagnosticNotification(): Notification { override fun createDiagnosticNotification(): Notification {
val intent = pendingIntentFactory.createTestPendingIntent() val intent = pendingIntentFactory.createTestPendingIntent()
return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest()) return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest())
.setContentTitle(buildMeta.applicationName) .setContentTitle(buildMeta.applicationName)
@ -314,6 +389,61 @@ class NotificationCreator @Inject constructor(
.build() .build()
} }
private suspend fun MessagingStyle.addMessagesFromEvents(
events: List<NotifiableMessageEvent>,
imageLoader: ImageLoader,
) {
events.forEach { event ->
val senderPerson = if (event.outGoingMessage) {
null
} else {
Person.Builder()
.setName(event.senderDisambiguatedDisplayName?.annotateForDebug(70))
.setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath, imageLoader))
.setKey(event.senderId.value)
.build()
}
when {
event.isSmartReplyError() -> addMessage(
stringProvider.getString(R.string.notification_inline_reply_failed),
event.timestamp,
senderPerson
)
else -> {
val message = MessagingStyle.Message(
event.body?.annotateForDebug(71),
event.timestamp,
senderPerson
).also { message ->
event.imageUri?.let {
message.setData("image/", it)
}
}
addMessage(message)
}
}
}
}
private suspend fun messagingStyleFromCurrentUser(
sessionId: SessionId,
user: MatrixUser,
imageLoader: ImageLoader,
roomName: String,
roomIsGroup: Boolean
): MessagingStyle {
return MessagingStyle(
Person.Builder()
.setName(user.displayName?.annotateForDebug(50))
.setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader))
.setKey(sessionId.value)
.build()
).also {
it.conversationTitle = roomName.takeIf { roomIsGroup }
it.isGroupConversation = roomIsGroup
}
}
private fun getBitmap(@DrawableRes drawableRes: Int): Bitmap? { private fun getBitmap(@DrawableRes drawableRes: Int): Bitmap? {
val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null
val canvas = Canvas() val canvas = Canvas()
@ -324,3 +454,5 @@ class NotificationCreator @Inject constructor(
return bitmap return bitmap
} }
} }
fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed

View file

@ -46,21 +46,20 @@ class QuickReplyActionFactory @Inject constructor(
if (!NotificationConfig.SUPPORT_QUICK_REPLY_ACTION) return null if (!NotificationConfig.SUPPORT_QUICK_REPLY_ACTION) return null
val sessionId = roomInfo.sessionId val sessionId = roomInfo.sessionId
val roomId = roomInfo.roomId val roomId = roomInfo.roomId
return buildQuickReplyIntent(sessionId, roomId, threadId)?.let { replyPendingIntent -> val replyPendingIntent = buildQuickReplyIntent(sessionId, roomId, threadId)
val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
.setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply)) .setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply))
.build() .build()
NotificationCompat.Action.Builder( return NotificationCompat.Action.Builder(
R.drawable.vector_notification_quick_reply, R.drawable.vector_notification_quick_reply,
stringProvider.getString(R.string.notification_room_action_quick_reply), stringProvider.getString(R.string.notification_room_action_quick_reply),
replyPendingIntent replyPendingIntent
) )
.addRemoteInput(remoteInput) .addRemoteInput(remoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false) .setShowsUserInterface(false)
.build() .build()
}
} }
/* /*
@ -74,30 +73,26 @@ class QuickReplyActionFactory @Inject constructor(
sessionId: SessionId, sessionId: SessionId,
roomId: RoomId, roomId: RoomId,
threadId: ThreadId?, threadId: ThreadId?,
): PendingIntent? { ): PendingIntent {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val intent = Intent(context, NotificationBroadcastReceiver::class.java)
val intent = Intent(context, NotificationBroadcastReceiver::class.java) intent.action = actionIds.smartReply
intent.action = actionIds.smartReply intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty())
intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty()) intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value) intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value) threadId?.let {
threadId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value)
}
PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
// PendingIntents attached to actions with remote inputs must be mutable
PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
)
} else {
null
} }
return PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
// PendingIntents attached to actions with remote inputs must be mutable
PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
)
} }
} }

View file

@ -20,6 +20,8 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
interface OnNotifiableEventReceived { interface OnNotifiableEventReceived {
@ -29,8 +31,11 @@ interface OnNotifiableEventReceived {
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultOnNotifiableEventReceived @Inject constructor( class DefaultOnNotifiableEventReceived @Inject constructor(
private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
private val coroutineScope: CoroutineScope,
) : OnNotifiableEventReceived { ) : OnNotifiableEventReceived {
override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent) coroutineScope.launch {
defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
}
} }
} }

View file

@ -0,0 +1,175 @@
/*
* Copyright (c) 2024 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 io.element.android.libraries.push.impl.notifications
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationManagerCompat
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultActiveNotificationsProviderTest {
@Test
fun `getAllNotifications with no active notifications returns empty list`() {
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = emptyList())
val emptyNotifications = activeNotificationsProvider.getAllNotifications()
assertThat(emptyNotifications).isEmpty()
}
@Test
fun `getAllNotifications with active notifications returns all`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
)
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
val result = activeNotificationsProvider.getAllNotifications()
assertThat(result).hasSize(3)
}
@Test
fun `getNotificationsForSession returns only notifications for that session id`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value),
aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value),
)
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
assertThat(activeNotificationsProvider.getNotificationsForSession(A_SESSION_ID)).hasSize(1)
assertThat(activeNotificationsProvider.getNotificationsForSession(A_SESSION_ID_2)).hasSize(2)
}
@Test
fun `getMembershipNotificationsForSession returns only membership notifications for that session id`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value,),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value),
aStatusBarNotification(
id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2),
groupId = A_SESSION_ID_2.value,
tag = A_ROOM_ID.value,
),
)
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
assertThat(activeNotificationsProvider.getMembershipNotificationForSession(A_SESSION_ID)).isEmpty()
assertThat(activeNotificationsProvider.getMembershipNotificationForSession(A_SESSION_ID_2)).hasSize(1)
}
@Test
fun `getMessageNotificationsForRoom returns only message notifications for those session and room ids`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID),
groupId = A_SESSION_ID.value,
tag = A_ROOM_ID.value
),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value, tag = A_ROOM_ID.value),
aStatusBarNotification(
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID_2),
groupId = A_SESSION_ID_2.value,
tag = A_ROOM_ID.value
),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value, tag = A_ROOM_ID.value),
aStatusBarNotification(
id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2),
groupId = A_SESSION_ID_2.value,
tag = A_ROOM_ID.value
),
)
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID)).hasSize(1)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2)).isEmpty()
}
@Test
fun `getMembershipNotificationsForRoom returns only membership notifications for those session and room ids`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID),
groupId = A_SESSION_ID.value,
tag = A_ROOM_ID.value
),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value, tag = A_ROOM_ID.value),
aStatusBarNotification(
id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2),
groupId = A_SESSION_ID_2.value,
tag = A_ROOM_ID_2.value
),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value, tag = A_ROOM_ID.value),
aStatusBarNotification(
id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2),
groupId = A_SESSION_ID_2.value,
tag = A_ROOM_ID_2.value
),
)
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
assertThat(activeNotificationsProvider.getMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID)).isEmpty()
assertThat(activeNotificationsProvider.getMembershipNotificationForRoom(A_SESSION_ID_2, A_ROOM_ID_2)).hasSize(2)
}
@Test
fun `getSummaryNotification returns only the summary notification for that session id if it exists`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value),
)
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
assertThat(activeNotificationsProvider.getSummaryNotification(A_SESSION_ID)).isNotNull()
assertThat(activeNotificationsProvider.getSummaryNotification(A_SESSION_ID_2)).isNull()
}
private fun aStatusBarNotification(id: Int, groupId: String, tag: String? = null) = mockk<StatusBarNotification> {
every { this@mockk.id } returns id
every { this@mockk.groupKey } returns groupId
every { this@mockk.tag } returns tag
}
private fun createActiveNotificationsProvider(
activeNotifications: List<StatusBarNotification> = emptyList(),
): DefaultActiveNotificationsProvider {
val notificationManager = mockk<NotificationManagerCompat> {
every { this@mockk.activeNotifications } returns activeNotifications
}
return DefaultActiveNotificationsProvider(
notificationManager = notificationManager,
notificationIdProvider = NotificationIdProvider(),
)
}
}

View file

@ -16,25 +16,36 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import androidx.core.app.NotificationManagerCompat
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SPACE_ID import io.element.android.libraries.matrix.test.A_SPACE_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoaderHolder import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoaderHolder
import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationCreator import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.MockkRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.MockkSummaryGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import io.element.android.services.appnavstate.test.aNavigationState import io.element.android.services.appnavstate.test.aNavigationState
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -58,16 +69,29 @@ class DefaultNotificationDrawerManagerTest {
@Test @Test
fun `cover all APIs`() = runTest { fun `cover all APIs`() = runTest {
// For now just call all the API. Later, add more valuable tests. // For now just call all the API. Later, add more valuable tests.
val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager() val matrixUser = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice", avatarUrl = "mxc://data")
defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID, doRender = true) val mockRoomGroupMessageCreator = FakeRoomGroupMessageCreator(
defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID, doRender = false) createRoomMessageResult = lambdaRecorder { user, _, roomId, _, existingNotification, ->
defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID, doRender = true) assertThat(user).isEqualTo(matrixUser)
defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID, doRender = false) assertThat(roomId).isEqualTo(A_ROOM_ID)
defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID, doRender = true) assertThat(existingNotification).isNull()
defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID, doRender = false) Notification()
}
)
val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator()
val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager(
roomGroupMessageCreator = mockRoomGroupMessageCreator,
summaryGroupMessageCreator = summaryGroupMessageCreator,
)
defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID)
defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID)
defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID)
defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID)
defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID)
defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID)
defaultNotificationDrawerManager.clearMembershipNotificationForSession(A_SESSION_ID) defaultNotificationDrawerManager.clearMembershipNotificationForSession(A_SESSION_ID)
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID, doRender = true) defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID)
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID, doRender = false) defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID)
defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent())
// Add the same Event again (will be ignored) // Add the same Event again (will be ignored)
defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent())
@ -103,33 +127,93 @@ class DefaultNotificationDrawerManagerTest {
defaultNotificationDrawerManager.destroy() defaultNotificationDrawerManager.destroy()
} }
@Test
fun `when MatrixClient has no cached user name a fallback one is used to render the notification`() = runTest {
val matrixClient = FakeMatrixClient(userDisplayName = null)
val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
val messageCreator = FakeRoomGroupMessageCreator()
val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager(
matrixClientProvider = matrixClientProvider,
roomGroupMessageCreator = messageCreator,
)
// Gets a display name from MatrixClient.getUserProfile
matrixClient.givenGetProfileResult(A_SESSION_ID, Result.success(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")))
defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent())
// Uses the user id as a fallback value since display name is blank
matrixClient.givenGetProfileResult(A_SESSION_ID, Result.success(aMatrixUser(id = A_SESSION_ID.value, displayName = "")))
defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent())
// Uses the user id as a fallback value since the result fails
matrixClient.givenGetProfileResult(A_SESSION_ID, Result.failure(IllegalStateException("Failed to get profile")))
defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent())
messageCreator.createRoomMessageResult.assertions()
.isCalledExactly(3)
.withSequence(
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")), any(), any(), any(), any()),
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value)), any(), any(), any(), any()),
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value, avatarUrl = AN_AVATAR_URL)), any(), any(), any(), any()),
)
defaultNotificationDrawerManager.destroy()
}
@Test
fun `clearSummaryNotificationIfNeeded will run after clearing all other notifications`() = runTest {
val notificationManager = mockk<NotificationManagerCompat> {
every { cancel(any(), any()) } returns Unit
}
val summaryId = NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)
val activeNotificationsProvider = FakeActiveNotificationsProvider(
mutableListOf(
mockk {
every { id } returns summaryId
}
)
)
val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager(
notificationManager = notificationManager,
activeNotificationsProvider = activeNotificationsProvider,
)
// Ask to clear all existing message notifications. Since only the summary notification is left, it should be cleared
defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID)
// Verify we asked to cancel the notification with summaryId
verify { notificationManager.cancel(null, summaryId) }
defaultNotificationDrawerManager.destroy()
}
private fun TestScope.createDefaultNotificationDrawerManager( private fun TestScope.createDefaultNotificationDrawerManager(
notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(RuntimeEnvironment.getApplication()),
appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(),
initialData: List<NotifiableEvent> = emptyList() roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(),
summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(),
activeNotificationsProvider: FakeActiveNotificationsProvider = FakeActiveNotificationsProvider(),
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
): DefaultNotificationDrawerManager { ): DefaultNotificationDrawerManager {
val context = RuntimeEnvironment.getApplication() val context = RuntimeEnvironment.getApplication()
return DefaultNotificationDrawerManager( return DefaultNotificationDrawerManager(
notifiableEventProcessor = NotifiableEventProcessor( notificationManager = notificationManager,
outdatedDetector = OutdatedEventDetector(),
appNavigationStateService = appNavigationStateService
),
notificationRenderer = NotificationRenderer( notificationRenderer = NotificationRenderer(
notificationIdProvider = NotificationIdProvider(), notificationIdProvider = NotificationIdProvider(),
notificationDisplayer = NotificationDisplayer(context), notificationDisplayer = DefaultNotificationDisplayer(context, NotificationManagerCompat.from(context)),
notificationFactory = NotificationFactory( notificationDataFactory = DefaultNotificationDataFactory(
notificationCreator = MockkNotificationCreator().instance, notificationCreator = FakeNotificationCreator(),
roomGroupMessageCreator = MockkRoomGroupMessageCreator().instance, roomGroupMessageCreator = roomGroupMessageCreator,
summaryGroupMessageCreator = MockkSummaryGroupMessageCreator().instance, summaryGroupMessageCreator = summaryGroupMessageCreator,
) activeNotificationsProvider = activeNotificationsProvider,
stringProvider = FakeStringProvider(),
),
), ),
notificationEventPersistence = InMemoryNotificationEventPersistence(initialData = initialData), notificationIdProvider = NotificationIdProvider(),
filteredEventDetector = FilteredEventDetector(),
appNavigationStateService = appNavigationStateService, appNavigationStateService = appNavigationStateService,
coroutineScope = this, coroutineScope = this,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), matrixClientProvider = matrixClientProvider,
buildMeta = aBuildMeta(),
matrixClientProvider = FakeMatrixClientProvider(),
imageLoaderHolder = FakeImageLoaderHolder(), imageLoaderHolder = FakeImageLoaderHolder(),
activeNotificationsProvider = activeNotificationsProvider,
) )
} }
} }

View file

@ -1,64 +0,0 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.cache.CircularCache
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class DefaultNotificationEventPersistenceTest {
@Test
fun `loadEvents should return empty NotificationEventQueue`() {
val sut = createDefaultNotificationEventPersistence()
val result = sut.loadEvents(
factory = { rawEvents ->
NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25))
}
)
assertThat(result.isEmpty()).isTrue()
}
@Test
fun `after persisting NotificationEventQueue, loadEvents should return non-empty NotificationEventQueue`() {
val sut = createDefaultNotificationEventPersistence()
val notificationEventQueue = NotificationEventQueue(mutableListOf(), seenEventIds = CircularCache.create(cacheSize = 25))
// First persist an empty queue
sut.persistEvents(notificationEventQueue)
// Add an event
notificationEventQueue.add(aSimpleNotifiableEvent())
// Persist
// Note: is cannot work because AndroidKeyStore is not available. But we check that the code does
// not crash.
sut.persistEvents(notificationEventQueue)
sut.loadEvents(
factory = { rawEvents ->
NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25))
}
)
// assertThat(result.isEmpty()).isFalse()
}
private fun createDefaultNotificationEventPersistence(): DefaultNotificationEventPersistence {
val context = RuntimeEnvironment.getApplication()
return DefaultNotificationEventPersistence(context)
}
}

View file

@ -18,7 +18,7 @@ package io.element.android.libraries.push.impl.notifications
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import coil.annotation.ExperimentalCoilApi import androidx.core.app.NotificationCompat
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -42,7 +42,7 @@ private const val A_USER_AVATAR_1 = "mxc://userAvatar1"
private const val A_USER_AVATAR_2 = "mxc://userAvatar2" private const val A_USER_AVATAR_2 = "mxc://userAvatar2"
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class RoomGroupMessageCreatorTest { class DefaultRoomGroupMessageCreatorTest {
@Test @Test
fun `test createRoomMessage with one Event`() = runTest { fun `test createRoomMessage with one Event`() = runTest {
val sut = createRoomGroupMessageCreator() val sut = createRoomGroupMessageCreator()
@ -56,19 +56,12 @@ class RoomGroupMessageCreatorTest {
), ),
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(), imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
) )
val resultMetaWithoutFormatting = result.meta.copy( assertThat(result.number).isEqualTo(1)
summaryLine = result.meta.summaryLine.toString() @Suppress("DEPRECATION")
) assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_LOW)
assertThat(resultMetaWithoutFormatting).isEqualTo( assertThat(result.`when`).isEqualTo(A_TIMESTAMP)
RoomNotification.Message.Meta(
roomId = A_ROOM_ID,
summaryLine = "room-name: sender-name message-body",
messageCount = 1,
latestTimestamp = A_TIMESTAMP,
shouldBing = false,
)
)
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
} }
@ -85,19 +78,10 @@ class RoomGroupMessageCreatorTest {
), ),
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(), imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
) )
val resultMetaWithoutFormatting = result.meta.copy( @Suppress("DEPRECATION")
summaryLine = result.meta.summaryLine.toString() assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT)
)
assertThat(resultMetaWithoutFormatting).isEqualTo(
RoomNotification.Message.Meta(
roomId = A_ROOM_ID,
summaryLine = "room-name: sender-name message-body",
messageCount = 1,
latestTimestamp = A_TIMESTAMP,
shouldBing = true,
)
)
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
} }
@ -115,7 +99,6 @@ class RoomGroupMessageCreatorTest {
) )
} }
@OptIn(ExperimentalCoilApi::class)
@Test @Test
fun `test createRoomMessage with room avatar and sender avatar android P`() = runTest { fun `test createRoomMessage with room avatar and sender avatar android P`() = runTest {
`test createRoomMessage with room avatar and sender avatar`( `test createRoomMessage with room avatar and sender avatar`(
@ -138,7 +121,6 @@ class RoomGroupMessageCreatorTest {
) )
} }
@OptIn(ExperimentalCoilApi::class)
private fun `test createRoomMessage with room avatar and sender avatar`( private fun `test createRoomMessage with room avatar and sender avatar`(
api: Int, api: Int,
expectedCoilRequests: List<Any>, expectedCoilRequests: List<Any>,
@ -160,20 +142,10 @@ class RoomGroupMessageCreatorTest {
), ),
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(), imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
) )
val resultMetaWithoutFormatting = result.meta.copy( assertThat(result.number).isEqualTo(1)
summaryLine = result.meta.summaryLine.toString() assertThat(fakeImageLoader.getCoilRequests()).containsExactlyElementsIn(expectedCoilRequests)
)
assertThat(resultMetaWithoutFormatting).isEqualTo(
RoomNotification.Message.Meta(
roomId = A_ROOM_ID,
summaryLine = "room-name: sender-name message-body",
messageCount = 1,
latestTimestamp = A_TIMESTAMP,
shouldBing = false,
)
)
assertThat(fakeImageLoader.getCoilRequests()).isEqualTo(expectedCoilRequests)
} }
@Test @Test
@ -188,19 +160,10 @@ class RoomGroupMessageCreatorTest {
), ),
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(), imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
) )
val resultMetaWithoutFormatting = result.meta.copy( assertThat(result.number).isEqualTo(2)
summaryLine = result.meta.summaryLine.toString() assertThat(result.`when`).isEqualTo(A_TIMESTAMP + 10)
)
assertThat(resultMetaWithoutFormatting).isEqualTo(
RoomNotification.Message.Meta(
roomId = A_ROOM_ID,
summaryLine = "room-name: 2 messages",
messageCount = 2,
latestTimestamp = A_TIMESTAMP + 10,
shouldBing = false,
)
)
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
} }
@ -218,19 +181,9 @@ class RoomGroupMessageCreatorTest {
), ),
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(), imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
) )
val resultMetaWithoutFormatting = result.meta.copy( assertThat(result.actions).isNull()
summaryLine = result.meta.summaryLine.toString()
)
assertThat(resultMetaWithoutFormatting).isEqualTo(
RoomNotification.Message.Meta(
roomId = A_ROOM_ID,
summaryLine = "room-name: sender-name message-body",
messageCount = 0,
latestTimestamp = A_TIMESTAMP,
shouldBing = false,
)
)
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
} }
@ -247,19 +200,10 @@ class RoomGroupMessageCreatorTest {
), ),
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(), imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
) )
val resultMetaWithoutFormatting = result.meta.copy( assertThat(result.number).isEqualTo(1)
summaryLine = result.meta.summaryLine.toString() assertThat(result.`when`).isEqualTo(A_TIMESTAMP)
)
assertThat(resultMetaWithoutFormatting).isEqualTo(
RoomNotification.Message.Meta(
roomId = A_ROOM_ID,
summaryLine = "sender-name: message-body",
messageCount = 1,
latestTimestamp = A_TIMESTAMP,
shouldBing = false,
)
)
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
} }
} }
@ -268,12 +212,13 @@ fun createRoomGroupMessageCreator(
sdkIntProvider: BuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.O), sdkIntProvider: BuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.O),
): RoomGroupMessageCreator { ): RoomGroupMessageCreator {
val context = RuntimeEnvironment.getApplication() as Context val context = RuntimeEnvironment.getApplication() as Context
return RoomGroupMessageCreator( val bitmapLoader = NotificationBitmapLoader(
notificationCreator = createNotificationCreator(), context = RuntimeEnvironment.getApplication(),
bitmapLoader = NotificationBitmapLoader( sdkIntProvider = sdkIntProvider,
context = RuntimeEnvironment.getApplication(), )
sdkIntProvider = sdkIntProvider, return DefaultRoomGroupMessageCreator(
), notificationCreator = createNotificationCreator(bitmapLoader = bitmapLoader),
bitmapLoader = bitmapLoader,
stringProvider = AndroidStringProvider(context.resources) stringProvider = AndroidStringProvider(context.resources)
) )
} }

View file

@ -0,0 +1,106 @@
/*
* Copyright (c) 2024 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 io.element.android.libraries.push.impl.notifications
import android.app.Notification
import androidx.core.app.NotificationCompat
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.nonNull
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultSummaryGroupMessageCreatorTest {
@Test
fun `process notifications with complete format`() = runTest {
val notificationCreator = FakeNotificationCreator()
val summaryCreator = DefaultSummaryGroupMessageCreator(
stringProvider = FakeStringProvider(),
notificationCreator = notificationCreator,
)
val result = summaryCreator.createSummaryNotification(
currentUser = aMatrixUser(),
roomNotifications = listOf(
RoomNotification(
notification = Notification(),
roomId = A_ROOM_ID,
summaryLine = "",
messageCount = 1,
latestTimestamp = A_FAKE_TIMESTAMP + 10,
shouldBing = true,
)
),
invitationNotifications = emptyList(),
simpleNotifications = emptyList(),
fallbackNotifications = emptyList(),
useCompleteNotificationFormat = true,
)
notificationCreator.createSummaryListNotificationResult.assertions()
.isCalledOnce()
.with(any(), nonNull(), any(), any(), any())
// Set from the events included
@Suppress("DEPRECATION")
assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT)
}
@Test
fun `process notifications without complete format`() = runTest {
val notificationCreator = FakeNotificationCreator()
val summaryCreator = DefaultSummaryGroupMessageCreator(
stringProvider = FakeStringProvider(),
notificationCreator = notificationCreator,
)
val result = summaryCreator.createSummaryNotification(
currentUser = aMatrixUser(),
roomNotifications = listOf(
RoomNotification(
notification = Notification(),
roomId = A_ROOM_ID,
summaryLine = "",
messageCount = 1,
latestTimestamp = A_FAKE_TIMESTAMP + 10,
shouldBing = true,
)
),
invitationNotifications = emptyList(),
simpleNotifications = emptyList(),
fallbackNotifications = emptyList(),
useCompleteNotificationFormat = false,
)
notificationCreator.createSummaryListNotificationResult.assertions()
.isCalledOnce()
.with(any(), value(null), any(), any(), any())
// Set from the events included
@Suppress("DEPRECATION")
assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT)
}
}

View file

@ -1,33 +0,0 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
class InMemoryNotificationEventPersistence(
initialData: List<NotifiableEvent> = emptyList()
) : NotificationEventPersistence {
private var data: List<NotifiableEvent> = initialData
override fun loadEvents(factory: (List<NotifiableEvent>) -> NotificationEventQueue): NotificationEventQueue {
return factory(data)
}
override fun persistEvents(queuedEvents: NotificationEventQueue) {
data = queuedEvents.rawEvents()
}
}

View file

@ -1,220 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SPACE_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.push.impl.notifications.fake.MockkOutdatedEventDetector
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import io.element.android.services.appnavstate.test.aNavigationState
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Test
private val NOT_VIEWING_A_ROOM = aNavigationState()
private val VIEWING_A_ROOM = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID)
private val VIEWING_A_THREAD = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID)
class NotifiableEventProcessorTest {
private val mockkOutdatedDetector = MockkOutdatedEventDetector()
@Test
fun `given simple events when processing then keep simple events`() {
val events = listOf(
aSimpleNotifiableEvent(eventId = AN_EVENT_ID),
aSimpleNotifiableEvent(eventId = AN_EVENT_ID_2)
)
val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
val result = eventProcessor.process(events, renderedEvents = emptyList())
assertThat(result).isEqualTo(
listOfProcessedEvents(
ProcessedEvent.Type.KEEP to events[0],
ProcessedEvent.Type.KEEP to events[1]
)
)
}
@Test
fun `given redacted simple event when processing then remove redaction event`() {
val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = EventType.REDACTION))
val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
val result = eventProcessor.process(events, renderedEvents = emptyList())
assertThat(result).isEqualTo(
listOfProcessedEvents(
ProcessedEvent.Type.REMOVE to events[0]
)
)
}
@Test
fun `given invites are not auto accepted when processing then keep invitation events`() {
val events = listOf(
anInviteNotifiableEvent(roomId = A_ROOM_ID),
anInviteNotifiableEvent(roomId = A_ROOM_ID_2)
)
val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
val result = eventProcessor.process(events, renderedEvents = emptyList())
assertThat(result).isEqualTo(
listOfProcessedEvents(
ProcessedEvent.Type.KEEP to events[0],
ProcessedEvent.Type.KEEP to events[1]
)
)
}
@Test
fun `given out of date message event when processing then removes message event`() {
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID))
mockkOutdatedDetector.givenEventIsOutOfDate(events[0])
val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
val result = eventProcessor.process(events, renderedEvents = emptyList())
assertThat(result).isEqualTo(
listOfProcessedEvents(
ProcessedEvent.Type.REMOVE to events[0],
)
)
}
@Test
fun `given in date message event when processing then keep message event`() {
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID))
mockkOutdatedDetector.givenEventIsInDate(events[0])
val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
val result = eventProcessor.process(events, renderedEvents = emptyList())
assertThat(result).isEqualTo(
listOfProcessedEvents(
ProcessedEvent.Type.KEEP to events[0],
)
)
}
@Test
fun `given viewing the same room main timeline when processing main timeline message event then removes message`() {
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null))
events.forEach { mockkOutdatedDetector.givenEventIsOutOfDate(it) }
val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM)
val result = eventProcessor.process(events, renderedEvents = emptyList())
assertThat(result).isEqualTo(
listOfProcessedEvents(
ProcessedEvent.Type.REMOVE to events[0],
)
)
}
@Test
fun `given viewing the same thread timeline when processing thread message event then removes message`() {
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID))
events.forEach { mockkOutdatedDetector.givenEventIsOutOfDate(it) }
val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD)
val result = eventProcessor.process(events, renderedEvents = emptyList())
assertThat(result).isEqualTo(
listOfProcessedEvents(
ProcessedEvent.Type.REMOVE to events[0],
)
)
}
@Test
fun `given viewing main timeline of the same room when processing thread timeline message event then keep message`() {
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID))
mockkOutdatedDetector.givenEventIsInDate(events[0])
val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM)
val result = eventProcessor.process(events, renderedEvents = emptyList())
assertThat(result).isEqualTo(
listOfProcessedEvents(
ProcessedEvent.Type.KEEP to events[0],
)
)
}
@Test
fun `given viewing thread timeline of the same room when processing main timeline message event then keep message`() {
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID))
mockkOutdatedDetector.givenEventIsInDate(events[0])
val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD)
val result = eventProcessor.process(events, renderedEvents = emptyList())
assertThat(result).isEqualTo(
listOfProcessedEvents(
ProcessedEvent.Type.KEEP to events[0],
)
)
}
@Test
fun `given events are different to rendered events when processing then removes difference`() {
val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID))
val renderedEvents = listOf<ProcessedEvent<NotifiableEvent>>(
ProcessedEvent(ProcessedEvent.Type.KEEP, events[0]),
ProcessedEvent(ProcessedEvent.Type.KEEP, anInviteNotifiableEvent(eventId = AN_EVENT_ID_2))
)
val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
val result = eventProcessor.process(events, renderedEvents = renderedEvents)
assertThat(result).isEqualTo(
listOfProcessedEvents(
ProcessedEvent.Type.REMOVE to renderedEvents[1].event,
ProcessedEvent.Type.KEEP to renderedEvents[0].event
)
)
}
private fun listOfProcessedEvents(vararg event: Pair<ProcessedEvent.Type, NotifiableEvent>) = event.map {
ProcessedEvent(it.first, it.second)
}
private fun createProcessor(
isInForeground: Boolean = false,
navigationState: NavigationState
): NotifiableEventProcessor {
return NotifiableEventProcessor(
outdatedDetector = mockkOutdatedDetector.instance,
appNavigationStateService = FakeAppNavigationStateService(MutableStateFlow(AppNavigationState(navigationState, isInForeground))),
)
}
}

View file

@ -0,0 +1,186 @@
/*
* 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 io.element.android.libraries.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
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)
@RunWith(RobolectricTestRunner::class)
class NotificationDataFactoryTest {
private val notificationCreator = FakeNotificationCreator()
private val fakeRoomGroupMessageCreator = FakeRoomGroupMessageCreator()
private val fakeSummaryGroupMessageCreator = FakeSummaryGroupMessageCreator()
private val activeNotificationsProvider = FakeActiveNotificationsProvider()
private val notificationDataFactory = DefaultNotificationDataFactory(
notificationCreator = notificationCreator,
roomGroupMessageCreator = fakeRoomGroupMessageCreator,
summaryGroupMessageCreator = fakeSummaryGroupMessageCreator,
activeNotificationsProvider = activeNotificationsProvider,
stringProvider = FakeStringProvider(),
)
@Test
fun `given a room invitation when mapping to notification then it's added`() = testWith(notificationDataFactory) {
val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(AN_INVITATION_EVENT)
val roomInvitation = listOf(AN_INVITATION_EVENT)
val result = toNotifications(roomInvitation)
assertThat(result).isEqualTo(
listOf(
OneShotNotification(
notification = expectedNotification,
key = A_ROOM_ID.value,
summaryLine = AN_INVITATION_EVENT.description,
isNoisy = AN_INVITATION_EVENT.noisy,
timestamp = AN_INVITATION_EVENT.timestamp
)
)
)
}
@Test
fun `given a simple event when mapping to notification then it's added`() = testWith(notificationDataFactory) {
val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(AN_INVITATION_EVENT)
val roomInvitation = listOf(A_SIMPLE_EVENT)
val result = toNotifications(roomInvitation)
assertThat(result).isEqualTo(
listOf(
OneShotNotification(
notification = expectedNotification,
key = AN_EVENT_ID.value,
summaryLine = A_SIMPLE_EVENT.description,
isNoisy = A_SIMPLE_EVENT.noisy,
timestamp = AN_INVITATION_EVENT.timestamp
)
)
)
}
@Test
fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationDataFactory) {
val events = listOf(A_MESSAGE_EVENT)
val expectedNotification = RoomNotification(
notification = fakeRoomGroupMessageCreator.createRoomMessage(
MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
events,
A_ROOM_ID,
FakeImageLoader().getImageLoader(),
null,
),
roomId = A_ROOM_ID,
summaryLine = "room-name: sender-name message-body",
messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy }
)
val roomWithMessage = listOf(A_MESSAGE_EVENT)
val fakeImageLoader = FakeImageLoader()
val result = toNotifications(
messages = roomWithMessage,
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
imageLoader = fakeImageLoader.getImageLoader(),
)
assertThat(result.size).isEqualTo(1)
assertThat(result.first().isDataEqualTo(expectedNotification)).isTrue()
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
}
@Test
fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationDataFactory) {
val redactedRoom = listOf(A_MESSAGE_EVENT.copy(isRedacted = true))
val fakeImageLoader = FakeImageLoader()
val result = toNotifications(
messages = redactedRoom,
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
imageLoader = fakeImageLoader.getImageLoader(),
)
assertThat(result).isEmpty()
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
}
@Test
fun `given a room with redacted and non redacted message events when mapping to notification then redacted events are removed`() = testWith(
notificationDataFactory
) {
val roomWithRedactedMessage = listOf(
A_MESSAGE_EVENT.copy(isRedacted = true),
A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted")),
)
val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted")))
val expectedNotification = RoomNotification(
notification = fakeRoomGroupMessageCreator.createRoomMessage(
MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
withRedactedRemoved,
A_ROOM_ID,
FakeImageLoader().getImageLoader(),
null,
),
roomId = A_ROOM_ID,
summaryLine = "room-name: sender-name message-body",
messageCount = withRedactedRemoved.size,
latestTimestamp = withRedactedRemoved.maxOf { it.timestamp },
shouldBing = withRedactedRemoved.any { it.noisy }
)
val fakeImageLoader = FakeImageLoader()
val result = toNotifications(
messages = roomWithRedactedMessage,
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
imageLoader = fakeImageLoader.getImageLoader(),
)
assertThat(result.size).isEqualTo(1)
assertThat(result.first().isDataEqualTo(expectedNotification)).isTrue()
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
}
}
fun <T> testWith(receiver: T, block: suspend T.() -> Unit) {
runTest {
receiver.block()
}
}

View file

@ -1,230 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.cache.CircularCache
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import org.junit.Test
class NotificationEventQueueTest {
private val seenIdsCache = CircularCache.create<EventId>(5)
@Test
fun `given events when redacting some then marks matching event ids as redacted`() {
val queue = givenQueue(
listOf(
aSimpleNotifiableEvent(eventId = EventId("\$redacted-id-1")),
aNotifiableMessageEvent(eventId = EventId("\$redacted-id-2")),
anInviteNotifiableEvent(eventId = EventId("\$redacted-id-3")),
aSimpleNotifiableEvent(eventId = EventId("\$kept-id")),
)
)
queue.markRedacted(listOf(EventId("\$redacted-id-1"), EventId("\$redacted-id-2"), EventId("\$redacted-id-3")))
assertThat(queue.rawEvents()).isEqualTo(
listOf(
aSimpleNotifiableEvent(eventId = EventId("\$redacted-id-1"), isRedacted = true),
aNotifiableMessageEvent(eventId = EventId("\$redacted-id-2"), isRedacted = true),
anInviteNotifiableEvent(eventId = EventId("\$redacted-id-3"), isRedacted = true),
aSimpleNotifiableEvent(eventId = EventId("\$kept-id"), isRedacted = false),
)
)
}
@Test
fun `given invite event when leaving invited room and syncing then removes event`() {
val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID)))
val roomsLeft = listOf(A_ROOM_ID)
queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList())
assertThat(queue.rawEvents()).isEmpty()
}
@Test
fun `given invite event when joining invited room and syncing then removes event`() {
val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID)))
val joinedRooms = listOf(A_ROOM_ID)
queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = joinedRooms)
assertThat(queue.rawEvents()).isEmpty()
}
@Test
fun `given message event when leaving message room and syncing then removes event`() {
val queue = givenQueue(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID)))
val roomsLeft = listOf(A_ROOM_ID)
queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList())
assertThat(queue.rawEvents()).isEmpty()
}
@Test
fun `given events when syncing without rooms left or joined ids then does not change the events`() {
val queue = givenQueue(
listOf(
aNotifiableMessageEvent(roomId = A_ROOM_ID),
anInviteNotifiableEvent(roomId = A_ROOM_ID)
)
)
queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = emptyList())
assertThat(queue.rawEvents()).isEqualTo(
listOf(
aNotifiableMessageEvent(roomId = A_ROOM_ID),
anInviteNotifiableEvent(roomId = A_ROOM_ID)
)
)
}
@Test
fun `given events then is not empty`() {
val queue = givenQueue(listOf(aSimpleNotifiableEvent()))
assertThat(queue.isEmpty()).isFalse()
}
@Test
fun `given no events then is empty`() {
val queue = givenQueue(emptyList())
assertThat(queue.isEmpty()).isTrue()
}
@Test
fun `given events when clearing and adding then removes previous events and adds only new events`() {
val queue = givenQueue(listOf(aSimpleNotifiableEvent()))
queue.clearAndAdd(listOf(anInviteNotifiableEvent()))
assertThat(queue.rawEvents()).isEqualTo(listOf(anInviteNotifiableEvent()))
}
@Test
fun `when clearing then is empty`() {
val queue = givenQueue(listOf(aSimpleNotifiableEvent()))
queue.clear()
assertThat(queue.rawEvents()).isEmpty()
}
@Test
fun `given no events when adding then adds event`() {
val queue = givenQueue(listOf())
queue.add(aSimpleNotifiableEvent())
assertThat(queue.rawEvents()).isEqualTo(listOf(aSimpleNotifiableEvent()))
}
@Test
fun `given no events when adding already seen event then ignores event`() {
val queue = givenQueue(listOf())
val notifiableEvent = aSimpleNotifiableEvent()
seenIdsCache.put(notifiableEvent.eventId)
queue.add(notifiableEvent)
assertThat(queue.rawEvents()).isEmpty()
}
@Test
fun `given replaceable event when adding event with same id then updates existing event`() {
val replaceableEvent = aSimpleNotifiableEvent(canBeReplaced = true)
val updatedEvent = replaceableEvent.copy(title = "updated title", isUpdated = true)
val queue = givenQueue(listOf(replaceableEvent))
queue.add(updatedEvent)
assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent))
}
@Test
fun `given non replaceable event when adding event with same id then ignores event`() {
val nonReplaceableEvent = aSimpleNotifiableEvent(canBeReplaced = false)
val updatedEvent = nonReplaceableEvent.copy(title = "updated title")
val queue = givenQueue(listOf(nonReplaceableEvent))
queue.add(updatedEvent)
assertThat(queue.rawEvents()).isEqualTo(listOf(nonReplaceableEvent))
}
@Test
fun `given event when adding new event with edited event id matching the existing event id then updates existing event`() {
val editedEvent = aSimpleNotifiableEvent(eventId = EventId("\$id-to-edit"))
val updatedEvent = editedEvent.copy(eventId = EventId("\$1"), editedEventId = EventId("\$id-to-edit"), title = "updated title", isUpdated = true)
val queue = givenQueue(listOf(editedEvent))
queue.add(updatedEvent)
assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent))
}
@Test
fun `given event when adding new event with edited event id matching the existing event edited id then updates existing event`() {
val editedEvent = aSimpleNotifiableEvent(eventId = EventId("\$0"), editedEventId = EventId("\$id-to-edit"))
val updatedEvent = editedEvent.copy(eventId = EventId("\$1"), editedEventId = EventId("\$id-to-edit"), title = "updated title", isUpdated = true)
val queue = givenQueue(listOf(editedEvent))
queue.add(updatedEvent)
assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent))
}
@Test
fun `when clearing membership notification then removes invite events with matching room id`() {
val queue = givenQueue(
listOf(
anInviteNotifiableEvent(roomId = A_ROOM_ID),
aNotifiableMessageEvent(roomId = A_ROOM_ID)
)
)
queue.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID)
assertThat(queue.rawEvents()).isEqualTo(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID)))
}
@Test
fun `when clearing messages for room then removes message events with matching room id`() {
val queue = givenQueue(
listOf(
anInviteNotifiableEvent(roomId = A_ROOM_ID),
aNotifiableMessageEvent(roomId = A_ROOM_ID)
)
)
queue.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID)
assertThat(queue.rawEvents()).isEqualTo(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID)))
}
private fun givenQueue(events: List<NotifiableEvent>) = NotificationEventQueue(events.toMutableList(), seenEventIds = seenIdsCache)
}

View file

@ -1,221 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.MockkRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.MockkSummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
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)
@RunWith(RobolectricTestRunner::class)
class NotificationFactoryTest {
private val mockkNotificationCreator = MockkNotificationCreator()
private val mockkRoomGroupMessageCreator = MockkRoomGroupMessageCreator()
private val mockkSummaryGroupMessageCreator = MockkSummaryGroupMessageCreator()
private val notificationFactory = NotificationFactory(
notificationCreator = mockkNotificationCreator.instance,
roomGroupMessageCreator = mockkRoomGroupMessageCreator.instance,
summaryGroupMessageCreator = mockkSummaryGroupMessageCreator.instance
)
@Test
fun `given a room invitation when mapping to notification then is Append`() = testWith(notificationFactory) {
val expectedNotification = mockkNotificationCreator.givenCreateRoomInvitationNotificationFor(AN_INVITATION_EVENT)
val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, AN_INVITATION_EVENT))
val result = roomInvitation.toNotifications()
assertThat(result).isEqualTo(
listOf(
OneShotNotification.Append(
notification = expectedNotification,
meta = OneShotNotification.Append.Meta(
key = A_ROOM_ID.value,
summaryLine = AN_INVITATION_EVENT.description,
isNoisy = AN_INVITATION_EVENT.noisy,
timestamp = AN_INVITATION_EVENT.timestamp
)
)
)
)
}
@Test
fun `given a missing event in room invitation when mapping to notification then is Removed`() = testWith(notificationFactory) {
val missingEventRoomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, AN_INVITATION_EVENT))
val result = missingEventRoomInvitation.toNotifications()
assertThat(result).isEqualTo(
listOf(
OneShotNotification.Removed(
key = A_ROOM_ID.value
)
)
)
}
@Test
fun `given a simple event when mapping to notification then is Append`() = testWith(notificationFactory) {
val expectedNotification = mockkNotificationCreator.givenCreateSimpleInvitationNotificationFor(A_SIMPLE_EVENT)
val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_SIMPLE_EVENT))
val result = roomInvitation.toNotifications()
assertThat(result).isEqualTo(
listOf(
OneShotNotification.Append(
notification = expectedNotification,
meta = OneShotNotification.Append.Meta(
key = AN_EVENT_ID.value,
summaryLine = A_SIMPLE_EVENT.description,
isNoisy = A_SIMPLE_EVENT.noisy,
timestamp = AN_INVITATION_EVENT.timestamp
)
)
)
)
}
@Test
fun `given a missing simple event when mapping to notification then is Removed`() = testWith(notificationFactory) {
val missingEventRoomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_SIMPLE_EVENT))
val result = missingEventRoomInvitation.toNotifications()
assertThat(result).isEqualTo(
listOf(
OneShotNotification.Removed(
key = AN_EVENT_ID.value
)
)
)
}
@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 = mockkRoomGroupMessageCreator.givenCreatesRoomMessageFor(
MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
events,
A_ROOM_ID
)
val roomWithMessage = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT)))
val fakeImageLoader = FakeImageLoader()
val result = roomWithMessage.toNotifications(
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
imageLoader = fakeImageLoader.getImageLoader(),
)
assertThat(result).isEqualTo(listOf(expectedNotification))
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
}
@Test
fun `given a room with no events to display when mapping to notification then is Empty`() = testWith(notificationFactory) {
val events = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_MESSAGE_EVENT))
val emptyRoom = mapOf(A_ROOM_ID to events)
val fakeImageLoader = FakeImageLoader()
val result = emptyRoom.toNotifications(
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
imageLoader = fakeImageLoader.getImageLoader(),
)
assertThat(result).isEqualTo(
listOf(
RoomNotification.Removed(
roomId = A_ROOM_ID
)
)
)
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
}
@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(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true))))
val fakeImageLoader = FakeImageLoader()
val result = redactedRoom.toNotifications(
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
imageLoader = fakeImageLoader.getImageLoader(),
)
assertThat(result).isEqualTo(
listOf(
RoomNotification.Removed(
roomId = A_ROOM_ID
)
)
)
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
}
@Test
fun `given a room with redacted and non redacted message events when mapping to notification then redacted events are removed`() = testWith(
notificationFactory
) {
val roomWithRedactedMessage = mapOf(
A_ROOM_ID to listOf(
ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)),
ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted")))
)
)
val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted")))
val expectedNotification = mockkRoomGroupMessageCreator.givenCreatesRoomMessageFor(
MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
withRedactedRemoved,
A_ROOM_ID,
)
val fakeImageLoader = FakeImageLoader()
val result = roomWithRedactedMessage.toNotifications(
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
imageLoader = fakeImageLoader.getImageLoader(),
)
assertThat(result).isEqualTo(listOf(expectedNotification))
assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0)
}
}
fun <T> testWith(receiver: T, block: suspend T.() -> Unit) {
runTest {
receiver.block()
}
}

View file

@ -16,16 +16,24 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationDisplayer import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationFactory import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.mockk.mockk import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -35,201 +43,81 @@ private const val MY_USER_DISPLAY_NAME = "display-name"
private const val MY_USER_AVATAR_URL = "avatar-url" private const val MY_USER_AVATAR_URL = "avatar-url"
private const val USE_COMPLETE_NOTIFICATION_FORMAT = true private const val USE_COMPLETE_NOTIFICATION_FORMAT = true
private val AN_EVENT_LIST = listOf<ProcessedEvent<NotifiableEvent>>() private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(A_NOTIFICATION)
private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList(), emptyList()) private val ONE_SHOT_NOTIFICATION =
private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk()) OneShotNotification(notification = A_NOTIFICATION, key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1)
private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed
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, timestamp = -1)
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class NotificationRendererTest { class NotificationRendererTest {
private val mockkNotificationDisplayer = MockkNotificationDisplayer() private val notificationDisplayer = FakeNotificationDisplayer()
private val mockkNotificationFactory = MockkNotificationFactory()
private val notificationCreator = FakeNotificationCreator()
private val roomGroupMessageCreator = FakeRoomGroupMessageCreator()
private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator()
private val notificationDataFactory = DefaultNotificationDataFactory(
notificationCreator = notificationCreator,
roomGroupMessageCreator = roomGroupMessageCreator,
summaryGroupMessageCreator = summaryGroupMessageCreator,
activeNotificationsProvider = FakeActiveNotificationsProvider(),
stringProvider = FakeStringProvider(),
)
private val notificationIdProvider = NotificationIdProvider() private val notificationIdProvider = NotificationIdProvider()
private val notificationRenderer = NotificationRenderer( private val notificationRenderer = NotificationRenderer(
notificationIdProvider = notificationIdProvider, notificationIdProvider = notificationIdProvider,
notificationDisplayer = mockkNotificationDisplayer.instance, notificationDisplayer = notificationDisplayer,
notificationFactory = mockkNotificationFactory.instance, notificationDataFactory = notificationDataFactory,
) )
@Test @Test
fun `given no notifications when rendering then cancels summary notification`() = runTest { fun `given no notifications when rendering then cancels summary notification`() = runTest {
givenNoNotifications() renderEventsAsNotifications(emptyList())
renderEventsAsNotifications() notificationDisplayer.verifySummaryCancelled()
mockkNotificationDisplayer.verifySummaryCancelled()
mockkNotificationDisplayer.verifyNoOtherInteractions()
}
@Test
fun `given last room message group notification is removed when rendering then remove the summary and then remove message notification`() = runTest {
givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION)
renderEventsAsNotifications()
mockkNotificationDisplayer.verifyInOrder {
cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID))
cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID))
}
}
@Test
fun `given a room message group notification is removed when rendering then remove the message notification and update summary`() = runTest {
givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)))
renderEventsAsNotifications()
mockkNotificationDisplayer.verifyInOrder {
cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID))
showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification)
}
} }
@Test @Test
fun `given a room message group notification is added when rendering then show the message notification and update summary`() = runTest { fun `given a room message group notification is added when rendering then show the message notification and update summary`() = runTest {
givenNotifications( roomGroupMessageCreator.createRoomMessageResult = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }
roomNotifications = listOf(
RoomNotification.Message( renderEventsAsNotifications(listOf(aNotifiableMessageEvent()))
A_NOTIFICATION,
MESSAGE_META notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence(
) listOf(value(A_ROOM_ID.value), value(notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)),
) listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification))
) )
renderEventsAsNotifications()
mockkNotificationDisplayer.verifyInOrder {
showNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), A_NOTIFICATION)
showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification)
}
}
@Test
fun `given last simple notification is removed when rendering then remove the summary and then remove simple notification`() = runTest {
givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION)
renderEventsAsNotifications()
mockkNotificationDisplayer.verifyInOrder {
cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID))
cancelNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID))
}
}
@Test
fun `given a simple notification is removed when rendering then remove the simple notification and update summary`() = runTest {
givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value)))
renderEventsAsNotifications()
mockkNotificationDisplayer.verifyInOrder {
cancelNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID))
showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification)
}
} }
@Test @Test
fun `given a simple notification is added when rendering then show the simple notification and update summary`() = runTest { fun `given a simple notification is added when rendering then show the simple notification and update summary`() = runTest {
givenNotifications( notificationCreator.createSimpleNotificationResult = lambdaRecorder { _ -> ONE_SHOT_NOTIFICATION.copy(key = AN_EVENT_ID.value).notification }
simpleNotifications = listOf(
OneShotNotification.Append( renderEventsAsNotifications(listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID)))
A_NOTIFICATION,
ONE_SHOT_META.copy(key = AN_EVENT_ID.value) notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence(
) listOf(value(AN_EVENT_ID.value), value(notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)),
) listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification))
) )
renderEventsAsNotifications()
mockkNotificationDisplayer.verifyInOrder {
showNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID), A_NOTIFICATION)
showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification)
}
}
@Test
fun `given last invitation notification is removed when rendering then remove the summary and then remove invitation notification`() = runTest {
givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION)
renderEventsAsNotifications()
mockkNotificationDisplayer.verifyInOrder {
cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID))
cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID))
}
}
@Test
fun `given an invitation notification is removed when rendering then remove the invitation notification and update summary`() = runTest {
givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value)))
renderEventsAsNotifications()
mockkNotificationDisplayer.verifyInOrder {
cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID))
showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification)
}
} }
@Test @Test
fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() = runTest { fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() = runTest {
givenNotifications( notificationCreator.createRoomInvitationNotificationResult = lambdaRecorder { _ -> ONE_SHOT_NOTIFICATION.copy(key = AN_EVENT_ID.value).notification }
simpleNotifications = listOf(
OneShotNotification.Append( renderEventsAsNotifications(listOf(anInviteNotifiableEvent()))
A_NOTIFICATION,
ONE_SHOT_META.copy(key = A_ROOM_ID.value) notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence(
) listOf(value(A_ROOM_ID.value), value(notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)),
) listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification))
) )
renderEventsAsNotifications()
mockkNotificationDisplayer.verifyInOrder {
showNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID), A_NOTIFICATION)
showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification)
}
} }
private suspend fun renderEventsAsNotifications() { private suspend fun renderEventsAsNotifications(events: List<NotifiableEvent>) {
notificationRenderer.render( notificationRenderer.render(
MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL), MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL),
useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT, useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT,
eventsToProcess = AN_EVENT_LIST, eventsToProcess = events,
imageLoader = FakeImageLoader().getImageLoader(), imageLoader = FakeImageLoader().getImageLoader(),
) )
} }
private fun givenNoNotifications() {
givenNotifications(emptyList(), emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION)
}
private fun givenNotifications(
roomNotifications: List<RoomNotification> = emptyList(),
invitationNotifications: List<OneShotNotification> = emptyList(),
simpleNotifications: List<OneShotNotification> = emptyList(),
fallbackNotifications: List<OneShotNotification> = emptyList(),
useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT,
summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION
) {
mockkNotificationFactory.givenNotificationsFor(
groupedEvents = A_PROCESSED_EVENTS,
matrixUser = MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL),
useCompleteNotificationFormat = useCompleteNotificationFormat,
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
summaryNotification = summaryNotification
)
}
} }

View file

@ -18,8 +18,9 @@ package io.element.android.libraries.push.impl.notifications.factories
import android.app.Notification import android.app.Notification
import android.content.Context import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person import androidx.core.app.NotificationManagerCompat
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -29,23 +30,29 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.push.impl.notifications.NotificationActionIds import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class NotificationCreatorTest { class DefaultNotificationCreatorTest {
@Test @Test
fun `test createDiagnosticNotification`() { fun `test createDiagnosticNotification`() {
val sut = createNotificationCreator() val sut = createNotificationCreator()
@ -212,15 +219,10 @@ class NotificationCreatorTest {
} }
@Test @Test
fun `test createMessagesListNotification`() { fun `test createMessagesListNotification`() = runTest {
val sut = createNotificationCreator() val sut = createNotificationCreator()
aMatrixUser() aMatrixUser()
val result = sut.createMessagesListNotification( val result = sut.createMessagesListNotification(
messageStyle = NotificationCompat.MessagingStyle(
Person.Builder()
.setName("name")
.build()
),
roomInfo = RoomEventGroupInfo( roomInfo = RoomEventGroupInfo(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -235,20 +237,19 @@ class NotificationCreatorTest {
largeIcon = null, largeIcon = null,
lastMessageTimestamp = 123_456L, lastMessageTimestamp = 123_456L,
tickerText = "tickerText", tickerText = "tickerText",
currentUser = aMatrixUser(),
existingNotification = null,
imageLoader = FakeImageLoader().getImageLoader(),
events = emptyList(),
) )
result.commonAssertions() result.commonAssertions()
} }
@Test @Test
fun `test createMessagesListNotification should bing and thread`() { fun `test createMessagesListNotification should bing and thread`() = runTest {
val sut = createNotificationCreator() val sut = createNotificationCreator()
aMatrixUser() aMatrixUser()
val result = sut.createMessagesListNotification( val result = sut.createMessagesListNotification(
messageStyle = NotificationCompat.MessagingStyle(
Person.Builder()
.setName("name")
.build()
),
roomInfo = RoomEventGroupInfo( roomInfo = RoomEventGroupInfo(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -263,6 +264,10 @@ class NotificationCreatorTest {
largeIcon = null, largeIcon = null,
lastMessageTimestamp = 123_456L, lastMessageTimestamp = 123_456L,
tickerText = "tickerText", tickerText = "tickerText",
currentUser = aMatrixUser(),
existingNotification = null,
imageLoader = FakeImageLoader().getImageLoader(),
events = emptyList(),
) )
result.commonAssertions() result.commonAssertions()
} }
@ -280,9 +285,10 @@ class NotificationCreatorTest {
fun createNotificationCreator( fun createNotificationCreator(
context: Context = RuntimeEnvironment.getApplication(), context: Context = RuntimeEnvironment.getApplication(),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
notificationChannels: NotificationChannels = createNotificationChannels() notificationChannels: NotificationChannels = createNotificationChannels(),
bitmapLoader: NotificationBitmapLoader = NotificationBitmapLoader(context, FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.R)),
): NotificationCreator { ): NotificationCreator {
return NotificationCreator( return DefaultNotificationCreator(
context = context, context = context,
notificationChannels = notificationChannels, notificationChannels = notificationChannels,
stringProvider = FakeStringProvider("test"), stringProvider = FakeStringProvider("test"),
@ -305,10 +311,23 @@ fun createNotificationCreator(
stringProvider = FakeStringProvider("QuickReplyActionFactory"), stringProvider = FakeStringProvider("QuickReplyActionFactory"),
clock = FakeSystemClock(), clock = FakeSystemClock(),
), ),
bitmapLoader = bitmapLoader,
acceptInvitationActionFactory = AcceptInvitationActionFactory(
context = context,
actionIds = NotificationActionIds(buildMeta),
stringProvider = FakeStringProvider("AcceptInvitationActionFactory"),
clock = FakeSystemClock(),
),
rejectInvitationActionFactory = RejectInvitationActionFactory(
context = context,
actionIds = NotificationActionIds(buildMeta),
stringProvider = FakeStringProvider("RejectInvitationActionFactory"),
clock = FakeSystemClock(),
),
) )
} }
fun createNotificationChannels(): NotificationChannels { fun createNotificationChannels(): NotificationChannels {
val context = RuntimeEnvironment.getApplication() val context = RuntimeEnvironment.getApplication()
return NotificationChannels(context, FakeStringProvider("")) return NotificationChannels(context, NotificationManagerCompat.from(context), FakeStringProvider(""))
} }

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2024 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 io.element.android.libraries.push.impl.notifications.fake
import android.service.notification.StatusBarNotification
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider
class FakeActiveNotificationsProvider(
var activeNotifications: MutableList<StatusBarNotification> = mutableListOf(),
) : ActiveNotificationsProvider {
override fun getAllNotifications(): List<StatusBarNotification> {
return activeNotifications
}
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
return activeNotifications
}
override fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification> {
return activeNotifications
}
override fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification> {
return activeNotifications
}
override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
return activeNotifications
}
override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? {
return activeNotifications.firstOrNull()
}
override fun count(sessionId: SessionId): Int {
return activeNotifications.size
}
}

View file

@ -0,0 +1,89 @@
/*
* Copyright (c) 2024 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 io.element.android.libraries.push.impl.notifications.fake
import android.app.Notification
import android.graphics.Bitmap
import androidx.core.app.NotificationCompat
import coil.ImageLoader
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaListAnyParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import io.element.android.tests.testutils.lambda.lambdaAnyRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
class FakeNotificationCreator(
var createMessagesListNotificationResult: LambdaListAnyParamsRecorder<Notification> = lambdaAnyRecorder { A_NOTIFICATION },
var createRoomInvitationNotificationResult: LambdaOneParamRecorder<InviteNotifiableEvent, Notification> = lambdaRecorder { _ -> A_NOTIFICATION },
var createSimpleNotificationResult: LambdaOneParamRecorder<SimpleNotifiableEvent, Notification> = lambdaRecorder { _ -> A_NOTIFICATION },
var createFallbackNotificationResult: LambdaOneParamRecorder<FallbackNotifiableEvent, Notification> = lambdaRecorder { _ -> A_NOTIFICATION },
var createSummaryListNotificationResult: LambdaFiveParamsRecorder<MatrixUser, NotificationCompat.InboxStyle?, String, Boolean, Long, Notification> =
lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION },
var createDiagnosticNotificationResult: LambdaNoParamRecorder<Notification> = lambdaRecorder { -> A_NOTIFICATION },
) : NotificationCreator {
override suspend fun createMessagesListNotification(
roomInfo: RoomEventGroupInfo,
threadId: ThreadId?,
largeIcon: Bitmap?,
lastMessageTimestamp: Long,
tickerText: String,
currentUser: MatrixUser,
existingNotification: Notification?,
imageLoader: ImageLoader,
events: List<NotifiableMessageEvent>
): Notification {
return createMessagesListNotificationResult(
listOf(roomInfo, threadId, largeIcon, lastMessageTimestamp, tickerText, currentUser, existingNotification, imageLoader, events)
)
}
override fun createRoomInvitationNotification(inviteNotifiableEvent: InviteNotifiableEvent): Notification {
return createRoomInvitationNotificationResult(inviteNotifiableEvent)
}
override fun createSimpleEventNotification(simpleNotifiableEvent: SimpleNotifiableEvent): Notification {
return createSimpleNotificationResult(simpleNotifiableEvent)
}
override fun createFallbackNotification(fallbackNotifiableEvent: FallbackNotifiableEvent): Notification {
return createFallbackNotificationResult(fallbackNotifiableEvent)
}
override fun createSummaryListNotification(
currentUser: MatrixUser,
style: NotificationCompat.InboxStyle?,
compatSummary: String,
noisy: Boolean,
lastMessageTimestamp: Long
): Notification {
return createSummaryListNotificationResult(currentUser, style, compatSummary, noisy, lastMessageTimestamp)
}
override fun createDiagnosticNotification(): Notification {
return createDiagnosticNotificationResult()
}
}

View file

@ -0,0 +1,89 @@
/*
* 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 io.element.android.libraries.push.impl.notifications.fake
import coil.ImageLoader
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.NotificationDataFactory
import io.element.android.libraries.push.impl.notifications.OneShotNotification
import io.element.android.libraries.push.impl.notifications.RoomNotification
import io.element.android.libraries.push.impl.notifications.SummaryNotification
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import io.element.android.tests.testutils.lambda.LambdaSixParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
class FakeNotificationDataFactory(
var messageEventToNotificationsResult: LambdaThreeParamsRecorder<List<NotifiableMessageEvent>, MatrixUser, ImageLoader, List<RoomNotification>> =
lambdaRecorder { _, _, _ -> emptyList() },
var summaryToNotificationsResult: LambdaSixParamsRecorder<
MatrixUser,
List<RoomNotification>,
List<OneShotNotification>,
List<OneShotNotification>,
List<OneShotNotification>,
Boolean,
SummaryNotification
> = lambdaRecorder { _, _, _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) },
var inviteToNotificationsResult: LambdaOneParamRecorder<List<InviteNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() },
var simpleEventToNotificationsResult: LambdaOneParamRecorder<List<SimpleNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() },
var fallbackEventToNotificationsResult: LambdaOneParamRecorder<List<FallbackNotifiableEvent>, List<OneShotNotification>> =
lambdaRecorder { _ -> emptyList() },
) : NotificationDataFactory {
override suspend fun toNotifications(messages: List<NotifiableMessageEvent>, currentUser: MatrixUser, imageLoader: ImageLoader): List<RoomNotification> {
return messageEventToNotificationsResult(messages, currentUser, imageLoader)
}
@JvmName("toNotificationInvites")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(invites: List<InviteNotifiableEvent>): List<OneShotNotification> {
return inviteToNotificationsResult(invites)
}
@JvmName("toNotificationSimpleEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(simpleEvents: List<SimpleNotifiableEvent>): List<OneShotNotification> {
return simpleEventToNotificationsResult(simpleEvents)
}
override fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification> {
return fallbackEventToNotificationsResult(fallback)
}
override fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): SummaryNotification {
return summaryToNotificationsResult(
currentUser,
roomNotifications,
invitationNotifications,
simpleNotifications,
fallbackNotifications,
useCompleteNotificationFormat
)
}
}

View file

@ -0,0 +1,57 @@
/*
* 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 io.element.android.libraries.push.impl.notifications.fake
import android.app.Notification
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
import io.element.android.libraries.push.impl.notifications.NotificationIdProvider
import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
class FakeNotificationDisplayer(
var showNotificationMessageResult: LambdaThreeParamsRecorder<String?, Int, Notification, Boolean> = lambdaRecorder { _, _, _ -> true },
var cancelNotificationMessageResult: LambdaTwoParamsRecorder<String?, Int, Unit> = lambdaRecorder { _, _ -> },
var displayDiagnosticNotificationResult: LambdaOneParamRecorder<Notification, Boolean> = lambdaRecorder { _ -> true },
var dismissDiagnosticNotificationResult: LambdaNoParamRecorder<Unit> = lambdaRecorder { -> },
) : NotificationDisplayer {
override fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean {
return showNotificationMessageResult(tag, id, notification)
}
override fun cancelNotificationMessage(tag: String?, id: Int) {
return cancelNotificationMessageResult(tag, id)
}
override fun displayDiagnosticNotification(notification: Notification): Boolean {
return displayDiagnosticNotificationResult(notification)
}
override fun dismissDiagnosticNotification() {
return dismissDiagnosticNotificationResult()
}
fun verifySummaryCancelled(times: Int = 1) {
cancelNotificationMessageResult.assertions().isCalledExactly(times).withSequence(
listOf(value(null), value(NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)))
)
}
}

View file

@ -16,31 +16,27 @@
package io.element.android.libraries.push.impl.notifications.fake package io.element.android.libraries.push.impl.notifications.fake
import android.app.Notification
import coil.ImageLoader
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.RoomNotification import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.mockk.coEvery import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder
import io.mockk.mockk import io.element.android.tests.testutils.lambda.lambdaRecorder
class MockkRoomGroupMessageCreator { class FakeRoomGroupMessageCreator(
val instance = mockk<RoomGroupMessageCreator>() var createRoomMessageResult: LambdaFiveParamsRecorder<MatrixUser, List<NotifiableMessageEvent>, RoomId, ImageLoader, Notification?, Notification> =
lambdaRecorder { _, _, _, _, _, -> A_NOTIFICATION }
fun givenCreatesRoomMessageFor( ) : RoomGroupMessageCreator {
matrixUser: MatrixUser, override suspend fun createRoomMessage(
currentUser: MatrixUser,
events: List<NotifiableMessageEvent>, events: List<NotifiableMessageEvent>,
roomId: RoomId, roomId: RoomId,
): RoomNotification.Message { imageLoader: ImageLoader,
val mockMessage = mockk<RoomNotification.Message>() existingNotification: Notification?
coEvery { ): Notification {
instance.createRoomMessage( return createRoomMessageResult(currentUser, events, roomId, imageLoader, existingNotification)
currentUser = matrixUser,
events = events,
roomId = roomId,
imageLoader = any(),
)
} returns mockMessage
return mockMessage
} }
} }

View file

@ -0,0 +1,51 @@
/*
* 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 io.element.android.libraries.push.impl.notifications.fake
import android.app.Notification
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.OneShotNotification
import io.element.android.libraries.push.impl.notifications.RoomNotification
import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.tests.testutils.lambda.LambdaSixParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
class FakeSummaryGroupMessageCreator(
var createSummaryNotificationResult: LambdaSixParamsRecorder<
MatrixUser, List<RoomNotification>, List<OneShotNotification>, List<OneShotNotification>, List<OneShotNotification>, Boolean, Notification
> =
lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION }
) : SummaryGroupMessageCreator {
override fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): Notification {
return createSummaryNotificationResult(
currentUser,
roomNotifications,
invitationNotifications,
simpleNotifications,
fallbackNotifications,
useCompleteNotificationFormat
)
}
}

View file

@ -1,46 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications.fake
import android.app.Notification
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.mockk.every
import io.mockk.mockk
class MockkNotificationCreator {
val instance = mockk<NotificationCreator>()
fun givenCreateRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification {
val mockNotification = mockk<Notification>()
every { instance.createRoomInvitationNotification(event) } returns mockNotification
return mockNotification
}
fun givenCreateSimpleInvitationNotificationFor(event: SimpleNotifiableEvent): Notification {
val mockNotification = mockk<Notification>()
every { instance.createSimpleEventNotification(event) } returns mockNotification
return mockNotification
}
fun givenCreateDiagnosticNotification(): Notification {
val mockNotification = mockk<Notification>()
every { instance.createDiagnosticNotification() } returns mockNotification
return mockNotification
}
}

View file

@ -1,47 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications.fake
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
import io.element.android.libraries.push.impl.notifications.NotificationIdProvider
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyOrder
class MockkNotificationDisplayer {
val instance = mockk<NotificationDisplayer>(relaxed = true)
fun givenDisplayDiagnosticNotificationResult(result: Boolean) {
every { instance.displayDiagnosticNotification(any()) } returns result
}
fun verifySummaryCancelled() {
verify { instance.cancelNotificationMessage(tag = null, NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)) }
}
fun verifyNoOtherInteractions() {
confirmVerified(instance)
}
fun verifyInOrder(verifyBlock: NotificationDisplayer.() -> Unit) {
verifyOrder { verifyBlock(instance) }
verifyNoOtherInteractions()
}
}

View file

@ -1,60 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications.fake
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.GroupedNotificationEvents
import io.element.android.libraries.push.impl.notifications.NotificationFactory
import io.element.android.libraries.push.impl.notifications.OneShotNotification
import io.element.android.libraries.push.impl.notifications.RoomNotification
import io.element.android.libraries.push.impl.notifications.SummaryNotification
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
class MockkNotificationFactory {
val instance = mockk<NotificationFactory>()
fun givenNotificationsFor(
groupedEvents: GroupedNotificationEvents,
matrixUser: MatrixUser,
useCompleteNotificationFormat: Boolean,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
summaryNotification: SummaryNotification
) {
with(instance) {
coEvery { groupedEvents.roomEvents.toNotifications(matrixUser, any()) } returns roomNotifications
every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications
every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications
every { groupedEvents.fallbackEvents.toNotifications() } returns fallbackNotifications
every {
createSummaryNotification(
matrixUser,
roomNotifications,
invitationNotifications,
simpleNotifications,
fallbackNotifications,
useCompleteNotificationFormat
)
} returns summaryNotification
}
}
}

View file

@ -1,34 +0,0 @@
/*
* 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 io.element.android.libraries.push.impl.notifications.fake
import io.element.android.libraries.push.impl.notifications.OutdatedEventDetector
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.mockk.every
import io.mockk.mockk
class MockkOutdatedEventDetector {
val instance = mockk<OutdatedEventDetector>()
fun givenEventIsOutOfDate(notifiableEvent: NotifiableEvent) {
every { instance.isMessageOutdated(notifiableEvent) } returns true
}
fun givenEventIsInDate(notifiableEvent: NotifiableEvent) {
every { instance.isMessageOutdated(notifiableEvent) } returns false
}
}

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2021 New Vector Ltd * Copyright (c) 2024 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,11 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.notifications.fake package io.element.android.libraries.push.impl.notifications.fixtures
import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator import android.app.Notification
import io.mockk.mockk
class MockkSummaryGroupMessageCreator { val A_NOTIFICATION = Notification()
val instance = mockk<SummaryGroupMessageCreator>()
}

View file

@ -18,27 +18,27 @@ package io.element.android.libraries.push.impl.troubleshoot
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationCreator import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationDisplayer import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class NotificationTestTest { class NotificationTestTest {
private val mockkNotificationCreator = MockkNotificationCreator().apply { private val notificationCreator = FakeNotificationCreator()
givenCreateDiagnosticNotification() private val fakeNotificationDisplayer = FakeNotificationDisplayer(
} displayDiagnosticNotificationResult = lambdaRecorder { _ -> true },
private val mockkNotificationDisplayer = MockkNotificationDisplayer().apply { dismissDiagnosticNotificationResult = lambdaRecorder { -> }
givenDisplayDiagnosticNotificationResult(true) )
}
private val notificationClickHandler = NotificationClickHandler() private val notificationClickHandler = NotificationClickHandler()
@Test @Test
fun `test NotificationTest notification cannot be displayed`() = runTest { fun `test NotificationTest notification cannot be displayed`() = runTest {
mockkNotificationDisplayer.givenDisplayDiagnosticNotificationResult(false) fakeNotificationDisplayer.displayDiagnosticNotificationResult = lambdaRecorder { _ -> false }
val sut = createNotificationTest() val sut = createNotificationTest()
launch { launch {
sut.run(this) sut.run(this)
@ -81,8 +81,8 @@ class NotificationTestTest {
private fun createNotificationTest(): NotificationTest { private fun createNotificationTest(): NotificationTest {
return NotificationTest( return NotificationTest(
notificationCreator = mockkNotificationCreator.instance, notificationCreator = notificationCreator,
notificationDisplayer = mockkNotificationDisplayer.instance, notificationDisplayer = fakeNotificationDisplayer,
notificationClickHandler = notificationClickHandler, notificationClickHandler = notificationClickHandler,
stringProvider = FakeStringProvider(), stringProvider = FakeStringProvider(),
) )

View file

@ -28,7 +28,7 @@ class FakeNotificationDrawerManager : NotificationDrawerManager {
clearMemberShipNotificationForSessionCallsCount.merge(sessionId.value, 1) { oldValue, value -> oldValue + value } clearMemberShipNotificationForSessionCallsCount.merge(sessionId.value, 1) { oldValue, value -> oldValue + value }
} }
override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId, doRender: Boolean) { override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
val key = getMembershipNotificationKey(sessionId, roomId) val key = getMembershipNotificationKey(sessionId, roomId)
clearMemberShipNotificationForRoomCallsCount.merge(key, 1) { oldValue, value -> oldValue + value } clearMemberShipNotificationForRoomCallsCount.merge(key, 1) { oldValue, value -> oldValue + value }
} }

View file

@ -63,7 +63,7 @@ object Versions {
val versionName = "$versionMajor.$versionMinor.$versionPatch" val versionName = "$versionMajor.$versionMinor.$versionPatch"
const val compileSdk = 34 const val compileSdk = 34
const val targetSdk = 33 const val targetSdk = 33
const val minSdk = 23 const val minSdk = 24
val javaCompileVersion = JavaVersion.VERSION_17 val javaCompileVersion = JavaVersion.VERSION_17
val javaLanguageVersion: JavaLanguageVersion = JavaLanguageVersion.of(11) val javaLanguageVersion: JavaLanguageVersion = JavaLanguageVersion.of(11)
} }

View file

@ -78,6 +78,20 @@ inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified
return LambdaFiveParamsRecorder(ensureNeverCalled, block) return LambdaFiveParamsRecorder(ensureNeverCalled, block)
} }
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified T6, reified R> lambdaRecorder(
ensureNeverCalled: Boolean = false,
noinline block: (T1, T2, T3, T4, T5, T6) -> R
): LambdaSixParamsRecorder<T1, T2, T3, T4, T5, T6, R> {
return LambdaSixParamsRecorder(ensureNeverCalled, block)
}
inline fun <reified R> lambdaAnyRecorder(
ensureNeverCalled: Boolean = false,
noinline block: (List<Any?>) -> R
): LambdaListAnyParamsRecorder<R> {
return LambdaListAnyParamsRecorder(ensureNeverCalled, block)
}
class LambdaNoParamRecorder<out R>(ensureNeverCalled: Boolean, val block: () -> R) : LambdaRecorder(ensureNeverCalled), () -> R { class LambdaNoParamRecorder<out R>(ensureNeverCalled: Boolean, val block: () -> R) : LambdaRecorder(ensureNeverCalled), () -> R {
override fun invoke(): R { override fun invoke(): R {
onInvoke() onInvoke()
@ -125,3 +139,53 @@ class LambdaFiveParamsRecorder<in T1, in T2, in T3, in T4, in T5, out R>(ensureN
return block(p1, p2, p3, p4, p5) return block(p1, p2, p3, p4, p5)
} }
} }
class LambdaSixParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, out R>(
ensureNeverCalled: Boolean,
val block: (T1, T2, T3, T4, T5, T6) -> R,
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6) -> R {
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6): R {
onInvoke(p1, p2, p3, p4, p5, p6)
return block(p1, p2, p3, p4, p5, p6)
}
}
class LambdaSevenParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, in T7, out R>(
ensureNeverCalled: Boolean,
val block: (T1, T2, T3, T4, T5, T6, T7) -> R,
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6, T7) -> R {
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6, p7: T7): R {
onInvoke(p1, p2, p3, p4, p5, p6, p7)
return block(p1, p2, p3, p4, p5, p6, p7)
}
}
class LambdaEightParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, in T7, in T8, out R>(
ensureNeverCalled: Boolean,
val block: (T1, T2, T3, T4, T5, T6, T7, T8) -> R,
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6, T7, T8) -> R {
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6, p7: T7, p8: T8): R {
onInvoke(p1, p2, p3, p4, p5, p6, p7, p8)
return block(p1, p2, p3, p4, p5, p6, p7, p8)
}
}
class LambdaNineParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, in T7, in T8, in T9, out R>(
ensureNeverCalled: Boolean,
val block: (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R,
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R {
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6, p7: T7, p8: T8, p9: T9): R {
onInvoke(p1, p2, p3, p4, p5, p6, p7, p8, p9)
return block(p1, p2, p3, p4, p5, p6, p7, p8, p9)
}
}
class LambdaListAnyParamsRecorder<out R>(
ensureNeverCalled: Boolean,
val block: (List<Any?>) -> R,
) : LambdaRecorder(ensureNeverCalled), (List<Any?>) -> R {
override fun invoke(p: List<Any?>): R {
onInvoke(*p.toTypedArray())
return block(p)
}
}

View file

@ -41,3 +41,12 @@ fun any() = object : ParameterMatcher {
override fun match(param: Any?) = true override fun match(param: Any?) = true
override fun toString(): String = "any()" override fun toString(): String = "any()"
} }
/**
* A matcher that matches any non null value
* Can be used when we don't care about the value of a parameter, just about its nullability.
*/
fun nonNull() = object : ParameterMatcher {
override fun match(param: Any?) = param != null
override fun toString(): String = "nonNull()"
}