diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt index 8d41a2a207..9ca4f73314 100644 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt @@ -20,6 +20,9 @@ object NotificationConfig { // TODO EAx Implement and set to true at some point 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 const val SUPPORT_QUICK_REPLY_ACTION = false } diff --git a/changelog.d/2924.misc b/changelog.d/2924.misc new file mode 100644 index 0000000000..c43093e0fa --- /dev/null +++ b/changelog.d/2924.misc @@ -0,0 +1,3 @@ +Simplify notifications by removing the custom persistence layer. + +Bump minSdk to 24 (Android 7). diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt b/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt index 5f12ace0ac..10168eba2f 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt @@ -72,15 +72,10 @@ class CallForegroundService : Service() { startForeground(1, notification) } - @Suppress("DEPRECATION") override fun onDestroy() { super.onDestroy() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - stopForeground(STOP_FOREGROUND_REMOVE) - } else { - stopForeground(true) - } + stopForeground(STOP_FOREGROUND_REMOVE) } override fun onBind(intent: Intent?): IBinder? { diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt index 05ba21b98d..0972eb3eb8 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt @@ -112,7 +112,7 @@ class AcceptDeclineInvitePresenter @Inject constructor( trigger = JoinedRoom.Trigger.Invite, ) .onSuccess { - notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) + notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId) } .map { roomId } } @@ -122,7 +122,7 @@ class AcceptDeclineInvitePresenter @Inject constructor( suspend { client.getRoom(roomId)?.use { it.leave().getOrThrow() - notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) + notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId) } roomId }.runCatchingUpdatingState(declinedAction) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt index 0da6e16ee0..a9cd576e6d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt @@ -16,8 +16,6 @@ package io.element.android.features.login.impl.oidc.webview -import android.annotation.TargetApi -import android.os.Build import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient @@ -25,7 +23,6 @@ import android.webkit.WebViewClient class OidcWebViewClient( private val eventListener: WebViewEventListener, ) : WebViewClient() { - @TargetApi(Build.VERSION_CODES.N) override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { return shouldOverrideUrl(request.url.toString()) } @@ -36,7 +33,6 @@ class OidcWebViewClient( } private fun shouldOverrideUrl(url: String): Boolean { - // Timber.d("shouldOverrideUrl: $url") return eventListener.shouldOverrideUrlLoading(url) } } diff --git a/features/migration/impl/build.gradle.kts b/features/migration/impl/build.gradle.kts index 2c8ffa58b3..392a8f3889 100644 --- a/features/migration/impl/build.gradle.kts +++ b/features/migration/impl/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) testImplementation(libs.molecule.runtime) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.sessionStorage.implMemory) diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04.kt new file mode 100644 index 0000000000..79947b683e --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04.kt @@ -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() } + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04Test.kt new file mode 100644 index 0000000000..5549a8d8a2 --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04Test.kt @@ -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() + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt index e1dbcb19d1..3adbd7528e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt @@ -16,11 +16,9 @@ package io.element.android.features.preferences.impl.notifications -import android.content.Context 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.SingleIn import javax.inject.Inject @@ -31,9 +29,9 @@ interface SystemNotificationsEnabledProvider { @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) class DefaultSystemNotificationsEnabledProvider @Inject constructor( - @ApplicationContext private val context: Context, + private val notificationManager: NotificationManagerCompat, ) : SystemNotificationsEnabledProvider { override fun notificationsEnabled(): Boolean { - return NotificationManagerCompat.from(context).areNotificationsEnabled() + return notificationManager.areNotificationsEnabled() } } diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt index 3d0f9be181..fbe43426aa 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt @@ -58,17 +58,16 @@ class AndroidMediaPreProcessorTest { val data = result.getOrThrow() assertThat(data.file.path).endsWith("image.png") val info = data as MediaUploadInfo.Image - // Computing thumbnailFile is failing with Robolectric - assertThat(info.thumbnailFile).isNull() + assertThat(info.thumbnailFile).isNotNull() assertThat(info.imageInfo).isEqualTo( ImageInfo( height = 1_178, width = 1_818, mimetype = MimeTypes.Png, size = 114_867, - thumbnailInfo = null, + ThumbnailInfo(height = 294, width = 454, mimetype = "image/jpeg", size = 4567), thumbnailSource = null, - blurhash = null, + blurhash = "K13]7q%zWC00R4of%\$baad" ) ) assertThat(file.exists()).isTrue() @@ -88,7 +87,6 @@ class AndroidMediaPreProcessorTest { val data = result.getOrThrow() assertThat(data.file.path).endsWith("image.png") val info = data as MediaUploadInfo.Image - // Computing thumbnailFile is failing with Robolectric assertThat(info.thumbnailFile).isNull() assertThat(info.imageInfo).isEqualTo( ImageInfo( diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt index ecdf32f906..9a778195fa 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt @@ -21,5 +21,5 @@ import io.element.android.libraries.matrix.api.core.SessionId interface NotificationDrawerManager { fun clearMembershipNotificationForSession(sessionId: SessionId) - fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId, doRender: Boolean) + fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) } diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 05e249798f..36dddbcedf 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(projects.libraries.network) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) + implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiStrings) implementation(projects.libraries.troubleshoot.api) api(projects.libraries.pushproviders.api) @@ -63,8 +64,8 @@ dependencies { implementation(projects.services.toolbox.api) testImplementation(libs.test.junit) - testImplementation(libs.test.robolectric) testImplementation(libs.test.mockk) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(libs.coil.test) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt new file mode 100644 index 0000000000..4a5c7d7a44 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt @@ -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) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt new file mode 100644 index 0000000000..c4814d8134 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt @@ -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 + fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List + fun getNotificationsForSession(sessionId: SessionId): List + fun getMembershipNotificationForSession(sessionId: SessionId): List + fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List + 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 { + return notificationManager.activeNotifications + } + + override fun getNotificationsForSession(sessionId: SessionId): List { + return notificationManager.activeNotifications.filter { it.groupKey == sessionId.value } + } + + override fun getMembershipNotificationForSession(sessionId: SessionId): List { + val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId) + return getNotificationsForSession(sessionId).filter { it.id == notificationId } + } + + override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List { + val notificationId = notificationIdProvider.getRoomMessagesNotificationId(sessionId) + return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value } + } + + override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List { + 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 + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index f4cd1bab39..cfbf3c6950 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -16,14 +16,13 @@ package io.element.android.libraries.push.impl.notifications -import io.element.android.libraries.androidutils.throttler.FirstThrottler -import io.element.android.libraries.core.cache.CircularCache -import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import androidx.annotation.VisibleForTesting +import androidx.core.app.NotificationManagerCompat import io.element.android.libraries.core.data.tryOrNull 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.SingleIn +import io.element.android.libraries.matrix.api.MatrixClient 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.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.push.api.notifications.NotificationDrawerManager 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.NavigationState import io.element.android.services.appnavstate.api.currentSessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject 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. * Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning. */ @SingleIn(AppScope::class) class DefaultNotificationDrawerManager @Inject constructor( - private val notifiableEventProcessor: NotifiableEventProcessor, + private val notificationManager: NotificationManagerCompat, private val notificationRenderer: NotificationRenderer, - private val notificationEventPersistence: NotificationEventPersistence, - private val filteredEventDetector: FilteredEventDetector, + private val notificationIdProvider: NotificationIdProvider, private val appNavigationStateService: AppNavigationStateService, - private val coroutineScope: CoroutineScope, - private val dispatchers: CoroutineDispatchers, - private val buildMeta: BuildMeta, + coroutineScope: CoroutineScope, private val matrixClientProvider: MatrixClientProvider, private val imageLoaderHolder: ImageLoaderHolder, + private val activeNotificationsProvider: ActiveNotificationsProvider, ) : NotificationDrawerManager { 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 private var useCompleteNotificationFormat = true @@ -84,7 +74,8 @@ class DefaultNotificationDrawerManager @Inject constructor( } // For test only - fun destroy() { + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun destroy() { appNavigationStateObserver?.cancel() } @@ -105,7 +96,6 @@ class DefaultNotificationDrawerManager @Inject constructor( clearMessagesForRoom( sessionId = navigationState.parentSpace.parentSession.sessionId, roomId = navigationState.roomId, - doRender = true, ) } is NavigationState.Thread -> { @@ -119,95 +109,71 @@ class DefaultNotificationDrawerManager @Inject constructor( 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. - * The notification corresponding to this event will not be displayed until - * #refreshNotificationDrawer() is called. + * Should be called as soon as a new event is ready to be displayed, filtering out notifications that shouldn't be displayed. * Events might be grouped and there might not be one notification per event! */ - fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { - updateEvents(doRender = true) { - it.onNotifiableEventReceived(notifiableEvent) + suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + if (notifiableEvent.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value)) { + 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) { - updateEvents(doRender = doRender) { - it.clearMessagesForSession(sessionId) - } + fun clearAllMessagesEvents(sessionId: SessionId) { + notificationManager.cancel(null, notificationIdProvider.getRoomMessagesNotificationId(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) { - updateEvents(doRender = true) { - it.clearAllForSession(sessionId) - } + activeNotificationsProvider.getNotificationsForSession(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. * Can also be called when a notification for this room is dismissed by the user. */ - fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId, doRender: Boolean) { - updateEvents(doRender = doRender) { - it.clearMessagesForRoom(sessionId, roomId) - } + fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { + notificationManager.cancel(roomId.value, notificationIdProvider.getRoomMessagesNotificationId(sessionId)) + clearSummaryNotificationIfNeeded(sessionId) } override fun clearMembershipNotificationForSession(sessionId: SessionId) { - updateEvents(doRender = true) { - it.clearMembershipNotificationForSession(sessionId) - } + activeNotificationsProvider.getMembershipNotificationForSession(sessionId) + .forEach { notificationManager.cancel(it.tag, it.id) } + clearSummaryNotificationIfNeeded(sessionId) } /** * Clear invitation notification for the provided room. */ - override fun clearMembershipNotificationForRoom( - sessionId: SessionId, - roomId: RoomId, - doRender: Boolean, - ) { - updateEvents(doRender = doRender) { - it.clearMembershipNotificationForRoom(sessionId, roomId) - } + override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { + activeNotificationsProvider.getMembershipNotificationForRoom(sessionId, roomId) + .forEach { notificationManager.cancel(it.tag, it.id) } + clearSummaryNotificationIfNeeded(sessionId) } /** * Clear the notifications for a single event. */ - fun clearEvent(sessionId: SessionId, eventId: EventId, doRender: Boolean) { - updateEvents(doRender = doRender) { - it.clearEvent(sessionId, eventId) + fun clearEvent(sessionId: SessionId, eventId: EventId) { + val id = notificationIdProvider.getRoomEventNotificationId(sessionId) + 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. * 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) { - updateEvents(doRender = true) { - it.clearMessagesForThread(sessionId, roomId, threadId) - } + // TODO maybe we'll have to embed more data in the tag to get a threadId + // Do nothing for now } - private fun updateEvents( - 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>) { + private suspend fun renderEvents(eventsToRender: List) { // Group by sessionId 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 imageLoader = imageLoaderHolder.get(client) val userFromCache = client.userProfile.value @@ -285,27 +201,29 @@ class DefaultNotificationDrawerManager @Inject constructor( // We have an avatar and a display name, use it userFromCache } else { - tryOrNull( - 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 - ) + client.getSafeUserProfile() } 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 + ) + } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistence.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistence.kt deleted file mode 100644 index 3646837937..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistence.kt +++ /dev/null @@ -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) -> NotificationEventQueue): NotificationEventQueue { - val rawEvents: ArrayList? = file - .takeIf { it.exists() } - ?.let { - try { - encryptedFile.openFileInput().use { fis -> - ObjectInputStream(fis).use { ois -> - @Suppress("UNCHECKED_CAST") - ois.readObject() as? ArrayList - } - }.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() - } - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt deleted file mode 100644 index 3219427b8a..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt +++ /dev/null @@ -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 - } - */ -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt deleted file mode 100644 index 4da6dbdd59..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt +++ /dev/null @@ -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> - -private val loggerTag = LoggerTag("NotifiableEventProcessor", LoggerTag.NotificationLoggerTag) - -class NotifiableEventProcessor @Inject constructor( - private val outdatedDetector: OutdatedEventDetector, - private val appNavigationStateService: AppNavigationStateService, -) { - fun process( - queuedEvents: List, - 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 - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt index 360357af54..42fd48701c 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt @@ -19,11 +19,17 @@ package io.element.android.libraries.push.impl.notifications import android.content.BroadcastReceiver import android.content.Context 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.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.RoomId 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 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.). */ 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 actionIds: NotificationActionIds override fun onReceive(context: Context?, intent: Intent?) { if (intent == null || context == null) return - context.bindings().inject(this) val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId) val eventId = intent.getStringExtra(KEY_EVENT_ID)?.let(::EventId) + + context.bindings().inject(this) + Timber.tag(loggerTag.value).d("onReceive: ${intent.action} ${intent.data} for: ${roomId?.value}/${eventId?.value}") when (intent.action) { actionIds.smartReply -> handleSmartReply(intent, context) actionIds.dismissRoom -> if (roomId != null) { - defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId, doRender = false) + defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId) } actionIds.dismissSummary -> - defaultNotificationDrawerManager.clearAllMessagesEvents(sessionId, doRender = false) + defaultNotificationDrawerManager.clearAllMessagesEvents(sessionId) actionIds.dismissInvite -> if (roomId != null) { - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = false) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) } actionIds.dismissEvent -> if (eventId != null) { - defaultNotificationDrawerManager.clearEvent(sessionId, eventId, doRender = false) + defaultNotificationDrawerManager.clearEvent(sessionId, eventId) } actionIds.markRoomRead -> if (roomId != null) { - defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId, doRender = true) + defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId) handleMarkAsRead(sessionId, roomId) } actionIds.join -> if (roomId != null) { - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = true) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) handleJoinRoom(sessionId, roomId) } actionIds.reject -> if (roomId != null) { - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = true) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) handleRejectRoom(sessionId, roomId) } } } - @Suppress("UNUSED_PARAMETER") - private fun handleJoinRoom(sessionId: SessionId, roomId: 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)) - } - } - } - } - */ + private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + client.joinRoom(roomId) } - @Suppress("UNUSED_PARAMETER") - private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) { - /* - activeSessionHolder.getSafeActiveSession()?.let { session -> - session.coroutineScope.launch { - tryOrNull { session.roomService().leaveRoom(roomId) } - } - } - - */ + private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + client.getRoom(roomId)?.leave() } - @Suppress("UNUSED_PARAMETER") - private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) { - /* - activeSessionHolder.getActiveSession().let { session -> - val room = session.getRoom(roomId) - if (room != null) { - session.coroutineScope.launch { - tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) } - } - } + private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + val isRenderReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isRenderReadReceiptsEnabled().first() + val receiptType = if (isRenderReadReceiptsEnabled) { + ReceiptType.READ + } else { + ReceiptType.READ_PRIVATE } - - */ + client.getRoom(roomId)?.markAsRead(receiptType = receiptType) } @Suppress("UNUSED_PARAMETER") diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt new file mode 100644 index 0000000000..45464bed8e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt @@ -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, + currentUser: MatrixUser, + imageLoader: ImageLoader, + ): List + + @JvmName("toNotificationInvites") + @Suppress("INAPPLICABLE_JVM_NAME") + fun toNotifications(invites: List): List + @JvmName("toNotificationSimpleEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + fun toNotifications(simpleEvents: List): List + fun toNotifications(fallback: List): List + + fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + 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, + currentUser: MatrixUser, + imageLoader: ImageLoader, + ): List { + 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): List { + 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): List { + 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): List { + 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, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + 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, 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 +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt index 04202bbb2f..96b6cac8d3 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt @@ -22,16 +22,25 @@ import android.content.Context import android.content.pm.PackageManager import androidx.core.app.ActivityCompat 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 timber.log.Timber import javax.inject.Inject -class NotificationDisplayer @Inject constructor( - @ApplicationContext private val context: Context, -) { - private val notificationManager = NotificationManagerCompat.from(context) +interface NotificationDisplayer { + fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean + fun cancelNotificationMessage(tag: String?, id: Int) + 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) { Timber.w("Not allowed to notify.") return false @@ -40,20 +49,11 @@ class NotificationDisplayer @Inject constructor( return true } - fun cancelNotificationMessage(tag: String?, id: Int) { + override fun cancelNotificationMessage(tag: String?, id: Int) { notificationManager.cancel(tag, id) } - fun cancelAllNotifications() { - // Keep this try catch (reported by GA) - try { - notificationManager.cancelAll() - } catch (e: Exception) { - Timber.e(e, "## cancelAllNotifications() failed") - } - } - - fun displayDiagnosticNotification(notification: Notification): Boolean { + override fun displayDiagnosticNotification(notification: Notification): Boolean { return showNotificationMessage( tag = "DIAGNOSTIC", id = NOTIFICATION_ID_DIAGNOSTIC, @@ -61,33 +61,17 @@ class NotificationDisplayer @Inject constructor( ) } - fun dismissDiagnosticNotification() { + override fun dismissDiagnosticNotification() { cancelNotificationMessage( tag = "DIAGNOSTIC", id = NOTIFICATION_ID_DIAGNOSTIC ) } - /** - * Cancel the foreground notification service. - */ - fun cancelNotificationForegroundService() { - notificationManager.cancel(NOTIFICATION_ID_FOREGROUND_SERVICE) - } - companion object { /* ========================================================================================== * 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 } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt deleted file mode 100644 index e593f49824..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt +++ /dev/null @@ -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) -> NotificationEventQueue): NotificationEventQueue - fun persistEvents(queuedEvents: NotificationEventQueue) -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt deleted file mode 100644 index c78244356e..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt +++ /dev/null @@ -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, - /** - * 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. - */ - private val seenEventIds: CircularCache -) { - fun markRedacted(eventIds: List) { - 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, roomsJoined: Collection) { - 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) { - 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 = queue -} - -private fun MutableList.replace(eventId: EventId, block: (NotifiableEvent) -> NotifiableEvent) { - val indexToReplace = indexOfFirst { it.eventId == eventId } - if (indexToReplace == -1) { - return - } - set(indexToReplace, block(get(indexToReplace))) -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt deleted file mode 100644 index ef3623f302..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt +++ /dev/null @@ -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> - -class NotificationFactory @Inject constructor( - private val notificationCreator: NotificationCreator, - private val roomGroupMessageCreator: RoomGroupMessageCreator, - private val summaryGroupMessageCreator: SummaryGroupMessageCreator -) { - suspend fun Map.toNotifications( - currentUser: MatrixUser, - imageLoader: ImageLoader, - ): List { - 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>.toNotifications(): List { - 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>.toNotifications(): List { - 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>.toNotifications(): List { - 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, - invitationNotifications: List, - simpleNotifications: List, - fallbackNotifications: List, - useCompleteNotificationFormat: Boolean - ): SummaryNotification { - val roomMeta = roomNotifications.filterIsInstance().map { it.meta } - val invitationMeta = invitationNotifications.filterIsInstance().map { it.meta } - val simpleMeta = simpleNotifications.filterIsInstance().map { it.meta } - val fallbackMeta = fallbackNotifications.filterIsInstance().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 -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt index 2c826abf10..b9f8edea05 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt @@ -18,7 +18,6 @@ package io.element.android.libraries.push.impl.notifications import coil.ImageLoader 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.push.impl.notifications.model.FallbackNotifiableEvent 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( private val notificationIdProvider: NotificationIdProvider, private val notificationDisplayer: NotificationDisplayer, - private val notificationFactory: NotificationFactory, + private val notificationDataFactory: NotificationDataFactory, ) { suspend fun render( currentUser: MatrixUser, useCompleteNotificationFormat: Boolean, - eventsToProcess: List>, + eventsToProcess: List, imageLoader: ImageLoader, ) { val groupedEvents = eventsToProcess.groupByType() - with(notificationFactory) { - val roomNotifications = groupedEvents.roomEvents.toNotifications(currentUser, imageLoader) - val invitationNotifications = groupedEvents.invitationEvents.toNotifications() - val simpleNotifications = groupedEvents.simpleEvents.toNotifications() - val fallbackNotifications = groupedEvents.fallbackEvents.toNotifications() + with(notificationDataFactory) { + val roomNotifications = toNotifications(groupedEvents.roomEvents, currentUser, imageLoader) + val invitationNotifications = toNotifications(groupedEvents.invitationEvents) + val simpleNotifications = toNotifications(groupedEvents.simpleEvents) + val fallbackNotifications = toNotifications(groupedEvents.fallbackEvents) val summaryNotification = createSummaryNotification( currentUser = currentUser, roomNotifications = roomNotifications, @@ -65,101 +64,43 @@ class NotificationRenderer @Inject constructor( ) } - roomNotifications.forEach { wrapper -> - when (wrapper) { - is RoomNotification.Removed -> { - Timber.tag(loggerTag.value).d("Removing room messages notification ${wrapper.roomId}") - notificationDisplayer.cancelNotificationMessage( - 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() - val appendFallback = fallbackNotifications.filterIsInstance() - if (appendFallback.isEmpty() && removedFallback.isNotEmpty()) { - Timber.tag(loggerTag.value).d("Removing global fallback notification") - notificationDisplayer.cancelNotificationMessage( - tag = "FALLBACK", - id = notificationIdProvider.getFallbackNotificationId(currentUser.userId) + roomNotifications.forEach { notificationData -> + notificationDisplayer.showNotificationMessage( + tag = notificationData.roomId.value, + id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId), + notification = notificationData.notification ) - } 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") notificationDisplayer.showNotificationMessage( tag = "FALLBACK", 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>.groupByType(): GroupedNotificationEvents { - val roomIdToEventMap: MutableMap>> = LinkedHashMap() - val simpleEvents: MutableList> = ArrayList() - val invitationEvents: MutableList> = ArrayList() - val fallbackEvents: MutableList> = ArrayList() - forEach { - when (val event = it.event) { - is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType()) - is NotifiableMessageEvent -> { - val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() } - roomEvents.add(it.castedToEventType()) - } - is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType()) - is FallbackNotifiableEvent -> { - fallbackEvents.add(it.castedToEventType()) - } +private fun List.groupByType(): GroupedNotificationEvents { + val roomEvents: MutableList = mutableListOf() + val simpleEvents: MutableList = mutableListOf() + val invitationEvents: MutableList = mutableListOf() + val fallbackEvents: MutableList = mutableListOf() + forEach { event -> + when (event) { + is InviteNotifiableEvent -> invitationEvents.add(event.castedToEventType()) + is NotifiableMessageEvent -> roomEvents.add(event.castedToEventType()) + is SimpleNotifiableEvent -> simpleEvents.add(event.castedToEventType()) + is FallbackNotifiableEvent -> fallbackEvents.add(event.castedToEventType()) } } - return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents, fallbackEvents) + return GroupedNotificationEvents(roomEvents, simpleEvents, invitationEvents, fallbackEvents) } @Suppress("UNCHECKED_CAST") -private fun ProcessedEvent.castedToEventType(): ProcessedEvent = this as ProcessedEvent +private fun NotifiableEvent.castedToEventType(): T = this as T data class GroupedNotificationEvents( - val roomEvents: Map>>, - val simpleEvents: List>, - val invitationEvents: List>, - val fallbackEvents: List>, + val roomEvents: List, + val simpleEvents: List, + val invitationEvents: List, + val fallbackEvents: List, ) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt deleted file mode 100644 index fb19bd76fd..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt +++ /dev/null @@ -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>, -) { - fun updateQueuedEvents( - action: (NotificationEventQueue, List>) -> T - ): T { - return synchronized(queuedEvents) { - action(queuedEvents, renderedEvents) - } - } - - fun clearAndAddRenderedEvents(eventsToRender: List>) { - renderedEvents.clear() - renderedEvents.addAll(eventsToRender) - } - - fun hasAlreadyRendered(eventsToRender: List>) = renderedEvents == eventsToRender - - fun queuedEvents(block: (NotificationEventQueue) -> Unit) { - synchronized(queuedEvents) { - block(queuedEvents) - } - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt deleted file mode 100644 index 52e61a7ec6..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt +++ /dev/null @@ -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 - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt deleted file mode 100644 index 2e91ca3467..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt +++ /dev/null @@ -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( - val type: Type, - val event: T -) { - enum class Type { - KEEP, - REMOVE - } -} - -fun List>.onlyKeptEvents() = mapNotNull { processedEvent -> - processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt index 65930f7652..3285a7ae90 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt @@ -16,48 +16,46 @@ package io.element.android.libraries.push.impl.notifications +import android.app.Notification 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 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.user.MatrixUser 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.isSmartReplyError import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.services.toolbox.api.strings.StringProvider import javax.inject.Inject -class RoomGroupMessageCreator @Inject constructor( - private val bitmapLoader: NotificationBitmapLoader, - private val stringProvider: StringProvider, - private val notificationCreator: NotificationCreator -) { +interface RoomGroupMessageCreator { suspend fun createRoomMessage( currentUser: MatrixUser, events: List, roomId: RoomId, 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, + roomId: RoomId, + imageLoader: ImageLoader, + existingNotification: Notification?, + ): Notification { val lastKnownRoomEvent = events.last() val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderDisambiguatedDisplayName ?: "Room name (${roomId.value.take(8)}…)" 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) { 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 smartReplyErrors = events.filter { it.isSmartReplyError() } - val messageCount = events.size - smartReplyErrors.size - val meta = RoomNotification.Message.Meta( - summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup), - messageCount = messageCount, - latestTimestamp = lastMessageTimestamp, - roomId = roomId, - shouldBing = events.any { it.noisy } - ) - return RoomNotification.Message( - notificationCreator.createMessagesListNotification( - style, + return notificationCreator.createMessagesListNotification( RoomEventGroupInfo( sessionId = currentUser.userId, roomId = roomId, roomDisplayName = roomName, isDirect = !roomIsGroup, hasSmartReplyError = smartReplyErrors.isNotEmpty(), - shouldBing = meta.shouldBing, + shouldBing = events.any { it.noisy }, customSound = events.last().soundName, isUpdated = events.last().isUpdated, ), threadId = lastKnownRoomEvent.threadId, largeIcon = largeBitmap, - lastMessageTimestamp, - tickerText - ), - meta + lastMessageTimestamp = lastMessageTimestamp, + tickerText = tickerText, + currentUser = currentUser, + existingNotification = existingNotification, + imageLoader = imageLoader, + events = events, ) } - private suspend fun NotificationCompat.MessagingStyle.addMessagesFromEvents( - events: List, - 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, 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( events: List, imageLoader: ImageLoader, @@ -184,5 +98,3 @@ class RoomGroupMessageCreator @Inject constructor( ?.let { bitmapLoader.getRoomBitmap(it, imageLoader) } } } - -private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt index 18aadb5de9..19cc4593e6 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt @@ -18,6 +18,8 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification 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.push.impl.R 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 javax.inject.Inject +interface SummaryGroupMessageCreator { + fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + useCompleteNotificationFormat: Boolean + ): Notification +} + /** * ======== Build summary notification ========= * On Android 7.0 (API level 24) and higher, the system automatically builds a summary for * your group using snippets of text from each notification. The user can expand this * notification to see each separate notification. - * To support older versions, which cannot show a nested group of notifications, - * you must create an extra notification that acts as the summary. - * This appears as the only notification and the system hides all the others. - * So this summary should include a snippet from all the other notifications, - * which the user can tap to open your app. * The behavior of the group summary may vary on some device types such as wearables. * To ensure the best experience on all devices and versions, always include a group summary when you create a group * https://developer.android.com/training/notify-user/group */ -class SummaryGroupMessageCreator @Inject constructor( +@ContributesBinding(AppScope::class) +class DefaultSummaryGroupMessageCreator @Inject constructor( private val stringProvider: StringProvider, private val notificationCreator: NotificationCreator, -) { - fun createSummaryNotification( +) : SummaryGroupMessageCreator { + override fun createSummaryNotification( currentUser: MatrixUser, - roomNotifications: List, - invitationNotifications: List, - simpleNotifications: List, - fallbackNotifications: List, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, useCompleteNotificationFormat: Boolean ): Notification { val summaryInboxStyle = NotificationCompat.InboxStyle().also { style -> diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt index c66e530837..8c74f845b7 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt @@ -36,10 +36,9 @@ import javax.inject.Inject @SingleIn(AppScope::class) class NotificationChannels @Inject constructor( @ApplicationContext private val context: Context, + private val notificationManager: NotificationManagerCompat, private val stringProvider: StringProvider, ) { - private val notificationManager = NotificationManagerCompat.from(context) - init { createNotificationChannels() } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt index 434504dd69..d1851d44a2 100755 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt @@ -22,26 +22,80 @@ import android.graphics.Bitmap import android.graphics.Canvas import androidx.annotation.DrawableRes 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.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.designsystem.utils.CommonDrawables +import io.element.android.libraries.di.AppScope 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.user.MatrixUser 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.channels.NotificationChannels 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.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.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 -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, + ): 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, private val notificationChannels: NotificationChannels, private val stringProvider: StringProvider, @@ -49,17 +103,23 @@ class NotificationCreator @Inject constructor( private val pendingIntentFactory: PendingIntentFactory, private val markAsReadActionFactory: MarkAsReadActionFactory, private val quickReplyActionFactory: QuickReplyActionFactory, -) { + private val bitmapLoader: NotificationBitmapLoader, + private val acceptInvitationActionFactory: AcceptInvitationActionFactory, + private val rejectInvitationActionFactory: RejectInvitationActionFactory +) : NotificationCreator { /** * Create a notification for a Room. */ - fun createMessagesListNotification( - messageStyle: NotificationCompat.MessagingStyle, + override suspend fun createMessagesListNotification( roomInfo: RoomEventGroupInfo, threadId: ThreadId?, largeIcon: Bitmap?, lastMessageTimestamp: Long, - tickerText: String + tickerText: String, + currentUser: MatrixUser, + existingNotification: Notification?, + imageLoader: ImageLoader, + events: List, ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) // 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 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) .setWhen(lastMessageTimestamp) // MESSAGING_STYLE sets title and content for API 16 and above devices. - .setStyle(messageStyle) - // 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) + .setStyle(messagingStyle) + // Not needed anymore? // Title for API < 16 devices. .setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1)) // Content for API < 16 devices. @@ -90,15 +172,10 @@ class NotificationCreator @Inject constructor( .setSubText( stringProvider.getQuantityString( R.plurals.notification_new_messages_for_room, - messageStyle.messages.size, - messageStyle.messages.size + messagingStyle.messages.size, + messagingStyle.messages.size ).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) // Set primary color (important for Wear 2.0 Notifications). .setColor(accentColor) @@ -118,7 +195,8 @@ class NotificationCreator @Inject constructor( } else { priority = NotificationCompat.PRIORITY_LOW } - + // Clear existing actions since we might be updating an existing notification + clearActions() // Add actions and notification intents // Mark room as read addAction(markAsReadActionFactory.create(roomInfo)) @@ -134,11 +212,11 @@ class NotificationCreator @Inject constructor( } setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)) } - .setTicker(tickerText.annotateForDebug(4)) + .setTicker(tickerText) .build() } - fun createRoomInvitationNotification( + override fun createRoomInvitationNotification( inviteNotifiableEvent: InviteNotifiableEvent ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) @@ -152,10 +230,11 @@ class NotificationCreator @Inject constructor( .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setSmallIcon(smallIcon) .setColor(accentColor) - // TODO removed for now, will be added back later -// .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) -// .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) .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 setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId)) @@ -182,7 +261,7 @@ class NotificationCreator @Inject constructor( .build() } - fun createSimpleEventNotification( + override fun createSimpleEventNotification( simpleNotifiableEvent: SimpleNotifiableEvent, ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) @@ -212,12 +291,11 @@ class NotificationCreator @Inject constructor( } else { priority = NotificationCompat.PRIORITY_LOW } - setAutoCancel(true) } .build() } - fun createFallbackNotification( + override fun createFallbackNotification( fallbackNotifiableEvent: FallbackNotifiableEvent, ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) @@ -244,17 +322,14 @@ class NotificationCreator @Inject constructor( fallbackNotifiableEvent.eventId ) ) - .apply { - priority = NotificationCompat.PRIORITY_LOW - setAutoCancel(true) - } + .setPriority(NotificationCompat.PRIORITY_LOW) .build() } /** * Create the summary notification. */ - fun createSummaryListNotification( + override fun createSummaryListNotification( currentUser: MatrixUser, style: NotificationCompat.InboxStyle?, compatSummary: String, @@ -298,7 +373,7 @@ class NotificationCreator @Inject constructor( .build() } - fun createDiagnosticNotification(): Notification { + override fun createDiagnosticNotification(): Notification { val intent = pendingIntentFactory.createTestPendingIntent() return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest()) .setContentTitle(buildMeta.applicationName) @@ -314,6 +389,61 @@ class NotificationCreator @Inject constructor( .build() } + private suspend fun MessagingStyle.addMessagesFromEvents( + events: List, + 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? { val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null val canvas = Canvas() @@ -324,3 +454,5 @@ class NotificationCreator @Inject constructor( return bitmap } } + +fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt index 53af2c71d5..56fb952c24 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt @@ -46,21 +46,20 @@ class QuickReplyActionFactory @Inject constructor( if (!NotificationConfig.SUPPORT_QUICK_REPLY_ACTION) return null val sessionId = roomInfo.sessionId val roomId = roomInfo.roomId - return buildQuickReplyIntent(sessionId, roomId, threadId)?.let { replyPendingIntent -> - val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) - .setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply)) - .build() + val replyPendingIntent = buildQuickReplyIntent(sessionId, roomId, threadId) + val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) + .setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply)) + .build() - NotificationCompat.Action.Builder( - R.drawable.vector_notification_quick_reply, - stringProvider.getString(R.string.notification_room_action_quick_reply), - replyPendingIntent - ) - .addRemoteInput(remoteInput) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) - .setShowsUserInterface(false) - .build() - } + return NotificationCompat.Action.Builder( + R.drawable.vector_notification_quick_reply, + stringProvider.getString(R.string.notification_room_action_quick_reply), + replyPendingIntent + ) + .addRemoteInput(remoteInput) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .setShowsUserInterface(false) + .build() } /* @@ -74,30 +73,26 @@ class QuickReplyActionFactory @Inject constructor( sessionId: SessionId, roomId: RoomId, threadId: ThreadId?, - ): PendingIntent? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - val intent = Intent(context, NotificationBroadcastReceiver::class.java) - intent.action = actionIds.smartReply - intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty()) - intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value) - intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value) - threadId?.let { - 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 + ): PendingIntent { + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.smartReply + intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty()) + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value) + threadId?.let { + intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value) } + + 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 + } + ) } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt index 6d4ad34eb8..cdcc6a1d93 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt @@ -20,6 +20,8 @@ import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import javax.inject.Inject interface OnNotifiableEventReceived { @@ -29,8 +31,11 @@ interface OnNotifiableEventReceived { @ContributesBinding(AppScope::class) class DefaultOnNotifiableEventReceived @Inject constructor( private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, + private val coroutineScope: CoroutineScope, ) : OnNotifiableEventReceived { override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { - defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent) + coroutineScope.launch { + defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent) + } } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt new file mode 100644 index 0000000000..31daf9e4cc --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt @@ -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 { + every { this@mockk.id } returns id + every { this@mockk.groupKey } returns groupId + every { this@mockk.tag } returns tag + } + + private fun createActiveNotificationsProvider( + activeNotifications: List = emptyList(), + ): DefaultActiveNotificationsProvider { + val notificationManager = mockk { + every { this@mockk.activeNotifications } returns activeNotifications + } + return DefaultActiveNotificationsProvider( + notificationManager = notificationManager, + notificationIdProvider = NotificationIdProvider(), + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index e3220f8dfa..3924171d60 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -16,25 +16,36 @@ 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.A_ROOM_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_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.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.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.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.model.NotifiableEvent import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService 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 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.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope @@ -58,16 +69,29 @@ class DefaultNotificationDrawerManagerTest { @Test fun `cover all APIs`() = runTest { // For now just call all the API. Later, add more valuable tests. - val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager() - defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID, doRender = true) - defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID, doRender = false) - defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID, doRender = true) - defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID, doRender = false) - defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID, doRender = true) - defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID, doRender = false) + val matrixUser = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice", avatarUrl = "mxc://data") + val mockRoomGroupMessageCreator = FakeRoomGroupMessageCreator( + createRoomMessageResult = lambdaRecorder { user, _, roomId, _, existingNotification, -> + assertThat(user).isEqualTo(matrixUser) + assertThat(roomId).isEqualTo(A_ROOM_ID) + assertThat(existingNotification).isNull() + 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.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID, doRender = true) - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID, doRender = false) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) // Add the same Event again (will be ignored) defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) @@ -103,33 +127,93 @@ class DefaultNotificationDrawerManagerTest { 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 { + 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( + notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(RuntimeEnvironment.getApplication()), appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), - initialData: List = emptyList() + roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(), + summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(), + activeNotificationsProvider: FakeActiveNotificationsProvider = FakeActiveNotificationsProvider(), + matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), ): DefaultNotificationDrawerManager { val context = RuntimeEnvironment.getApplication() return DefaultNotificationDrawerManager( - notifiableEventProcessor = NotifiableEventProcessor( - outdatedDetector = OutdatedEventDetector(), - appNavigationStateService = appNavigationStateService - ), + notificationManager = notificationManager, notificationRenderer = NotificationRenderer( notificationIdProvider = NotificationIdProvider(), - notificationDisplayer = NotificationDisplayer(context), - notificationFactory = NotificationFactory( - notificationCreator = MockkNotificationCreator().instance, - roomGroupMessageCreator = MockkRoomGroupMessageCreator().instance, - summaryGroupMessageCreator = MockkSummaryGroupMessageCreator().instance, - ) + notificationDisplayer = DefaultNotificationDisplayer(context, NotificationManagerCompat.from(context)), + notificationDataFactory = DefaultNotificationDataFactory( + notificationCreator = FakeNotificationCreator(), + roomGroupMessageCreator = roomGroupMessageCreator, + summaryGroupMessageCreator = summaryGroupMessageCreator, + activeNotificationsProvider = activeNotificationsProvider, + stringProvider = FakeStringProvider(), + ), ), - notificationEventPersistence = InMemoryNotificationEventPersistence(initialData = initialData), - filteredEventDetector = FilteredEventDetector(), + notificationIdProvider = NotificationIdProvider(), appNavigationStateService = appNavigationStateService, coroutineScope = this, - dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), - buildMeta = aBuildMeta(), - matrixClientProvider = FakeMatrixClientProvider(), + matrixClientProvider = matrixClientProvider, imageLoaderHolder = FakeImageLoaderHolder(), + activeNotificationsProvider = activeNotificationsProvider, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistenceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistenceTest.kt deleted file mode 100644 index ce984c712f..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistenceTest.kt +++ /dev/null @@ -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) - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt similarity index 69% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreatorTest.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt index 399b0fc4b3..8a8b5efd43 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt @@ -18,7 +18,7 @@ package io.element.android.libraries.push.impl.notifications import android.content.Context import android.os.Build -import coil.annotation.ExperimentalCoilApi +import androidx.core.app.NotificationCompat import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.media.MediaSource 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" @RunWith(RobolectricTestRunner::class) -class RoomGroupMessageCreatorTest { +class DefaultRoomGroupMessageCreatorTest { @Test fun `test createRoomMessage with one Event`() = runTest { val sut = createRoomGroupMessageCreator() @@ -56,19 +56,12 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - 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(result.number).isEqualTo(1) + @Suppress("DEPRECATION") + assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_LOW) + assertThat(result.`when`).isEqualTo(A_TIMESTAMP) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } @@ -85,19 +78,10 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "room-name: sender-name message-body", - messageCount = 1, - latestTimestamp = A_TIMESTAMP, - shouldBing = true, - ) - ) + @Suppress("DEPRECATION") + assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } @@ -115,7 +99,6 @@ class RoomGroupMessageCreatorTest { ) } - @OptIn(ExperimentalCoilApi::class) @Test fun `test createRoomMessage with room avatar and sender avatar android P`() = runTest { `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`( api: Int, expectedCoilRequests: List, @@ -160,20 +142,10 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - 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) + assertThat(result.number).isEqualTo(1) + assertThat(fakeImageLoader.getCoilRequests()).containsExactlyElementsIn(expectedCoilRequests) } @Test @@ -188,19 +160,10 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "room-name: 2 messages", - messageCount = 2, - latestTimestamp = A_TIMESTAMP + 10, - shouldBing = false, - ) - ) + assertThat(result.number).isEqualTo(2) + assertThat(result.`when`).isEqualTo(A_TIMESTAMP + 10) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } @@ -218,19 +181,9 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - 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(result.actions).isNull() assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } @@ -247,19 +200,10 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "sender-name: message-body", - messageCount = 1, - latestTimestamp = A_TIMESTAMP, - shouldBing = false, - ) - ) + assertThat(result.number).isEqualTo(1) + assertThat(result.`when`).isEqualTo(A_TIMESTAMP) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } } @@ -268,12 +212,13 @@ fun createRoomGroupMessageCreator( sdkIntProvider: BuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.O), ): RoomGroupMessageCreator { val context = RuntimeEnvironment.getApplication() as Context - return RoomGroupMessageCreator( - notificationCreator = createNotificationCreator(), - bitmapLoader = NotificationBitmapLoader( - context = RuntimeEnvironment.getApplication(), - sdkIntProvider = sdkIntProvider, - ), + val bitmapLoader = NotificationBitmapLoader( + context = RuntimeEnvironment.getApplication(), + sdkIntProvider = sdkIntProvider, + ) + return DefaultRoomGroupMessageCreator( + notificationCreator = createNotificationCreator(bitmapLoader = bitmapLoader), + bitmapLoader = bitmapLoader, stringProvider = AndroidStringProvider(context.resources) ) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt new file mode 100644 index 0000000000..190ae9330a --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt @@ -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) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/InMemoryNotificationEventPersistence.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/InMemoryNotificationEventPersistence.kt deleted file mode 100644 index 09f907f50b..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/InMemoryNotificationEventPersistence.kt +++ /dev/null @@ -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 = emptyList() -) : NotificationEventPersistence { - private var data: List = initialData - - override fun loadEvents(factory: (List) -> NotificationEventQueue): NotificationEventQueue { - return factory(data) - } - - override fun persistEvents(queuedEvents: NotificationEventQueue) { - data = queuedEvents.rawEvents() - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt deleted file mode 100644 index a8626766e5..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt +++ /dev/null @@ -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(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) = 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))), - ) - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt new file mode 100644 index 0000000000..aff5de7d4b --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt @@ -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 testWith(receiver: T, block: suspend T.() -> Unit) { + runTest { + receiver.block() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt deleted file mode 100644 index 7cc2687207..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt +++ /dev/null @@ -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(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) = NotificationEventQueue(events.toMutableList(), seenEventIds = seenIdsCache) -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt deleted file mode 100644 index 6a211fc446..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt +++ /dev/null @@ -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 testWith(receiver: T, block: suspend T.() -> Unit) { - runTest { - receiver.block() - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt index 21fc1b4fca..0a5b216ea0 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt @@ -16,16 +16,24 @@ 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.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.MockkNotificationDisplayer -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationFactory +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +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.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 org.junit.Test 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 USE_COMPLETE_NOTIFICATION_FORMAT = true -private val AN_EVENT_LIST = listOf>() -private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList(), emptyList()) -private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk()) -private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed -private val A_NOTIFICATION = mockk() -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) +private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(A_NOTIFICATION) +private val ONE_SHOT_NOTIFICATION = + OneShotNotification(notification = A_NOTIFICATION, key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) @RunWith(RobolectricTestRunner::class) class NotificationRendererTest { - private val mockkNotificationDisplayer = MockkNotificationDisplayer() - private val mockkNotificationFactory = MockkNotificationFactory() + private val notificationDisplayer = FakeNotificationDisplayer() + + 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 notificationRenderer = NotificationRenderer( notificationIdProvider = notificationIdProvider, - notificationDisplayer = mockkNotificationDisplayer.instance, - notificationFactory = mockkNotificationFactory.instance, + notificationDisplayer = notificationDisplayer, + notificationDataFactory = notificationDataFactory, ) @Test fun `given no notifications when rendering then cancels summary notification`() = runTest { - givenNoNotifications() + renderEventsAsNotifications(emptyList()) - renderEventsAsNotifications() - - 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) - } + notificationDisplayer.verifySummaryCancelled() } @Test fun `given a room message group notification is added when rendering then show the message notification and update summary`() = runTest { - givenNotifications( - roomNotifications = listOf( - RoomNotification.Message( - A_NOTIFICATION, - MESSAGE_META - ) - ) + roomGroupMessageCreator.createRoomMessageResult = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION } + + renderEventsAsNotifications(listOf(aNotifiableMessageEvent())) + + 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 fun `given a simple notification is added when rendering then show the simple notification and update summary`() = runTest { - givenNotifications( - simpleNotifications = listOf( - OneShotNotification.Append( - A_NOTIFICATION, - ONE_SHOT_META.copy(key = AN_EVENT_ID.value) - ) - ) + notificationCreator.createSimpleNotificationResult = lambdaRecorder { _ -> ONE_SHOT_NOTIFICATION.copy(key = AN_EVENT_ID.value).notification } + + renderEventsAsNotifications(listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID))) + + 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 fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() = runTest { - givenNotifications( - simpleNotifications = listOf( - OneShotNotification.Append( - A_NOTIFICATION, - ONE_SHOT_META.copy(key = A_ROOM_ID.value) - ) - ) + notificationCreator.createRoomInvitationNotificationResult = lambdaRecorder { _ -> ONE_SHOT_NOTIFICATION.copy(key = AN_EVENT_ID.value).notification } + + renderEventsAsNotifications(listOf(anInviteNotifiableEvent())) + + 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) { notificationRenderer.render( MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL), useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT, - eventsToProcess = AN_EVENT_LIST, + eventsToProcess = events, imageLoader = FakeImageLoader().getImageLoader(), ) } - - private fun givenNoNotifications() { - givenNotifications(emptyList(), emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION) - } - - private fun givenNotifications( - roomNotifications: List = emptyList(), - invitationNotifications: List = emptyList(), - simpleNotifications: List = emptyList(), - fallbackNotifications: List = 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 - ) - } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt similarity index 84% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt index edf87fcd1a..7af19d66a7 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt @@ -18,8 +18,9 @@ package io.element.android.libraries.push.impl.notifications.factories import android.app.Notification import android.content.Context +import android.os.Build import androidx.core.app.NotificationCompat -import androidx.core.app.Person +import androidx.core.app.NotificationManagerCompat import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.meta.BuildMeta 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.ui.components.aMatrixUser 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.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.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.InviteNotifiableEvent 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.systemclock.A_FAKE_TIMESTAMP import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) -class NotificationCreatorTest { +class DefaultNotificationCreatorTest { @Test fun `test createDiagnosticNotification`() { val sut = createNotificationCreator() @@ -212,15 +219,10 @@ class NotificationCreatorTest { } @Test - fun `test createMessagesListNotification`() { + fun `test createMessagesListNotification`() = runTest { val sut = createNotificationCreator() aMatrixUser() val result = sut.createMessagesListNotification( - messageStyle = NotificationCompat.MessagingStyle( - Person.Builder() - .setName("name") - .build() - ), roomInfo = RoomEventGroupInfo( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -235,20 +237,19 @@ class NotificationCreatorTest { largeIcon = null, lastMessageTimestamp = 123_456L, tickerText = "tickerText", + currentUser = aMatrixUser(), + existingNotification = null, + imageLoader = FakeImageLoader().getImageLoader(), + events = emptyList(), ) result.commonAssertions() } @Test - fun `test createMessagesListNotification should bing and thread`() { + fun `test createMessagesListNotification should bing and thread`() = runTest { val sut = createNotificationCreator() aMatrixUser() val result = sut.createMessagesListNotification( - messageStyle = NotificationCompat.MessagingStyle( - Person.Builder() - .setName("name") - .build() - ), roomInfo = RoomEventGroupInfo( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -263,6 +264,10 @@ class NotificationCreatorTest { largeIcon = null, lastMessageTimestamp = 123_456L, tickerText = "tickerText", + currentUser = aMatrixUser(), + existingNotification = null, + imageLoader = FakeImageLoader().getImageLoader(), + events = emptyList(), ) result.commonAssertions() } @@ -280,9 +285,10 @@ class NotificationCreatorTest { fun createNotificationCreator( context: Context = RuntimeEnvironment.getApplication(), buildMeta: BuildMeta = aBuildMeta(), - notificationChannels: NotificationChannels = createNotificationChannels() + notificationChannels: NotificationChannels = createNotificationChannels(), + bitmapLoader: NotificationBitmapLoader = NotificationBitmapLoader(context, FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.R)), ): NotificationCreator { - return NotificationCreator( + return DefaultNotificationCreator( context = context, notificationChannels = notificationChannels, stringProvider = FakeStringProvider("test"), @@ -305,10 +311,23 @@ fun createNotificationCreator( stringProvider = FakeStringProvider("QuickReplyActionFactory"), 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 { val context = RuntimeEnvironment.getApplication() - return NotificationChannels(context, FakeStringProvider("")) + return NotificationChannels(context, NotificationManagerCompat.from(context), FakeStringProvider("")) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt new file mode 100644 index 0000000000..680688d3dc --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt @@ -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 = mutableListOf(), +) : ActiveNotificationsProvider { + override fun getAllNotifications(): List { + return activeNotifications + } + + override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List { + return activeNotifications + } + + override fun getNotificationsForSession(sessionId: SessionId): List { + return activeNotifications + } + + override fun getMembershipNotificationForSession(sessionId: SessionId): List { + return activeNotifications + } + + override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List { + return activeNotifications + } + + override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? { + return activeNotifications.firstOrNull() + } + + override fun count(sessionId: SessionId): Int { + return activeNotifications.size + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt new file mode 100644 index 0000000000..bf20fef14e --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt @@ -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 = lambdaAnyRecorder { A_NOTIFICATION }, + var createRoomInvitationNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, + var createSimpleNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, + var createFallbackNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, + var createSummaryListNotificationResult: LambdaFiveParamsRecorder = + lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }, + var createDiagnosticNotificationResult: LambdaNoParamRecorder = 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 + ): 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() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt new file mode 100644 index 0000000000..6cf4196bb5 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt @@ -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, MatrixUser, ImageLoader, List> = + lambdaRecorder { _, _, _ -> emptyList() }, + var summaryToNotificationsResult: LambdaSixParamsRecorder< + MatrixUser, + List, + List, + List, + List, + Boolean, + SummaryNotification + > = lambdaRecorder { _, _, _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) }, + var inviteToNotificationsResult: LambdaOneParamRecorder, List> = lambdaRecorder { _ -> emptyList() }, + var simpleEventToNotificationsResult: LambdaOneParamRecorder, List> = lambdaRecorder { _ -> emptyList() }, + var fallbackEventToNotificationsResult: LambdaOneParamRecorder, List> = + lambdaRecorder { _ -> emptyList() }, +) : NotificationDataFactory { + override suspend fun toNotifications(messages: List, currentUser: MatrixUser, imageLoader: ImageLoader): List { + return messageEventToNotificationsResult(messages, currentUser, imageLoader) + } + + @JvmName("toNotificationInvites") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications(invites: List): List { + return inviteToNotificationsResult(invites) + } + + @JvmName("toNotificationSimpleEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications(simpleEvents: List): List { + return simpleEventToNotificationsResult(simpleEvents) + } + + override fun toNotifications(fallback: List): List { + return fallbackEventToNotificationsResult(fallback) + } + + override fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + useCompleteNotificationFormat: Boolean + ): SummaryNotification { + return summaryToNotificationsResult( + currentUser, + roomNotifications, + invitationNotifications, + simpleNotifications, + fallbackNotifications, + useCompleteNotificationFormat + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt new file mode 100644 index 0000000000..c8c041720c --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt @@ -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 = lambdaRecorder { _, _, _ -> true }, + var cancelNotificationMessageResult: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> }, + var displayDiagnosticNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> true }, + var dismissDiagnosticNotificationResult: LambdaNoParamRecorder = 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))) + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkRoomGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt similarity index 55% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkRoomGroupMessageCreator.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt index 389a4f441d..c0e1692775 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkRoomGroupMessageCreator.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt @@ -16,31 +16,27 @@ 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.user.MatrixUser 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.mockk.coEvery -import io.mockk.mockk +import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder -class MockkRoomGroupMessageCreator { - val instance = mockk() - - fun givenCreatesRoomMessageFor( - matrixUser: MatrixUser, +class FakeRoomGroupMessageCreator( + var createRoomMessageResult: LambdaFiveParamsRecorder, RoomId, ImageLoader, Notification?, Notification> = + lambdaRecorder { _, _, _, _, _, -> A_NOTIFICATION } +) : RoomGroupMessageCreator { + override suspend fun createRoomMessage( + currentUser: MatrixUser, events: List, roomId: RoomId, - ): RoomNotification.Message { - val mockMessage = mockk() - coEvery { - instance.createRoomMessage( - currentUser = matrixUser, - events = events, - roomId = roomId, - imageLoader = any(), - ) - } returns mockMessage - return mockMessage + imageLoader: ImageLoader, + existingNotification: Notification? + ): Notification { + return createRoomMessageResult(currentUser, events, roomId, imageLoader, existingNotification) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt new file mode 100644 index 0000000000..08083ceb18 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt @@ -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, List, List, List, Boolean, Notification + > = + lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION } +) : SummaryGroupMessageCreator { + override fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + useCompleteNotificationFormat: Boolean + ): Notification { + return createSummaryNotificationResult( + currentUser, + roomNotifications, + invitationNotifications, + simpleNotifications, + fallbackNotifications, + useCompleteNotificationFormat + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationCreator.kt deleted file mode 100644 index 205ba058e6..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationCreator.kt +++ /dev/null @@ -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() - - fun givenCreateRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification { - val mockNotification = mockk() - every { instance.createRoomInvitationNotification(event) } returns mockNotification - return mockNotification - } - - fun givenCreateSimpleInvitationNotificationFor(event: SimpleNotifiableEvent): Notification { - val mockNotification = mockk() - every { instance.createSimpleEventNotification(event) } returns mockNotification - return mockNotification - } - - fun givenCreateDiagnosticNotification(): Notification { - val mockNotification = mockk() - every { instance.createDiagnosticNotification() } returns mockNotification - return mockNotification - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationDisplayer.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationDisplayer.kt deleted file mode 100644 index dc55cecfac..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationDisplayer.kt +++ /dev/null @@ -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(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() - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationFactory.kt deleted file mode 100644 index 6a8410d2cb..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationFactory.kt +++ /dev/null @@ -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() - - fun givenNotificationsFor( - groupedEvents: GroupedNotificationEvents, - matrixUser: MatrixUser, - useCompleteNotificationFormat: Boolean, - roomNotifications: List, - invitationNotifications: List, - simpleNotifications: List, - fallbackNotifications: List, - 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 - } - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkOutdatedEventDetector.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkOutdatedEventDetector.kt deleted file mode 100644 index 414f7ae652..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkOutdatedEventDetector.kt +++ /dev/null @@ -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() - - fun givenEventIsOutOfDate(notifiableEvent: NotifiableEvent) { - every { instance.isMessageOutdated(notifiableEvent) } returns true - } - - fun givenEventIsInDate(notifiableEvent: NotifiableEvent) { - every { instance.isMessageOutdated(notifiableEvent) } returns false - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkSummaryGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationFixture.kt similarity index 64% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkSummaryGroupMessageCreator.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationFixture.kt index 8f99651c89..797665ea78 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkSummaryGroupMessageCreator.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationFixture.kt @@ -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"); * you may not use this file except in compliance with the License. @@ -14,11 +14,8 @@ * 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 io.mockk.mockk +import android.app.Notification -class MockkSummaryGroupMessageCreator { - val instance = mockk() -} +val A_NOTIFICATION = Notification() diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt index 1351117527..1f5b1c43db 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt @@ -18,27 +18,27 @@ package io.element.android.libraries.push.impl.troubleshoot import app.cash.turbine.test 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.MockkNotificationDisplayer +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.Test class NotificationTestTest { - private val mockkNotificationCreator = MockkNotificationCreator().apply { - givenCreateDiagnosticNotification() - } - private val mockkNotificationDisplayer = MockkNotificationDisplayer().apply { - givenDisplayDiagnosticNotificationResult(true) - } + private val notificationCreator = FakeNotificationCreator() + private val fakeNotificationDisplayer = FakeNotificationDisplayer( + displayDiagnosticNotificationResult = lambdaRecorder { _ -> true }, + dismissDiagnosticNotificationResult = lambdaRecorder { -> } + ) private val notificationClickHandler = NotificationClickHandler() @Test fun `test NotificationTest notification cannot be displayed`() = runTest { - mockkNotificationDisplayer.givenDisplayDiagnosticNotificationResult(false) + fakeNotificationDisplayer.displayDiagnosticNotificationResult = lambdaRecorder { _ -> false } val sut = createNotificationTest() launch { sut.run(this) @@ -81,8 +81,8 @@ class NotificationTestTest { private fun createNotificationTest(): NotificationTest { return NotificationTest( - notificationCreator = mockkNotificationCreator.instance, - notificationDisplayer = mockkNotificationDisplayer.instance, + notificationCreator = notificationCreator, + notificationDisplayer = fakeNotificationDisplayer, notificationClickHandler = notificationClickHandler, stringProvider = FakeStringProvider(), ) diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt index 702e46c3ae..1531d2df48 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt @@ -28,7 +28,7 @@ class FakeNotificationDrawerManager : NotificationDrawerManager { 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) clearMemberShipNotificationForRoomCallsCount.merge(key, 1) { oldValue, value -> oldValue + value } } diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 9561332c74..9e3d157c6c 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -63,7 +63,7 @@ object Versions { val versionName = "$versionMajor.$versionMinor.$versionPatch" const val compileSdk = 34 const val targetSdk = 33 - const val minSdk = 23 + const val minSdk = 24 val javaCompileVersion = JavaVersion.VERSION_17 val javaLanguageVersion: JavaLanguageVersion = JavaLanguageVersion.of(11) } diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/LambdaRecorder.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/LambdaRecorder.kt index 8455d34ef6..b227918461 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/LambdaRecorder.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/LambdaRecorder.kt @@ -78,6 +78,20 @@ inline fun lambdaRecorder( + ensureNeverCalled: Boolean = false, + noinline block: (T1, T2, T3, T4, T5, T6) -> R +): LambdaSixParamsRecorder { + return LambdaSixParamsRecorder(ensureNeverCalled, block) +} + +inline fun lambdaAnyRecorder( + ensureNeverCalled: Boolean = false, + noinline block: (List) -> R +): LambdaListAnyParamsRecorder { + return LambdaListAnyParamsRecorder(ensureNeverCalled, block) +} + class LambdaNoParamRecorder(ensureNeverCalled: Boolean, val block: () -> R) : LambdaRecorder(ensureNeverCalled), () -> R { override fun invoke(): R { onInvoke() @@ -125,3 +139,53 @@ class LambdaFiveParamsRecorder(ensureN return block(p1, p2, p3, p4, p5) } } + +class LambdaSixParamsRecorder( + 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( + 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( + 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( + 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( + ensureNeverCalled: Boolean, + val block: (List) -> R, +) : LambdaRecorder(ensureNeverCalled), (List) -> R { + override fun invoke(p: List): R { + onInvoke(*p.toTypedArray()) + return block(p) + } +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/ParameterMatcher.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/ParameterMatcher.kt index dd509ed262..c6c5adbb4c 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/ParameterMatcher.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/ParameterMatcher.kt @@ -41,3 +41,12 @@ fun any() = object : ParameterMatcher { override fun match(param: Any?) = true 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()" +}