diff --git a/build.gradle.kts b/build.gradle.kts index a2dbec823c..e29f7c393b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -236,11 +236,11 @@ koverMerged { name = "Global minimum code coverage." target = kotlinx.kover.api.VerificationTarget.ALL bound { - minValue = 60 + minValue = 65 // Setting a max value, so that if coverage is bigger, it means that we have to change minValue. // For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update // minValue to 25 and maxValue to 35. - maxValue = 70 + maxValue = 75 counter = kotlinx.kover.api.CounterType.INSTRUCTION valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 96edaf737d..c469499264 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -136,6 +136,7 @@ test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = " coil = { module = "io.coil-kt:coil", version.ref = "coil" } coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" } +coil_test = { module = "io.coil-kt:coil-test", version.ref = "coil" } compound = { module = "io.element.android:compound-android", version = "0.0.1" } datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index 08155b2a34..42eb81d4a7 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -16,8 +16,6 @@ package io.element.android.libraries.androidutils.system -import android.annotation.TargetApi -import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent @@ -104,19 +102,6 @@ fun Context.openAppSettingsPage( } } -/** - * Shows notification system settings for the given channel id. - */ -@TargetApi(Build.VERSION_CODES.O) -fun Activity.startNotificationChannelSettingsIntent(channelID: String) { - if (!supportNotificationChannels()) return - val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, packageName) - putExtra(Settings.EXTRA_CHANNEL_ID, channelID) - } - startActivity(intent) -} - @RequiresApi(Build.VERSION_CODES.O) fun Context.startInstallFromSourceIntent( activityResultLauncher: ActivityResultLauncher, diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 6c765fc44f..f72a0886a0 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -65,8 +65,10 @@ dependencies { testImplementation(libs.test.mockk) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) + testImplementation(libs.coil.test) testImplementation(libs.coroutines.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.toolbox.impl) testImplementation(projects.services.toolbox.test) 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 f2e3240203..09f622e978 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 @@ -36,6 +36,7 @@ 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 @@ -61,6 +62,8 @@ class DefaultNotificationDrawerManager @Inject constructor( private val buildMeta: BuildMeta, private val matrixClientProvider: MatrixClientProvider, ) : 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. */ @@ -72,12 +75,17 @@ class DefaultNotificationDrawerManager @Inject constructor( init { // Observe application state - coroutineScope.launch { + appNavigationStateObserver = coroutineScope.launch { appNavigationStateService.appNavigationState .collect { onAppNavigationStateChange(it.navigationState) } } } + // For test only + fun destroy() { + appNavigationStateObserver?.cancel() + } + private var currentAppNavigationState: NavigationState? = null private fun onAppNavigationStateChange(navigationState: NavigationState) { 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 new file mode 100644 index 0000000000..3646837937 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistence.kt @@ -0,0 +1,94 @@ +/* + * 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/NotificationBitmapLoader.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt index 1ad38bf787..e78ce37639 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt @@ -27,11 +27,13 @@ import coil.transform.CircleCropTransformation import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import timber.log.Timber import javax.inject.Inject class NotificationBitmapLoader @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val sdkIntProvider: BuildVersionSdkIntProvider, ) { /** @@ -65,7 +67,7 @@ class NotificationBitmapLoader @Inject constructor( * @param path mxc url */ suspend fun getUserIcon(path: String?): IconCompat? { - if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + if (path == null || sdkIntProvider.get() < Build.VERSION_CODES.P) { return null } 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 index d1aee8c0b2..e593f49824 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 New Vector Ltd + * 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. @@ -16,76 +16,9 @@ package io.element.android.libraries.push.impl.notifications -import android.content.Context -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.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) - -class NotificationEventPersistence @Inject constructor( - @ApplicationContext private val context: Context, -) { - private val file by lazy { - deleteLegacyFileIfAny() - context.getDatabasePath(FILE_NAME) - } - - private val encryptedFile by lazy { - EncryptedFileFactory(context).create(file) - } - - 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()) - } - - 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() - } - } +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 index 8eea6a9d5a..bb76de47d2 100644 --- 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 @@ -28,7 +28,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableMess import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import timber.log.Timber -data class NotificationEventQueue constructor( +data class NotificationEventQueue( private val queue: MutableList, /** * An in memory FIFO cache of the seen events. 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 index 859bff17cf..15c236eac9 100644 --- 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 @@ -19,7 +19,7 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification 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.NotificationFactory +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 @@ -28,9 +28,8 @@ import javax.inject.Inject private typealias ProcessedMessageEvents = List> -// TODO Find a better name, it clashes with io.element.android.libraries.push.impl.notifications.factories.NotificationFactory class NotificationFactory @Inject constructor( - private val notificationFactory: NotificationFactory, + private val notificationCreator: NotificationCreator, private val roomGroupMessageCreator: RoomGroupMessageCreator, private val summaryGroupMessageCreator: SummaryGroupMessageCreator ) { @@ -65,7 +64,7 @@ class NotificationFactory @Inject constructor( when (processed) { ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId.value) ProcessedEvent.Type.KEEP -> OneShotNotification.Append( - notificationFactory.createRoomInvitationNotification(event), + notificationCreator.createRoomInvitationNotification(event), OneShotNotification.Append.Meta( key = event.roomId.value, summaryLine = event.description, @@ -83,7 +82,7 @@ class NotificationFactory @Inject constructor( when (processed) { ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value) ProcessedEvent.Type.KEEP -> OneShotNotification.Append( - notificationFactory.createSimpleEventNotification(event), + notificationCreator.createSimpleEventNotification(event), OneShotNotification.Append.Meta( key = event.eventId.value, summaryLine = event.description, @@ -100,7 +99,7 @@ class NotificationFactory @Inject constructor( when (processed) { ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value) ProcessedEvent.Type.KEEP -> OneShotNotification.Append( - notificationFactory.createFallbackNotification(event), + notificationCreator.createFallbackNotification(event), OneShotNotification.Append.Meta( key = event.eventId.value, summaryLine = event.description.orEmpty(), 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 29d828d34c..cde4f489a2 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 @@ -27,16 +27,15 @@ 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.NotificationFactory +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.services.toolbox.api.strings.StringProvider -import timber.log.Timber import javax.inject.Inject class RoomGroupMessageCreator @Inject constructor( private val bitmapLoader: NotificationBitmapLoader, private val stringProvider: StringProvider, - private val notificationFactory: NotificationFactory + private val notificationCreator: NotificationCreator ) { suspend fun createRoomMessage( @@ -78,7 +77,7 @@ class RoomGroupMessageCreator @Inject constructor( shouldBing = events.any { it.noisy } ) return RoomNotification.Message( - notificationFactory.createMessagesListNotification( + notificationCreator.createMessagesListNotification( style, RoomEventGroupInfo( sessionId = currentUser.userId, @@ -133,22 +132,16 @@ class RoomGroupMessageCreator @Inject constructor( } private fun createRoomMessagesGroupSummaryLine(events: List, roomName: String, roomIsDirect: Boolean): CharSequence { - return try { - when (events.size) { - 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect) - else -> { - stringProvider.getQuantityString( - R.plurals.notification_compat_summary_line_for_room, - events.size, - roomName, - events.size - ) - } + 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 + ) } - } catch (e: Throwable) { - // String not found or bad format - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER failed to resolve string") - roomName } } @@ -166,7 +159,7 @@ class RoomGroupMessageCreator @Inject constructor( inSpans(StyleSpan(Typeface.BOLD)) { append(roomName) append(": ") - event.senderName + append(event.senderName) append(" ") } append(event.description) 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 f999456107..f46df7d3a5 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 @@ -21,7 +21,7 @@ import androidx.core.app.NotificationCompat 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.NotificationFactory +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.services.toolbox.api.strings.StringProvider import javax.inject.Inject @@ -41,7 +41,7 @@ import javax.inject.Inject */ class SummaryGroupMessageCreator @Inject constructor( private val stringProvider: StringProvider, - private val notificationFactory: NotificationFactory, + private val notificationCreator: NotificationCreator, ) { fun createSummaryNotification( @@ -77,7 +77,7 @@ class SummaryGroupMessageCreator @Inject constructor( // Use account name now, for multi-session .setSummaryText(currentUser.userId.value.annotateForDebug(44)) return if (useCompleteNotificationFormat) { - notificationFactory.createSummaryListNotification( + notificationCreator.createSummaryListNotification( currentUser, summaryInboxStyle, sumTitle, @@ -170,7 +170,7 @@ class SummaryGroupMessageCreator @Inject constructor( messageStr } } - return notificationFactory.createSummaryListNotification( + return notificationCreator.createSummaryListNotification( currentUser = currentUser, style = null, compatSummary = privacyTitle, 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 ab115262de..74020f9323 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 @@ -16,7 +16,6 @@ package io.element.android.libraries.push.impl.notifications.channels -import android.app.Activity import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context @@ -24,7 +23,6 @@ import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat -import io.element.android.libraries.androidutils.system.startNotificationChannelSettingsIntent import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn @@ -163,17 +161,5 @@ class NotificationChannels @Inject constructor( @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - - fun openSystemSettingsForSilentCategory(activity: Activity) { - activity.startNotificationChannelSettingsIntent(SILENT_NOTIFICATION_CHANNEL_ID) - } - - fun openSystemSettingsForNoisyCategory(activity: Activity) { - activity.startNotificationChannelSettingsIntent(NOISY_NOTIFICATION_CHANNEL_ID) - } - - fun openSystemSettingsForCallCategory(activity: Activity) { - activity.startNotificationChannelSettingsIntent(CALL_NOTIFICATION_CHANNEL_ID) - } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt similarity index 99% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt index 105b5789e4..2beddc53f9 100755 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt @@ -41,7 +41,7 @@ import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiab import io.element.android.services.toolbox.api.strings.StringProvider import javax.inject.Inject -class NotificationFactory @Inject constructor( +class NotificationCreator @Inject constructor( @ApplicationContext private val context: Context, private val notificationChannels: NotificationChannels, private val stringProvider: StringProvider, 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 new file mode 100644 index 0000000000..6b4ea24fd4 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -0,0 +1,134 @@ +/* + * 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.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.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.push.impl.notifications.fake.FakeAndroidNotificationFactory +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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class DefaultNotificationDrawerManagerTest { + @Test + fun `clearAllEvents should have no effect when queue is empty`() = runTest { + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager() + defaultNotificationDrawerManager.clearAllEvents(A_SESSION_ID) + defaultNotificationDrawerManager.destroy() + } + + @Test + fun `cover all APIs`() = runTest { + // For now just call all the API. Later, add more valuable tests. + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager() + defaultNotificationDrawerManager.notificationStyleChanged() + 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) + 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.onNotifiableEventReceived(aNotifiableMessageEvent()) + // Add the same Event again (will be ignored) + defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) + defaultNotificationDrawerManager.destroy() + } + + @Test + fun `react to applicationStateChange`() = runTest { + // For now just call all the API. Later, add more valuable tests. + val appNavigationStateFlow: MutableStateFlow = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Root, + isInForeground = true, + ) + ) + val appNavigationStateService = FakeAppNavigationStateService(appNavigationState = appNavigationStateFlow) + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + appNavigationStateService = appNavigationStateService + ) + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) + runCurrent() + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID), isInForeground = true)) + runCurrent() + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID), isInForeground = true)) + runCurrent() + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID), isInForeground = true)) + runCurrent() + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID), isInForeground = true)) + runCurrent() + // Like a user sign out + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) + runCurrent() + defaultNotificationDrawerManager.destroy() + } + + private fun TestScope.createDefaultNotificationDrawerManager( + appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), + initialData: List = emptyList() + ): DefaultNotificationDrawerManager { + val context = RuntimeEnvironment.getApplication() + return DefaultNotificationDrawerManager( + notifiableEventProcessor = NotifiableEventProcessor( + outdatedDetector = OutdatedEventDetector(), + appNavigationStateService = appNavigationStateService + ), + notificationRenderer = NotificationRenderer( + NotificationIdProvider(), + NotificationDisplayer(context), + NotificationFactory( + FakeAndroidNotificationFactory().instance, + FakeRoomGroupMessageCreator().instance, + FakeSummaryGroupMessageCreator().instance, + ) + ), + notificationEventPersistence = InMemoryNotificationEventPersistence(initialData = initialData), + filteredEventDetector = FilteredEventDetector(), + appNavigationStateService = appNavigationStateService, + coroutineScope = this, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + buildMeta = aBuildMeta(), + matrixClientProvider = FakeMatrixClientProvider(), + ) + } +} 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 new file mode 100644 index 0000000000..ce984c712f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistenceTest.kt @@ -0,0 +1,64 @@ +/* + * 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/InMemoryNotificationEventPersistence.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/InMemoryNotificationEventPersistence.kt new file mode 100644 index 0000000000..09f907f50b --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/InMemoryNotificationEventPersistence.kt @@ -0,0 +1,33 @@ +/* + * 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/RoomGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreatorTest.kt new file mode 100644 index 0000000000..46c41454d3 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreatorTest.kt @@ -0,0 +1,280 @@ +/* + * 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 android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Build +import coil.Coil +import coil.ImageLoader +import coil.annotation.ExperimentalCoilApi +import coil.test.FakeImageLoaderEngine +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 +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.push.impl.notifications.factories.createNotificationCreator +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import io.element.android.services.toolbox.impl.strings.AndroidStringProvider +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +private const val A_TIMESTAMP = 6480L +private const val A_ROOM_AVATAR = "mxc://roomAvatar" +private const val A_USER_AVATAR_1 = "mxc://userAvatar1" +private const val A_USER_AVATAR_2 = "mxc://userAvatar2" + +@RunWith(RobolectricTestRunner::class) +class RoomGroupMessageCreatorTest { + @Test + fun `test createRoomMessage with one Event`() = runTest { + val sut = createRoomGroupMessageCreator() + val result = sut.createRoomMessage( + currentUser = aMatrixUser(), + events = listOf( + aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( + imageUriString = "aUri", + ) + ), + roomId = A_ROOM_ID, + ) + 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, + ) + ) + } + + @Test + fun `test createRoomMessage with one noisy Event`() = runTest { + val sut = createRoomGroupMessageCreator() + val result = sut.createRoomMessage( + currentUser = aMatrixUser(), + events = listOf( + aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( + noisy = true, + ) + ), + roomId = A_ROOM_ID, + ) + 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, + ) + ) + } + + @Test + fun `test createRoomMessage with room avatar and sender avatar android O`() { + `test createRoomMessage with room avatar and sender avatar`( + api = Build.VERSION_CODES.O, + // Only the Room avatar is loaded + expectedCoilRequests = listOf( + MediaRequestData( + source = MediaSource(url = A_ROOM_AVATAR), + kind = MediaRequestData.Kind.Thumbnail(1024) + ) + ) + ) + } + + @OptIn(ExperimentalCoilApi::class) + @Test + fun `test createRoomMessage with room avatar and sender avatar android P`() = runTest { + `test createRoomMessage with room avatar and sender avatar`( + api = Build.VERSION_CODES.P, + // Room and user avatar are loaded + expectedCoilRequests = listOf( + MediaRequestData( + source = MediaSource(url = A_USER_AVATAR_1), + kind = MediaRequestData.Kind.Thumbnail(1024) + ), + MediaRequestData( + source = MediaSource(url = A_USER_AVATAR_2), + kind = MediaRequestData.Kind.Thumbnail(1024) + ), + MediaRequestData( + source = MediaSource(url = A_ROOM_AVATAR), + kind = MediaRequestData.Kind.Thumbnail(1024) + ), + ) + ) + } + + @OptIn(ExperimentalCoilApi::class) + private fun `test createRoomMessage with room avatar and sender avatar`( + api: Int, + expectedCoilRequests: List, + ) = runTest { + val coilRequests = mutableListOf() + val engine = FakeImageLoaderEngine.Builder() + .intercept( + predicate = { + coilRequests.add(it) + true + }, + drawable = ColorDrawable(Color.BLUE) + ) + .build() + val imageLoader = ImageLoader.Builder(RuntimeEnvironment.getApplication()) + .components { add(engine) } + .build() + Coil.setImageLoader(imageLoader) + val sut = createRoomGroupMessageCreator( + sdkIntProvider = FakeBuildVersionSdkIntProvider(api) + ) + val result = sut.createRoomMessage( + currentUser = aMatrixUser( + // Some user avatar + avatarUrl = A_USER_AVATAR_1, + ), + events = listOf( + aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( + roomAvatarPath = A_ROOM_AVATAR, + senderAvatarPath = A_USER_AVATAR_2, + ) + ), + roomId = A_ROOM_ID, + ) + 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(coilRequests.toList()).isEqualTo(expectedCoilRequests) + } + + @Test + fun `test createRoomMessage with two Events`() = runTest { + val sut = createRoomGroupMessageCreator() + val result = sut.createRoomMessage( + currentUser = aMatrixUser(), + events = listOf( + aNotifiableMessageEvent(timestamp = A_TIMESTAMP), + aNotifiableMessageEvent(timestamp = A_TIMESTAMP + 10), + ), + roomId = A_ROOM_ID, + ) + 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, + ) + ) + } + + @Test + fun `test createRoomMessage with smart reply error`() = runTest { + val sut = createRoomGroupMessageCreator() + val result = sut.createRoomMessage( + currentUser = aMatrixUser(), + events = listOf( + aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( + outGoingMessage = true, + outGoingMessageFailed = true, + ), + ), + roomId = A_ROOM_ID, + ) + 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, + ) + ) + } + + @Test + fun `test createRoomMessage for direct room`() = runTest { + val sut = createRoomGroupMessageCreator() + val result = sut.createRoomMessage( + currentUser = aMatrixUser(), + events = listOf( + aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( + roomIsDirect = true, + ), + ), + roomId = A_ROOM_ID, + ) + 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, + ) + ) + } +} + +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, + ), + stringProvider = AndroidStringProvider(context.resources) + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/FakeIntentProvider.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/FakeIntentProvider.kt new file mode 100644 index 0000000000..2de70bb672 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/FakeIntentProvider.kt @@ -0,0 +1,29 @@ +/* + * 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.factories + +import android.content.Intent +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.intent.IntentProvider + +class FakeIntentProvider : IntentProvider { + override fun getViewRoomIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?) = Intent() + + override fun getInviteListIntent(sessionId: SessionId) = Intent() +} 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/NotificationCreatorTest.kt new file mode 100644 index 0000000000..edf87fcd1a --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.kt @@ -0,0 +1,314 @@ +/* + * 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.factories + +import android.app.Notification +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.Person +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 +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_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.RoomEventGroupInfo +import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels +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.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.strings.FakeStringProvider +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class NotificationCreatorTest { + @Test + fun `test createDiagnosticNotification`() { + val sut = createNotificationCreator() + val result = sut.createDiagnosticNotification() + result.commonAssertions( + expectedGroup = null, + expectedCategory = NotificationCompat.CATEGORY_STATUS, + ) + } + + @Test + fun `test createFallbackNotification`() { + val sut = createNotificationCreator() + val result = sut.createFallbackNotification( + FallbackNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + description = "description", + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + timestamp = A_FAKE_TIMESTAMP, + ) + ) + result.commonAssertions( + expectedCategory = null, + ) + } + + @Test + fun `test createSimpleEventNotification`() { + val sut = createNotificationCreator() + val result = sut.createSimpleEventNotification( + SimpleNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + noisy = false, + title = "title", + description = "description", + type = null, + timestamp = A_FAKE_TIMESTAMP, + soundName = null, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + ) + ) + result.commonAssertions( + expectedCategory = null, + ) + } + + @Test + fun `test createSimpleEventNotification noisy`() { + val sut = createNotificationCreator() + val result = sut.createSimpleEventNotification( + SimpleNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + noisy = true, + title = "title", + description = "description", + type = null, + timestamp = A_FAKE_TIMESTAMP, + soundName = null, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + ) + ) + result.commonAssertions( + expectedCategory = null, + ) + } + + @Test + fun `test createRoomInvitationNotification`() { + val sut = createNotificationCreator() + val result = sut.createRoomInvitationNotification( + InviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + noisy = false, + title = "title", + description = "description", + type = null, + timestamp = A_FAKE_TIMESTAMP, + soundName = null, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + roomName = "roomName", + ) + ) + result.commonAssertions( + expectedCategory = null, + ) + } + + @Test + fun `test createRoomInvitationNotification noisy`() { + val sut = createNotificationCreator() + val result = sut.createRoomInvitationNotification( + InviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + noisy = true, + title = "title", + description = "description", + type = null, + timestamp = A_FAKE_TIMESTAMP, + soundName = null, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + roomName = "roomName", + ) + ) + result.commonAssertions( + expectedCategory = null, + ) + } + + @Test + fun `test createSummaryListNotification`() { + val sut = createNotificationCreator() + val matrixUser = aMatrixUser() + val result = sut.createSummaryListNotification( + currentUser = matrixUser, + style = null, + compatSummary = "compatSummary", + noisy = false, + lastMessageTimestamp = 123_456L, + ) + result.commonAssertions( + expectedGroup = matrixUser.userId.value, + ) + } + + @Test + fun `test createSummaryListNotification noisy`() { + val sut = createNotificationCreator() + val matrixUser = aMatrixUser() + val result = sut.createSummaryListNotification( + currentUser = matrixUser, + style = null, + compatSummary = "compatSummary", + noisy = true, + lastMessageTimestamp = 123_456L, + ) + result.commonAssertions( + expectedGroup = matrixUser.userId.value, + ) + } + + @Test + fun `test createMessagesListNotification`() { + 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, + roomDisplayName = "roomDisplayName", + isDirect = false, + hasSmartReplyError = false, + shouldBing = false, + customSound = null, + isUpdated = false, + ), + threadId = null, + largeIcon = null, + lastMessageTimestamp = 123_456L, + tickerText = "tickerText", + ) + result.commonAssertions() + } + + @Test + fun `test createMessagesListNotification should bing and thread`() { + 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, + roomDisplayName = "roomDisplayName", + isDirect = false, + hasSmartReplyError = false, + shouldBing = true, + customSound = null, + isUpdated = false, + ), + threadId = A_THREAD_ID, + largeIcon = null, + lastMessageTimestamp = 123_456L, + tickerText = "tickerText", + ) + result.commonAssertions() + } + + private fun Notification.commonAssertions( + expectedGroup: String? = A_SESSION_ID.value, + expectedCategory: String? = NotificationCompat.CATEGORY_MESSAGE, + ) { + assertThat(contentIntent).isNotNull() + assertThat(group).isEqualTo(expectedGroup) + assertThat(category).isEqualTo(expectedCategory) + } +} + +fun createNotificationCreator( + context: Context = RuntimeEnvironment.getApplication(), + buildMeta: BuildMeta = aBuildMeta(), + notificationChannels: NotificationChannels = createNotificationChannels() +): NotificationCreator { + return NotificationCreator( + context = context, + notificationChannels = notificationChannels, + stringProvider = FakeStringProvider("test"), + buildMeta = buildMeta, + pendingIntentFactory = PendingIntentFactory( + context, + FakeIntentProvider(), + FakeSystemClock(), + NotificationActionIds(buildMeta), + ), + markAsReadActionFactory = MarkAsReadActionFactory( + context = context, + actionIds = NotificationActionIds(buildMeta), + stringProvider = FakeStringProvider("MarkAsReadActionFactory"), + clock = FakeSystemClock(), + ), + quickReplyActionFactory = QuickReplyActionFactory( + context = context, + actionIds = NotificationActionIds(buildMeta), + stringProvider = FakeStringProvider("QuickReplyActionFactory"), + clock = FakeSystemClock(), + ), + ) +} + +fun createNotificationChannels(): NotificationChannels { + val context = RuntimeEnvironment.getApplication() + return NotificationChannels(context, FakeStringProvider("")) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt index c046e1253f..6637b64979 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt @@ -17,14 +17,14 @@ package io.element.android.libraries.push.impl.notifications.fake import android.app.Notification -import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory +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 FakeAndroidNotificationFactory { - val instance = mockk() + val instance = mockk() fun givenCreateRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification { val mockNotification = mockk() diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt index 780d2abb71..44a2873465 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt @@ -77,13 +77,14 @@ fun aNotifiableMessageEvent( roomId: RoomId = A_ROOM_ID, eventId: EventId = AN_EVENT_ID, threadId: ThreadId? = null, - isRedacted: Boolean = false + isRedacted: Boolean = false, + timestamp: Long = 0, ) = NotifiableMessageEvent( sessionId = sessionId, eventId = eventId, editedEventId = null, noisy = false, - timestamp = 0, + timestamp = timestamp, senderName = "sender-name", senderId = UserId("@sending-id:domain.com"), body = "message-body", diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt index a09e2a9c5e..ac6060fa8b 100644 --- a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt @@ -20,23 +20,19 @@ 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.SpaceId import io.element.android.libraries.matrix.api.core.ThreadId -import io.element.android.services.appnavstate.api.NavigationState -import io.element.android.services.appnavstate.api.AppNavigationStateService 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 kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow class FakeAppNavigationStateService( - private val fakeAppNavigationState: MutableStateFlow = MutableStateFlow( + override val appNavigationState: MutableStateFlow = MutableStateFlow( AppNavigationState( navigationState = NavigationState.Root, isInForeground = true, ) ), ) : AppNavigationStateService { - - override val appNavigationState: StateFlow = fakeAppNavigationState - override fun onNavigateToSession(owner: String, sessionId: SessionId) = Unit override fun onLeavingSession(owner: String) = Unit