diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationRegistry.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationRegistry.kt new file mode 100644 index 00000000000..fd6777654fa --- /dev/null +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationRegistry.kt @@ -0,0 +1,61 @@ +package net.thunderbird.feature.notification.api + +import net.thunderbird.feature.notification.api.content.Notification + +/** + * A registry for managing notifications and their corresponding IDs. + * + * It establishes and maintains the correlation between a [Notification] object + * and its unique [NotificationId]. + * This can also be used to track which notifications are currently being displayed + * to the user. + */ +interface NotificationRegistry { + /** + * A [Map] off all the current notifications, associated with their IDs, + * being displayed to the user. + */ + val registrar: Map + + /** + * Retrieves a [Notification] object based on its [notificationId]. + * + * @param notificationId The ID of the notification to retrieve. + * @return The [Notification] object associated with the given [notificationId], + * or `null` if no such notification exists. + */ + operator fun get(notificationId: NotificationId): Notification? + + /** + * Retrieves the [NotificationId] associated with the given [notification]. + * + * @param notification The notification for which to retrieve the ID. + * @return The [NotificationId] if the notification is registered, or `null` otherwise. + */ + operator fun get(notification: Notification): NotificationId? + + /** + * Registers a notification and returns its unique ID. + * + * If the provided [notification] is already registered, this function will effectively + * return its known [NotificationId]. + * + * @param notification The [Notification] object to register. + * @return The unique [NotificationId] assigned to the registered notification. + */ + suspend fun register(notification: Notification): NotificationId + + /** + * Unregisters a [Notification] by its [NotificationId]. + * + * @param notificationId The ID of the notification to unregister. + */ + fun unregister(notificationId: NotificationId) + + /** + * Unregisters a previously registered notification. + * + * @param notification The [Notification] object to unregister. + */ + fun unregister(notification: Notification) +} diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationSeverity.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationSeverity.kt index b943b3f3d73..961ca6a6bab 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationSeverity.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationSeverity.kt @@ -12,7 +12,7 @@ package net.thunderbird.feature.notification.api * For [Temporary] and [Warning], user action might be recommended or optional. * For [Information], no user action is usually needed. */ -enum class NotificationSeverity { +enum class NotificationSeverity(val dismissable: Boolean) { /** * Completely blocks the user from performing essential tasks or accessing core functionality. * @@ -24,7 +24,7 @@ enum class NotificationSeverity { * - Retry * - Provide other credentials */ - Fatal, + Fatal(dismissable = false), /** * Prevents the user from completing specific core actions or causes significant disruption to functionality. @@ -36,7 +36,7 @@ enum class NotificationSeverity { * - **Notification Actions:** * - Retry */ - Critical, + Critical(dismissable = false), /** * Causes a temporary disruption or delay to functionality, which may resolve on its own. @@ -48,7 +48,7 @@ enum class NotificationSeverity { * - **Notification Message:** You are offline, the message will be sent later. * - **Notification Actions:** N/A */ - Temporary, + Temporary(dismissable = true), /** * Alerts the user to a potential issue or limitation that may affect functionality if not addressed. @@ -61,7 +61,7 @@ enum class NotificationSeverity { * - **Notification Actions:** * - Manage Storage */ - Warning, + Warning(dismissable = true), /** * Provides status or context without impacting functionality or requiring action. @@ -72,5 +72,5 @@ enum class NotificationSeverity { * - **Notification Message:** Last time email synchronization succeeded * - **Notification Actions:** N/A */ - Information, + Information(dismissable = true), } diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/command/NotificationCommandException.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/command/NotificationCommandException.kt new file mode 100644 index 00000000000..25a048c076d --- /dev/null +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/command/NotificationCommandException.kt @@ -0,0 +1,6 @@ +package net.thunderbird.feature.notification.api.command + +class NotificationCommandException @JvmOverloads constructor( + override val message: String?, + override val cause: Throwable? = null, +) : Exception(message, cause) diff --git a/feature/notification/impl/build.gradle.kts b/feature/notification/impl/build.gradle.kts index 0e35f2b6516..f5e5006b877 100644 --- a/feature/notification/impl/build.gradle.kts +++ b/feature/notification/impl/build.gradle.kts @@ -1,11 +1,13 @@ plugins { id(ThunderbirdPlugins.Library.kmpCompose) + alias(libs.plugins.dev.mokkery) } kotlin { sourceSets { commonMain.dependencies { implementation(projects.core.common) + implementation(projects.core.featureflag) implementation(projects.core.outcome) implementation(projects.core.logging.api) implementation(projects.feature.notification.api) diff --git a/feature/notification/impl/src/androidMain/AndroidManifest.xml b/feature/notification/impl/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000000..9a0a68d58e0 --- /dev/null +++ b/feature/notification/impl/src/androidMain/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.android.kt b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.android.kt index cef362deedf..2fe43819ef5 100644 --- a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.android.kt +++ b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.android.kt @@ -1,18 +1,28 @@ package net.thunderbird.feature.notification.impl.inject import net.thunderbird.feature.notification.api.content.SystemNotification +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier +import net.thunderbird.feature.notification.impl.intent.action.AlarmPermissionMissingNotificationTapActionIntentCreator import net.thunderbird.feature.notification.impl.intent.action.DefaultNotificationActionIntentCreator import net.thunderbird.feature.notification.impl.intent.action.NotificationActionIntentCreator +import net.thunderbird.feature.notification.impl.receiver.AndroidSystemNotificationNotifier +import net.thunderbird.feature.notification.impl.receiver.SystemNotificationNotifier import net.thunderbird.feature.notification.impl.ui.action.DefaultSystemNotificationActionCreator import net.thunderbird.feature.notification.impl.ui.action.NotificationActionCreator import org.koin.android.ext.koin.androidApplication import org.koin.core.module.Module import org.koin.core.qualifier.named import org.koin.dsl.module +import org.koin.dsl.onClose internal actual val platformFeatureNotificationModule: Module = module { - single>>(named()) { + single>>(named()) { listOf( + AlarmPermissionMissingNotificationTapActionIntentCreator( + context = androidApplication(), + logger = get(), + ), + // The Default implementation must always be the last. DefaultNotificationActionIntentCreator( logger = get(), applicationContext = androidApplication(), @@ -26,4 +36,14 @@ internal actual val platformFeatureNotificationModule: Module = module { actionIntentCreators = get(named()), ) } + + single>(named()) { + AndroidSystemNotificationNotifier( + logger = get(), + applicationContext = androidApplication(), + notificationActionCreator = get(named(NotificationActionCreator.TypeQualifier.System)), + ) + }.onClose { notifier -> + notifier?.dispose() + } } diff --git a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/AlarmPermissionMissingNotificationTapActionIntentCreator.kt b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/AlarmPermissionMissingNotificationTapActionIntentCreator.kt new file mode 100644 index 00000000000..fa881871fdf --- /dev/null +++ b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/AlarmPermissionMissingNotificationTapActionIntentCreator.kt @@ -0,0 +1,53 @@ +package net.thunderbird.feature.notification.impl.intent.action + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.annotation.RequiresApi +import androidx.core.app.PendingIntentCompat +import androidx.core.net.toUri +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.notification.api.content.Notification +import net.thunderbird.feature.notification.api.content.PushServiceNotification +import net.thunderbird.feature.notification.api.ui.action.NotificationAction + +private const val TAG = "AlarmPermissionMissingNotificationIntentCreator" + +class AlarmPermissionMissingNotificationTapActionIntentCreator( + private val context: Context, + private val logger: Logger, +) : NotificationActionIntentCreator { + override fun accept(notification: Notification, action: NotificationAction): Boolean = + Build.VERSION.SDK_INT > Build.VERSION_CODES.S && + notification is PushServiceNotification.AlarmPermissionMissing + + @RequiresApi(Build.VERSION_CODES.S) + override fun create( + notification: PushServiceNotification.AlarmPermissionMissing, + action: NotificationAction.Tap, + ): PendingIntent { + logger.debug(TAG) { "create() called with: notification = $notification" } + val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply { + data = "package:${context.packageName}".toUri() + } + + return requireNotNull( + PendingIntentCompat.getActivity( + /* context = */ + context, + /* requestCode = */ + 1, + /* intent = */ + intent, + /* flags = */ + 0, + /* isMutable = */ + false, + ), + ) { + "Could not create PendingIntent for AlarmPermissionMissing Notification." + } + } +} diff --git a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreator.kt b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreator.kt index ebabf08ff48..985c99c46e3 100644 --- a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreator.kt +++ b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreator.kt @@ -4,6 +4,7 @@ import android.app.PendingIntent import android.content.Context import androidx.core.app.PendingIntentCompat import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.notification.api.content.Notification import net.thunderbird.feature.notification.api.ui.action.NotificationAction private const val TAG = "DefaultNotificationActionIntentCreator" @@ -21,11 +22,11 @@ private const val TAG = "DefaultNotificationActionIntentCreator" internal class DefaultNotificationActionIntentCreator( private val logger: Logger, private val applicationContext: Context, -) : NotificationActionIntentCreator { - override fun accept(action: NotificationAction): Boolean = true +) : NotificationActionIntentCreator { + override fun accept(notification: Notification, action: NotificationAction): Boolean = true - override fun create(action: NotificationAction): PendingIntent? { - logger.debug(TAG) { "create() called with: action = $action" } + override fun create(notification: Notification, action: NotificationAction): PendingIntent? { + logger.debug(TAG) { "create() called with: notification = $notification, action = $action" } val packageManager = applicationContext.packageManager val launchIntent = requireNotNull( packageManager.getLaunchIntentForPackage(applicationContext.packageName), diff --git a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/NotificationActionIntentCreator.kt b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/NotificationActionIntentCreator.kt index 7c0299f2fe0..94309bf7a1b 100644 --- a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/NotificationActionIntentCreator.kt +++ b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/NotificationActionIntentCreator.kt @@ -1,6 +1,7 @@ package net.thunderbird.feature.notification.impl.intent.action import android.app.PendingIntent +import net.thunderbird.feature.notification.api.content.Notification import net.thunderbird.feature.notification.api.ui.action.NotificationAction /** @@ -11,14 +12,17 @@ import net.thunderbird.feature.notification.api.ui.action.NotificationAction * * @param TNotificationAction The type of [NotificationAction] this creator can handle. */ -internal interface NotificationActionIntentCreator { +internal interface NotificationActionIntentCreator< + in TNotification : Notification, + in TNotificationAction : NotificationAction, + > { /** * Determines whether this [NotificationActionIntentCreator] can create an intent for the given [action]. * * @param action The [NotificationAction] to check. * @return `true` if this creator can handle the [action], `false` otherwise. */ - fun accept(action: NotificationAction): Boolean + fun accept(notification: Notification, action: NotificationAction): Boolean /** * Creates a [PendingIntent] for the given notification action. @@ -26,7 +30,7 @@ internal interface NotificationActionIntentCreator, +) : SystemNotificationNotifier { + private val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(applicationContext) + + override suspend fun show( + id: NotificationId, + notification: SystemNotification, + ) { + logger.debug(TAG) { "show() called with: id = $id, notification = $notification" } + val androidNotification = notification.toAndroidNotification() + notificationManager.notify(id.value, androidNotification) + } + + override fun dispose() { + logger.debug(TAG) { "dispose() called" } + } + + private suspend fun SystemNotification.toAndroidNotification(): Notification { + logger.debug(TAG) { "toAndroidNotification() called with systemNotification = $this" } + val systemNotification = this + return NotificationCompat + .Builder(applicationContext, channel.id) + .apply { + setSmallIcon( + checkNotNull(icon.systemNotificationIcon) { + "A icon is required to display a system notification" + }, + ) + setContentTitle(title) + setTicker(accessibilityText) + contentText?.let(::setContentText) + subText?.let(::setSubText) + setOngoing(severity.dismissable.not()) + setWhen(createdAt.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds()) + asLockscreenNotification()?.let { lockscreenNotification -> + if (lockscreenNotification.notification != systemNotification) { + setPublicVersion(lockscreenNotification.notification.toAndroidNotification()) + } + } + + val tapAction = notificationActionCreator.create( + notification = systemNotification, + action = NotificationAction.Tap, + ) + setContentIntent(tapAction.pendingIntent) + + setNotificationStyle(notification = systemNotification) + + if (actions.isNotEmpty()) { + for (action in actions) { + val notificationAction = notificationActionCreator + .create(notification = systemNotification, action) + + addAction( + /* icon = */ + notificationAction.icon ?: 0, + /* title = */ + notificationAction.title, + /* intent = */ + notificationAction.pendingIntent, + ) + } + } + } + .build() + } + + private fun NotificationCompat.Builder.setNotificationStyle( + notification: SystemNotification, + ) { + when (val style = notification.systemNotificationStyle) { + is SystemNotificationStyle.BigTextStyle -> setStyle( + NotificationCompat.BigTextStyle().bigText(style.text), + ) + + is SystemNotificationStyle.InboxStyle -> { + val inboxStyle = NotificationCompat.InboxStyle() + .setBigContentTitle(style.bigContentTitle) + .setSummaryText(style.summary) + + style.lines.forEach(inboxStyle::addLine) + + setStyle(inboxStyle) + } + + SystemNotificationStyle.Undefined -> Unit + } + } +} diff --git a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/ui/action/DefaultSystemNotificationActionCreator.kt b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/ui/action/DefaultSystemNotificationActionCreator.kt index dc7395e13ae..877abe77f7c 100644 --- a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/ui/action/DefaultSystemNotificationActionCreator.kt +++ b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/ui/action/DefaultSystemNotificationActionCreator.kt @@ -1,6 +1,7 @@ package net.thunderbird.feature.notification.impl.ui.action import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.notification.api.content.Notification import net.thunderbird.feature.notification.api.content.SystemNotification import net.thunderbird.feature.notification.api.ui.action.NotificationAction import net.thunderbird.feature.notification.impl.intent.action.NotificationActionIntentCreator @@ -9,7 +10,7 @@ private const val TAG = "DefaultSystemNotificationActionCreator" internal class DefaultSystemNotificationActionCreator( private val logger: Logger, - private val actionIntentCreators: List>, + private val actionIntentCreators: List>, ) : NotificationActionCreator { override suspend fun create( notification: SystemNotification, @@ -17,8 +18,8 @@ internal class DefaultSystemNotificationActionCreator( ): AndroidNotificationAction { logger.debug(TAG) { "create() called with: notification = $notification, action = $action" } val intent = actionIntentCreators - .first { it.accept(action) } - .create(action) + .first { it.accept(notification, action) } + .create(notification, action) return AndroidNotificationAction( icon = action.icon?.systemNotificationIcon, diff --git a/feature/notification/impl/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreatorTest.kt b/feature/notification/impl/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreatorTest.kt index e45bb2da940..36f8f600496 100644 --- a/feature/notification/impl/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreatorTest.kt +++ b/feature/notification/impl/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreatorTest.kt @@ -14,6 +14,7 @@ import assertk.assertions.isTrue import assertk.assertions.prop import net.thunderbird.core.logging.testing.TestLogger import net.thunderbird.feature.notification.api.ui.action.NotificationAction +import net.thunderbird.feature.notification.testing.fake.FakeNotification import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.mockStatic @@ -49,7 +50,7 @@ class DefaultNotificationActionIntentCreatorTest { // Act val accepted = multipleActions.fold(initial = true) { accepted, action -> - accepted and testSubject.accept(action) + accepted and testSubject.accept(notification = FakeNotification(), action) } // Assert @@ -95,7 +96,7 @@ class DefaultNotificationActionIntentCreatorTest { val testSubject = createTestSubject(context) // Act - testSubject.create(NotificationAction.Tap) + testSubject.create(notification = FakeNotification(), action = NotificationAction.Tap) // Assert pendingIntentCompat.verify { diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationRegistry.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationRegistry.kt new file mode 100644 index 00000000000..cbdd3294fdb --- /dev/null +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationRegistry.kt @@ -0,0 +1,60 @@ +package net.thunderbird.feature.notification.impl + +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.incrementAndFetch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.thunderbird.feature.notification.api.NotificationId +import net.thunderbird.feature.notification.api.NotificationRegistry +import net.thunderbird.feature.notification.api.content.Notification + +@OptIn(ExperimentalAtomicApi::class) +class DefaultNotificationRegistry : NotificationRegistry { + private val mutex = Mutex() + + // We use a MutableMap, rather than MutableMap, + // allowing for quick lookups (O(1) on average for MutableMap) to check if a notification is already present + // during registration. + private val _registrar = mutableMapOf() + private val rawId = AtomicInt(value = 0) + + override val registrar: Map get() = _registrar + .entries + .associate { (notification, notificationId) -> notificationId to notification } + + override fun get(notificationId: NotificationId): Notification? { + return _registrar + .entries + .firstOrNull { (_, value) -> value == notificationId } + ?.key + } + + override fun get(notification: Notification): NotificationId? { + return _registrar[notification] + } + + override suspend fun register(notification: Notification): NotificationId { + return mutex.withLock { + val existingNotificationId = get(notification) + if (existingNotificationId != null) { + return@withLock existingNotificationId + } + + val id = rawId.incrementAndFetch() + val notificationId = NotificationId(id) + _registrar.put(notification, notificationId) + + notificationId + } + } + + override fun unregister(notificationId: NotificationId) { + val notification = get(notificationId) + _registrar.remove(notification) + } + + override fun unregister(notification: Notification) { + _registrar.remove(notification) + } +} diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt index 7703611f951..dc0cc830222 100644 --- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt @@ -1,19 +1,23 @@ package net.thunderbird.feature.notification.impl.command +import net.thunderbird.core.featureflag.FeatureFlagProvider import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.notification.api.NotificationRegistry import net.thunderbird.feature.notification.api.command.NotificationCommand import net.thunderbird.feature.notification.api.content.InAppNotification import net.thunderbird.feature.notification.api.content.Notification import net.thunderbird.feature.notification.api.content.SystemNotification +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier import net.thunderbird.feature.notification.impl.receiver.InAppNotificationNotifier -import net.thunderbird.feature.notification.impl.receiver.SystemNotificationNotifier /** * A factory for creating a set of notification commands based on a given notification. */ internal class NotificationCommandFactory( private val logger: Logger, - private val systemNotificationNotifier: SystemNotificationNotifier, + private val featureFlagProvider: FeatureFlagProvider, + private val notificationRegistry: NotificationRegistry, + private val systemNotificationNotifier: NotificationNotifier, private val inAppNotificationNotifier: InAppNotificationNotifier, ) { /** @@ -31,6 +35,8 @@ internal class NotificationCommandFactory( commands.add( SystemNotificationCommand( logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, notification = notification, notifier = systemNotificationNotifier, ), diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommand.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommand.kt index a9b55f97598..630d473278a 100644 --- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommand.kt +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommand.kt @@ -1,11 +1,20 @@ package net.thunderbird.feature.notification.impl.command +import net.thunderbird.core.featureflag.FeatureFlagKey +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.featureflag.FeatureFlagResult import net.thunderbird.core.logging.Logger import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.NotificationRegistry +import net.thunderbird.feature.notification.api.NotificationSeverity import net.thunderbird.feature.notification.api.command.NotificationCommand +import net.thunderbird.feature.notification.api.command.NotificationCommandException +import net.thunderbird.feature.notification.api.content.InAppNotification import net.thunderbird.feature.notification.api.content.SystemNotification import net.thunderbird.feature.notification.api.receiver.NotificationNotifier +private const val TAG = "SystemNotificationCommand" + /** * Command for displaying system notifications. * @@ -14,13 +23,64 @@ import net.thunderbird.feature.notification.api.receiver.NotificationNotifier */ internal class SystemNotificationCommand( private val logger: Logger, + private val featureFlagProvider: FeatureFlagProvider, + private val notificationRegistry: NotificationRegistry, notification: SystemNotification, notifier: NotificationNotifier, + private val isAppInBackground: () -> Boolean = { + // TODO(#9391): Verify if the app is backgrounded. + false + }, ) : NotificationCommand(notification, notifier) { + + private val isFeatureFlagEnabled: Boolean + get() = featureFlagProvider + .provide(FeatureFlagKey.UseNotificationSenderForSystemNotifications) == FeatureFlagResult.Enabled + override suspend fun execute(): Outcome, Failure> { - logger.debug { - "TODO: Implementation on GitHub Issue #9245. Notification = $notification." + logger.debug(TAG) { "execute() called" } + return when { + isFeatureFlagEnabled.not() -> + Outcome.failure( + error = Failure( + command = this, + throwable = NotificationCommandException( + message = "${FeatureFlagKey.UseNotificationSenderForSystemNotifications.key} feature flag" + + "is not enabled", + ), + ), + ) + + canExecuteCommand() -> { + notifier.show( + id = notificationRegistry.register(notification), + notification = notification, + ) + Outcome.success(Success(command = this)) + } + + else -> { + Outcome.failure( + error = Failure( + command = this, + throwable = NotificationCommandException("Can't execute command."), + ), + ) + } + } + } + + private fun canExecuteCommand(): Boolean { + val shouldAlwaysShow = when (notification.severity) { + NotificationSeverity.Fatal, NotificationSeverity.Critical -> true + else -> false + } + + return when { + shouldAlwaysShow -> true + isAppInBackground() -> true + notification !is InAppNotification -> true + else -> false } - return Outcome.success(data = Success(command = this)) } } diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt index a98af699488..4c2ed01619d 100644 --- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt @@ -1,11 +1,14 @@ package net.thunderbird.feature.notification.impl.inject +import net.thunderbird.feature.notification.api.NotificationRegistry import net.thunderbird.feature.notification.api.sender.NotificationSender +import net.thunderbird.feature.notification.impl.DefaultNotificationRegistry import net.thunderbird.feature.notification.impl.command.NotificationCommandFactory import net.thunderbird.feature.notification.impl.receiver.InAppNotificationNotifier import net.thunderbird.feature.notification.impl.receiver.SystemNotificationNotifier import net.thunderbird.feature.notification.impl.sender.DefaultNotificationSender import org.koin.core.module.Module +import org.koin.core.qualifier.named import org.koin.dsl.module internal expect val platformFeatureNotificationModule: Module @@ -13,13 +16,14 @@ internal expect val platformFeatureNotificationModule: Module val featureNotificationModule = module { includes(platformFeatureNotificationModule) - factory { SystemNotificationNotifier() } factory { InAppNotificationNotifier() } factory { NotificationCommandFactory( logger = get(), - systemNotificationNotifier = get(), + notificationRegistry = get(), + featureFlagProvider = get(), + systemNotificationNotifier = get(named()), inAppNotificationNotifier = get(), ) } @@ -29,4 +33,6 @@ val featureNotificationModule = module { commandFactory = get(), ) } + + single { DefaultNotificationRegistry() } } diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/SystemNotificationNotifier.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/SystemNotificationNotifier.kt index 748680d8915..389dc7a5c03 100644 --- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/SystemNotificationNotifier.kt +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/SystemNotificationNotifier.kt @@ -1,6 +1,5 @@ package net.thunderbird.feature.notification.impl.receiver -import net.thunderbird.feature.notification.api.NotificationId import net.thunderbird.feature.notification.api.content.SystemNotification import net.thunderbird.feature.notification.api.receiver.NotificationNotifier @@ -11,12 +10,4 @@ import net.thunderbird.feature.notification.api.receiver.NotificationNotifier * **Note:** The current implementation is a placeholder and needs to be completed * as part of GitHub Issue #9245. */ -internal class SystemNotificationNotifier : NotificationNotifier { - override suspend fun show(id: NotificationId, notification: SystemNotification) { - TODO("Implementation on GitHub Issue #9245") - } - - override fun dispose() { - TODO("Implementation on GitHub Issue #9245") - } -} +internal interface SystemNotificationNotifier : NotificationNotifier diff --git a/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationRegistryTest.kt b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationRegistryTest.kt new file mode 100644 index 00000000000..56d43d7dc9c --- /dev/null +++ b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationRegistryTest.kt @@ -0,0 +1,175 @@ +package net.thunderbird.feature.notification.impl + +import assertk.assertThat +import assertk.assertions.containsAtLeast +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import net.thunderbird.feature.notification.api.NotificationId +import net.thunderbird.feature.notification.testing.fake.FakeNotification + +@Suppress("MaxLineLength") +class DefaultNotificationRegistryTest { + @Test + fun `register should return NotificationId given notification`() = runTest { + // Arrange + val notification = FakeNotification() + val registry = DefaultNotificationRegistry() + + // Act + val notificationId = registry.register(notification) + + // Assert + assertThat(registry[notificationId]) + .isNotNull() + .isEqualTo(notification) + } + + @Test + fun `register should return same NotificationId when registering the same notification multiple times`() = runTest { + // Arrange + val notification = FakeNotification() + val registry = DefaultNotificationRegistry() + + // Act + val notificationId1 = registry.register(notification) + val notificationId2 = registry.register(notification) + + // Assert + assertThat(notificationId1) + .isEqualTo(notificationId2) + assertThat(registry[notificationId1]) + .isNotNull() + .isEqualTo(notification) + assertThat(registry[notificationId2]) + .isNotNull() + .isEqualTo(notification) + } + + @Test + fun `register should not register duplicated notifications when running concurrently`() = runTest { + // Arrange + val notificationSize = 100 + val registerTries = 50 + val notifications = List(size = notificationSize) { index -> + FakeNotification( + title = "fake notification $index", + ) + } + val expectedNotificationIds = List(size = notificationSize) { index -> + NotificationId(value = index + 1) + } + val registry = DefaultNotificationRegistry() + + // Act + List(size = registerTries) { + thread(start = true) { + notifications.forEach { notification -> + runBlocking { + registry.register(notification) + } + } + } + }.forEach { + it.join() + } + + // Assert + val registrar = registry.registrar + assertThat(registrar).hasSize(notificationSize) + assertThat(registrar) + .containsAtLeast(elements = expectedNotificationIds.zip(notifications).toTypedArray()) + } + + @Test + fun `operator get Notification should return NotificationId when notification is in the registrar`() = runTest { + // Arrange + val notification = FakeNotification() + val registry = DefaultNotificationRegistry() + registry.register(notification) + + // Act + val notificationId = registry[notification] + + // Assert + assertThat(notificationId).isNotNull() + } + + @Test + fun `operator get Notification should return null when notification is NOT in the registrar`() = runTest { + // Arrange + val notification = FakeNotification() + val notRegisteredNotification = FakeNotification(title = "that is not registered!!") + val registry = DefaultNotificationRegistry() + registry.register(notification) + + // Act + val notificationId = registry[notRegisteredNotification] + + // Assert + assertThat(notificationId).isNull() + } + + @Test + fun `operator get NotificationId should return Notification when notification is in the registrar`() = runTest { + // Arrange + val notification = FakeNotification() + val registry = DefaultNotificationRegistry() + val notificationId = registry.register(notification) + + // Act + val registrarNotification = registry[notificationId] + + // Assert + assertThat(registrarNotification).isNotNull() + } + + @Test + fun `operator get NotificationId should return null when notification is NOT in the registrar`() = runTest { + // Arrange + val registry = DefaultNotificationRegistry() + val notification = FakeNotification() + registry.register(notification) + + // Act + val notificationId = registry[NotificationId(value = Int.MAX_VALUE)] + + // Assert + assertThat(notificationId).isNull() + } + + @Test + fun `unregister should remove notification from registrar when given a notification object and Notification is in registrar`() = + runTest { + // Arrange + val registry = DefaultNotificationRegistry() + val notification = FakeNotification() + registry.register(notification) + + // Act + registry.unregister(notification) + + // Assert + assertThat(registry[notification]).isNull() + } + + @Test + fun `unregister should remove notification from registrar when given a notification id and Notification is in registrar`() = + runTest { + // Arrange + val registry = DefaultNotificationRegistry() + val notification = FakeNotification() + val notificationId = registry.register(notification) + + // Act + registry.unregister(notificationId) + + // Assert + assertThat(registry[notification]).isNull() + } +} diff --git a/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommandTest.kt b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommandTest.kt new file mode 100644 index 00000000000..164b4f5d745 --- /dev/null +++ b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommandTest.kt @@ -0,0 +1,311 @@ +package net.thunderbird.feature.notification.impl.command + +import assertk.all +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import dev.mokkery.matcher.any +import dev.mokkery.spy +import dev.mokkery.verify.VerifyMode.Companion.exactly +import dev.mokkery.verifySuspend +import kotlin.random.Random +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.featureflag.FeatureFlagKey +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.featureflag.FeatureFlagResult +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.NotificationId +import net.thunderbird.feature.notification.api.NotificationRegistry +import net.thunderbird.feature.notification.api.NotificationSeverity +import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure +import net.thunderbird.feature.notification.api.command.NotificationCommand.Success +import net.thunderbird.feature.notification.api.command.NotificationCommandException +import net.thunderbird.feature.notification.api.content.Notification +import net.thunderbird.feature.notification.api.content.SystemNotification +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier +import net.thunderbird.feature.notification.testing.fake.FakeNotification +import net.thunderbird.feature.notification.testing.fake.FakeSystemOnlyNotification + +@Suppress("MaxLineLength") +class SystemNotificationCommandTest { + @Test + fun `execute should return Failure when use_notification_sender_for_system_notifications feature flag is Disabled`() = + runTest { + // Arrange + val testSubject = createTestSubject( + featureFlagProvider = { key -> + when (key) { + FeatureFlagKey.UseNotificationSenderForSystemNotifications -> FeatureFlagResult.Disabled + else -> FeatureFlagResult.Enabled + } + }, + ) + + // Act + val outcome = testSubject.execute() + + // Assert + + assertThat(outcome) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command) + .isEqualTo(testSubject) + prop(Failure::throwable) + .isInstanceOf() + .hasMessage( + "${FeatureFlagKey.UseNotificationSenderForSystemNotifications.key} feature flag" + + "is not enabled", + ) + } + } + + @Test + fun `execute should return Failure when use_notification_sender_for_system_notifications feature flag is Unavailable`() = + runTest { + // Arrange + val testSubject = createTestSubject( + featureFlagProvider = { key -> + when (key) { + FeatureFlagKey.UseNotificationSenderForSystemNotifications -> FeatureFlagResult.Unavailable + else -> FeatureFlagResult.Enabled + } + }, + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command) + .isEqualTo(testSubject) + prop(Failure::throwable) + .isInstanceOf() + .hasMessage( + "${FeatureFlagKey.UseNotificationSenderForSystemNotifications.key} feature flag" + + "is not enabled", + ) + } + } + + @Test + fun `execute should return Failure when the app is in the foreground, notification is also InApp and severity is not Fatal or Critical`() = + runTest { + // Arrange + val notification = FakeNotification( + severity = NotificationSeverity.Information, + ) + val testSubject = createTestSubject( + notification = notification, + // TODO(#9391): Verify if the app is backgrounded. + isAppInBackground = { false }, + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command) + .isEqualTo(testSubject) + prop(Failure::throwable) + .isInstanceOf() + .hasMessage("Can't execute command.") + } + } + + @Test + fun `execute should return Success when the app is in the background`() = + runTest { + // Arrange + val notification = FakeNotification( + severity = NotificationSeverity.Information, + ) + val notifier = spy(FakeNotifier()) + val testSubject = createTestSubject( + notification = notification, + // TODO(#9391): Verify if the app is backgrounded. + isAppInBackground = { true }, + notifier = notifier, + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("data") { it.data } + .all { + prop(Success::command) + .isEqualTo(testSubject) + } + + verifySuspend(exactly(1)) { + notifier.show(any(), notification) + } + } + + @Test + fun `execute should return Success when the notification severity is Fatal`() = + runTest { + // Arrange + val notification = FakeNotification( + severity = NotificationSeverity.Fatal, + ) + val notifier = spy(FakeNotifier()) + val testSubject = createTestSubject( + notification = notification, + // TODO(#9391): Verify if the app is backgrounded. + isAppInBackground = { false }, + notifier = notifier, + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("data") { it.data } + .all { + prop(Success::command) + .isEqualTo(testSubject) + } + + verifySuspend(exactly(1)) { + notifier.show(any(), notification) + } + } + + @Test + fun `execute should return Success when the notification severity is Critical`() = + runTest { + // Arrange + val notification = FakeNotification( + severity = NotificationSeverity.Critical, + ) + val notifier = spy(FakeNotifier()) + val testSubject = createTestSubject( + notification = notification, + // TODO(#9391): Verify if the app is backgrounded. + isAppInBackground = { false }, + notifier = notifier, + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("data") { it.data } + .all { + prop(Success::command) + .isEqualTo(testSubject) + } + + verifySuspend(exactly(1)) { + notifier.show(any(), notification) + } + } + + @Test + fun `execute should return Success when the notification the app is not in background and notification is not an in-app notification`() = + runTest { + // Arrange + val notification = FakeSystemOnlyNotification( + severity = NotificationSeverity.Information, + ) + val notifier = spy(FakeNotifier()) + val testSubject = createTestSubject( + notification = notification, + // TODO(#9391): Verify if the app is backgrounded. + isAppInBackground = { false }, + notifier = notifier, + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("data") { it.data } + .all { + prop(Success::command) + .isEqualTo(testSubject) + } + + verifySuspend(exactly(1)) { + notifier.show(any(), notification) + } + } + + private fun createTestSubject( + notification: SystemNotification = FakeNotification(), + featureFlagProvider: FeatureFlagProvider = FeatureFlagProvider { FeatureFlagResult.Enabled }, + notifier: NotificationNotifier = FakeNotifier(), + notificationRegistry: NotificationRegistry = FakeNotificationRegistry(), + isAppInBackground: () -> Boolean = { + // TODO(#9391): Verify if the app is backgrounded. + false + }, + ): SystemNotificationCommand { + val logger = TestLogger() + return SystemNotificationCommand( + logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, + notification = notification, + notifier = notifier, + isAppInBackground = isAppInBackground, + ) + } +} + +private open class FakeNotificationRegistry : NotificationRegistry { + override val registrar: Map + get() = TODO("Not yet implemented") + + override fun get(notificationId: NotificationId): Notification? { + TODO("Not yet implemented") + } + + override fun get(notification: Notification): NotificationId? { + TODO("Not yet implemented") + } + + override suspend fun register(notification: Notification): NotificationId { + return NotificationId(value = Random.nextInt()) + } + + override fun unregister(notificationId: NotificationId) { + TODO("Not yet implemented") + } + + override fun unregister(notification: Notification) { + TODO("Not yet implemented") + } +} + +private open class FakeNotifier : NotificationNotifier { + override suspend fun show( + id: NotificationId, + notification: SystemNotification, + ) = Unit + + override fun dispose() = Unit +}