Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,35 @@
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)
}
commonTest.dependencies {
implementation(projects.core.logging.testing)
implementation(projects.feature.notification.testing)
}
androidUnitTest.dependencies {
implementation(libs.androidx.test.core)
implementation(libs.mockito.core)
implementation(libs.mockito.kotlin)
implementation(libs.robolectric)
}
}
}
android {
namespace = "net.thunderbird.feature.notification"
}
compose.resources {
publicResClass = false
packageOfResClass = "net.thunderbird.feature.notification.resources.impl"
}

View file

@ -0,0 +1,6 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>

View file

@ -0,0 +1,49 @@
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<List<NotificationActionIntentCreator<*, *>>>(named<NotificationActionIntentCreator.TypeQualifier>()) {
listOf(
AlarmPermissionMissingNotificationTapActionIntentCreator(
context = androidApplication(),
logger = get(),
),
// The Default implementation must always be the last.
DefaultNotificationActionIntentCreator(
logger = get(),
applicationContext = androidApplication(),
),
)
}
single<NotificationActionCreator<SystemNotification>>(named(NotificationActionCreator.TypeQualifier.System)) {
DefaultSystemNotificationActionCreator(
logger = get(),
actionIntentCreators = get(named<NotificationActionIntentCreator.TypeQualifier>()),
)
}
single<NotificationNotifier<SystemNotification>>(named<SystemNotificationNotifier>()) {
AndroidSystemNotificationNotifier(
logger = get(),
applicationContext = androidApplication(),
notificationActionCreator = get(named(NotificationActionCreator.TypeQualifier.System)),
)
}.onClose { notifier ->
notifier?.dispose()
}
}

View file

@ -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<PushServiceNotification.AlarmPermissionMissing, NotificationAction.Tap> {
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."
}
}
}

View file

@ -0,0 +1,50 @@
package net.thunderbird.feature.notification.impl.intent.action
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"
/**
* A default implementation of [NotificationActionIntentCreator] that creates a [PendingIntent]
* to launch the application when a notification action is triggered.
*
* This creator accepts any [NotificationAction] and always attempts to create a launch intent
* for the current application.
*
* @property logger The logger instance for logging debug messages.
* @property applicationContext The application context used to access system services like PackageManager.
*/
internal class DefaultNotificationActionIntentCreator(
private val logger: Logger,
private val applicationContext: Context,
) : NotificationActionIntentCreator<Notification, NotificationAction> {
override fun accept(notification: Notification, action: NotificationAction): Boolean = true
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),
) {
"Could not retrieve the launch intent from ${applicationContext.packageName}"
}
return PendingIntentCompat.getActivity(
/* context = */
applicationContext,
/* requestCode = */
1,
/* intent = */
launchIntent,
/* flags = */
0,
/* isMutable = */
false,
)
}
}

View file

@ -0,0 +1,36 @@
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
/**
* Interface for creating a [PendingIntent] for a given [NotificationAction].
*
* This interface is used to decouple the creation of [PendingIntent]s from the notification creation logic.
* Implementations of this interface should be registered in the Koin graph using the [TypeQualifier].
*
* @param TNotificationAction The type of [NotificationAction] this creator can handle.
*/
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(notification: Notification, action: NotificationAction): Boolean
/**
* Creates a [PendingIntent] for the given notification action.
*
* @param action The notification action to create an intent for.
* @return The created [PendingIntent], or `null` if the action is not supported or an error occurs.
*/
fun create(notification: TNotification, action: TNotificationAction): PendingIntent?
object TypeQualifier
}

View file

@ -0,0 +1,111 @@
package net.thunderbird.feature.notification.impl.receiver
import android.app.Notification
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import kotlin.time.ExperimentalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import net.thunderbird.core.logging.Logger
import net.thunderbird.feature.notification.api.NotificationId
import net.thunderbird.feature.notification.api.content.SystemNotification
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
import net.thunderbird.feature.notification.api.ui.style.SystemNotificationStyle
import net.thunderbird.feature.notification.impl.ui.action.NotificationActionCreator
private const val TAG = "AndroidSystemNotificationNotifier"
@OptIn(ExperimentalTime::class)
internal class AndroidSystemNotificationNotifier(
private val logger: Logger,
private val applicationContext: Context,
private val notificationActionCreator: NotificationActionCreator<SystemNotification>,
) : 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
}
}
}

View file

@ -0,0 +1,18 @@
package net.thunderbird.feature.notification.impl.ui.action
import android.app.PendingIntent
import androidx.annotation.DrawableRes
/**
* Represents an action that can be performed on an Android notification.
*
* @property icon The drawable resource ID for the action's icon.
* @property title The title of the action.
* @property pendingIntent The [PendingIntent] to be executed when the action is triggered.
*/
data class AndroidNotificationAction(
@param:DrawableRes
val icon: Int?,
val title: String?,
val pendingIntent: PendingIntent?,
)

View file

@ -0,0 +1,30 @@
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
private const val TAG = "DefaultSystemNotificationActionCreator"
internal class DefaultSystemNotificationActionCreator(
private val logger: Logger,
private val actionIntentCreators: List<NotificationActionIntentCreator<Notification, NotificationAction>>,
) : NotificationActionCreator<SystemNotification> {
override suspend fun create(
notification: SystemNotification,
action: NotificationAction,
): AndroidNotificationAction {
logger.debug(TAG) { "create() called with: notification = $notification, action = $action" }
val intent = actionIntentCreators
.first { it.accept(notification, action) }
.create(notification, action)
return AndroidNotificationAction(
icon = action.icon?.systemNotificationIcon,
title = action.resolveTitle(),
pendingIntent = intent,
)
}
}

View file

@ -0,0 +1,25 @@
package net.thunderbird.feature.notification.impl.ui.action
import net.thunderbird.feature.notification.api.content.Notification
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
/**
* Interface responsible for creating Android-specific notification actions ([AndroidNotificationAction])
* from generic notification actions ([NotificationAction]).
*
* This allows decoupling the core notification logic from the Android platform specifics.
*
* @param TNotification The type of [Notification] this creator can handle.
*/
interface NotificationActionCreator<TNotification : Notification> {
/**
* Creates an [AndroidNotificationAction] for the given [notification] and [action].
*
* @param notification The notification to create the action for.
* @param action The action to create.
* @return The created [AndroidNotificationAction].
*/
suspend fun create(notification: TNotification, action: NotificationAction): AndroidNotificationAction
enum class TypeQualifier { System, InApp }
}

View file

@ -0,0 +1,133 @@
package net.thunderbird.feature.notification.impl.intent.action
import android.app.Application
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.PendingIntentCompat
import androidx.test.core.app.ApplicationProvider
import assertk.all
import assertk.assertThat
import assertk.assertions.isEqualTo
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
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.spy
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultNotificationActionIntentCreatorTest {
private val application: Application = ApplicationProvider.getApplicationContext()
@Test
fun `accept should return true for any type of notification action`() {
// Arrange
val multipleActions = listOf(
NotificationAction.Tap,
NotificationAction.Reply,
NotificationAction.MarkAsRead,
NotificationAction.Delete,
NotificationAction.MarkAsSpam,
NotificationAction.Archive,
NotificationAction.UpdateServerSettings,
NotificationAction.Retry,
NotificationAction.CustomAction(title = "Custom Action 1"),
NotificationAction.CustomAction(title = "Custom Action 2"),
NotificationAction.CustomAction(title = "Custom Action 3"),
)
val testSubject = createTestSubject()
// Act
val accepted = multipleActions.fold(initial = true) { accepted, action ->
accepted and testSubject.accept(notification = FakeNotification(), action)
}
// Assert
assertThat(accepted).isTrue()
}
@Test
fun `create should return PendingIntent for any type of notification action`() {
// Arrange
mockStatic(PendingIntentCompat::class.java).use { pendingIntentCompat ->
// Arrange (cont.)
val expectedIntent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_LAUNCHER)
setPackage(application.packageName)
}
val mockedPackageManager = mock<PackageManager> {
on {
getLaunchIntentForPackage(eq(application.packageName))
} doReturn expectedIntent
}
val context = spy(application) {
on { packageManager } doReturn mockedPackageManager
}
pendingIntentCompat
.`when`<PendingIntent> {
PendingIntentCompat.getActivity(
/* context = */
any(),
/* requestCode = */
any(),
/* intent = */
any(),
/* flags = */
any(),
/* isMutable = */
eq(false),
)
}
.thenReturn(mock<PendingIntent>())
val intentCaptor = argumentCaptor<Intent>()
val testSubject = createTestSubject(context)
// Act
testSubject.create(notification = FakeNotification(), action = NotificationAction.Tap)
// Assert
pendingIntentCompat.verify {
PendingIntentCompat.getActivity(
/* context = */
eq(context),
/* requestCode = */
eq(1),
/* intent = */
intentCaptor.capture(),
/* flags = */
eq(0),
/* isMutable = */
eq(false),
)
}
assertThat(intentCaptor.firstValue).all {
prop(Intent::getAction).isEqualTo(Intent.ACTION_MAIN)
prop(Intent::getPackage).isEqualTo(application.packageName)
transform { intent -> intent.hasCategory(Intent.CATEGORY_LAUNCHER) }
.isTrue()
}
}
}
private fun createTestSubject(
context: Context = application,
): DefaultNotificationActionIntentCreator {
return DefaultNotificationActionIntentCreator(
logger = TestLogger(),
applicationContext = context,
)
}
}

View file

@ -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<Notification, NotificationId>, rather than MutableMap<NotificationId, Notification>,
// allowing for quick lookups (O(1) on average for MutableMap) to check if a notification is already present
// during registration.
private val _registrar = mutableMapOf<Notification, NotificationId>()
private val rawId = AtomicInt(value = 0)
override val registrar: Map<NotificationId, Notification> 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)
}
}

View file

@ -0,0 +1,64 @@
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.command.NotificationCommand
import net.thunderbird.feature.notification.api.command.NotificationCommandException
import net.thunderbird.feature.notification.api.content.InAppNotification
import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
private const val TAG = "InAppNotificationCommand"
/**
* A command that handles in-app notifications.
*
* This class is responsible for executing the logic associated with displaying an in-app notification.
*
* @param notification The [InAppNotification] to be handled.
* @param notifier The [NotificationNotifier] responsible for actually displaying the notification.
*/
internal class InAppNotificationCommand(
private val logger: Logger,
private val featureFlagProvider: FeatureFlagProvider,
private val notificationRegistry: NotificationRegistry,
notification: InAppNotification,
notifier: NotificationNotifier<InAppNotification>,
) : NotificationCommand<InAppNotification>(notification, notifier) {
private val isFeatureFlagEnabled: Boolean
get() = featureFlagProvider
.provide(FeatureFlagKey.DisplayInAppNotifications) == FeatureFlagResult.Enabled
override suspend fun execute(): Outcome<Success<InAppNotification>, Failure<InAppNotification>> {
logger.debug(TAG) { "execute() called with: notification = $notification" }
return when {
isFeatureFlagEnabled.not() ->
Outcome.failure(
error = Failure(
command = this,
throwable = NotificationCommandException(
message = "${FeatureFlagKey.DisplayInAppNotifications.key} feature flag is not enabled",
),
),
)
canExecuteCommand() -> {
notifier.show(id = notificationRegistry.register(notification), notification = notification)
Outcome.success(Success(command = this))
}
else -> {
Outcome.failure(Failure(command = this, throwable = Exception("Can't execute command.")))
}
}
}
// TODO(#9392): Verify if the app is on foreground. IF it isn't, then should fail
// executing the command
// TODO(#9420): If the app is on background and the severity is Fatal or Critical, we should
// let the command execute, but store it in a database instead of triggering the show notification logic.
private fun canExecuteCommand(): Boolean = true
}

View file

@ -0,0 +1,59 @@
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
/**
* A factory for creating a set of notification commands based on a given notification.
*/
internal class NotificationCommandFactory(
private val logger: Logger,
private val featureFlagProvider: FeatureFlagProvider,
private val notificationRegistry: NotificationRegistry,
private val systemNotificationNotifier: NotificationNotifier<SystemNotification>,
private val inAppNotificationNotifier: NotificationNotifier<InAppNotification>,
) {
/**
* Creates a set of [NotificationCommand]s for the given [notification].
*
* The commands are returned in a [LinkedHashSet] to preserve the order in which they should be executed.
*
* @param notification The notification for which to create commands.
* @return A set of notification commands.
*/
fun create(notification: Notification): LinkedHashSet<NotificationCommand<out Notification>> {
val commands = linkedSetOf<NotificationCommand<out Notification>>()
if (notification is SystemNotification) {
commands.add(
SystemNotificationCommand(
logger = logger,
featureFlagProvider = featureFlagProvider,
notificationRegistry = notificationRegistry,
notification = notification,
notifier = systemNotificationNotifier,
),
)
}
if (notification is InAppNotification) {
commands.add(
InAppNotificationCommand(
logger = logger,
featureFlagProvider = featureFlagProvider,
notificationRegistry = notificationRegistry,
notification = notification,
notifier = inAppNotificationNotifier,
),
)
}
return commands
}
}

View file

@ -0,0 +1,86 @@
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.
*
* @param notification The system notification to display.
* @param notifier The notifier responsible for displaying the notification.
*/
internal class SystemNotificationCommand(
private val logger: Logger,
private val featureFlagProvider: FeatureFlagProvider,
private val notificationRegistry: NotificationRegistry,
notification: SystemNotification,
notifier: NotificationNotifier<SystemNotification>,
private val isAppInBackground: () -> Boolean = {
// TODO(#9391): Verify if the app is backgrounded.
false
},
) : NotificationCommand<SystemNotification>(notification, notifier) {
private val isFeatureFlagEnabled: Boolean
get() = featureFlagProvider
.provide(FeatureFlagKey.UseNotificationSenderForSystemNotifications) == FeatureFlagResult.Enabled
override suspend fun execute(): Outcome<Success<SystemNotification>, Failure<SystemNotification>> {
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
}
}
}

View file

@ -0,0 +1,52 @@
package net.thunderbird.feature.notification.impl.inject
import net.thunderbird.feature.notification.api.NotificationRegistry
import net.thunderbird.feature.notification.api.content.InAppNotification
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
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.InAppNotificationEventBus
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.bind
import org.koin.dsl.module
internal expect val platformFeatureNotificationModule: Module
val featureNotificationModule = module {
includes(platformFeatureNotificationModule)
single<NotificationRegistry> { DefaultNotificationRegistry() }
single { InAppNotificationEventBus() }
.bind(InAppNotificationReceiver::class)
single<NotificationNotifier<InAppNotification>>(named<InAppNotificationNotifier>()) {
InAppNotificationNotifier(
logger = get(),
notificationRegistry = get(),
inAppNotificationEventBus = get(),
)
}
factory<NotificationCommandFactory> {
NotificationCommandFactory(
logger = get(),
featureFlagProvider = get(),
notificationRegistry = get(),
systemNotificationNotifier = get(named<SystemNotificationNotifier>()),
inAppNotificationNotifier = get(named<InAppNotificationNotifier>()),
)
}
single<NotificationSender> {
DefaultNotificationSender(
commandFactory = get(),
)
}
}

View file

@ -0,0 +1,31 @@
package net.thunderbird.feature.notification.impl.receiver
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
/**
* An event bus for in-app notifications.
*
* This interface extends [InAppNotificationReceiver] to allow listening for notification events,
* and adds a [publish] method to send new notification events.
*/
internal interface InAppNotificationEventBus : InAppNotificationReceiver {
/**
* Publishes an in-app notification event to the event bus.
*
* @param event The [InAppNotificationEvent] to be published.
*/
suspend fun publish(event: InAppNotificationEvent)
}
internal fun InAppNotificationEventBus(): InAppNotificationEventBus = object : InAppNotificationEventBus {
private val _events = MutableSharedFlow<InAppNotificationEvent>(replay = 1)
override val events: SharedFlow<InAppNotificationEvent> = _events.asSharedFlow()
override suspend fun publish(event: InAppNotificationEvent) {
_events.emit(event)
}
}

View file

@ -0,0 +1,34 @@
package net.thunderbird.feature.notification.impl.receiver
import net.thunderbird.core.logging.Logger
import net.thunderbird.feature.notification.api.NotificationId
import net.thunderbird.feature.notification.api.NotificationRegistry
import net.thunderbird.feature.notification.api.content.InAppNotification
import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
private const val TAG = "InAppNotificationNotifier"
/**
* This notifier is responsible for taking a [InAppNotification] data object and
* presenting it to the user in a suitable way.
*/
internal class InAppNotificationNotifier(
private val logger: Logger,
private val notificationRegistry: NotificationRegistry,
private val inAppNotificationEventBus: InAppNotificationEventBus,
) : NotificationNotifier<InAppNotification> {
override suspend fun show(id: NotificationId, notification: InAppNotification) {
logger.debug(TAG) { "show() called with: id = $id, notification = $notification" }
if (notificationRegistry.registrar.containsKey(id)) {
inAppNotificationEventBus.publish(
event = InAppNotificationEvent.Show(notification),
)
}
}
override fun dispose() {
logger.debug(TAG) { "dispose() called" }
}
}

View file

@ -0,0 +1,13 @@
package net.thunderbird.feature.notification.impl.receiver
import net.thunderbird.feature.notification.api.content.SystemNotification
import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
/**
* This notifier is responsible for taking a [SystemNotification] data object and
* presenting it to the user in a suitable way.
*
* **Note:** The current implementation is a placeholder and needs to be completed
* as part of GitHub Issue #9245.
*/
internal interface SystemNotificationNotifier : NotificationNotifier<SystemNotification>

View file

@ -0,0 +1,42 @@
package net.thunderbird.feature.notification.impl.sender
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.notification.api.command.NotificationCommand
import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure
import net.thunderbird.feature.notification.api.command.NotificationCommand.Success
import net.thunderbird.feature.notification.api.content.Notification
import net.thunderbird.feature.notification.api.sender.NotificationSender
import net.thunderbird.feature.notification.impl.command.NotificationCommandFactory
/**
* Responsible for sending notifications by creating and executing the appropriate commands.
*
* This class utilizes a [NotificationCommandFactory] to generate a list of
* [NotificationCommand]s based on the provided [Notification]. It then executes
* each command and emits the result of the execution as a [Flow].
*
* @param commandFactory The factory used to create notification commands.
*/
class DefaultNotificationSender internal constructor(
private val commandFactory: NotificationCommandFactory,
) : NotificationSender {
/**
* Sends a notification by creating and executing the appropriate commands.
*
* This function takes a [Notification] object, uses the [commandFactory] to generate
* a list of [NotificationCommand]s tailored to that notification, and then executes
* each command sequentially. The result of each command execution ([NotificationCommand.CommandOutcome])
* is emitted as part of the returned [Flow].
*
* @param notification The [Notification] to be sent.
* @return A [Flow] that emits the [NotificationCommand.CommandOutcome] for each executed command.
*/
override fun send(notification: Notification): Flow<Outcome<Success<Notification>, Failure<Notification>>> = flow {
val commands = commandFactory.create(notification)
commands.forEach { command ->
emit(command.execute())
}
}
}

View file

@ -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()
}
}

View file

@ -0,0 +1,141 @@
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.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.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.InAppNotification
import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
import net.thunderbird.feature.notification.testing.fake.FakeNotification
import net.thunderbird.feature.notification.testing.fake.FakeNotificationRegistry
import net.thunderbird.feature.notification.testing.fake.receiver.FakeInAppNotificationNotifier
@Suppress("MaxLineLength")
class InAppNotificationCommandTest {
@Test
fun `execute should return Failure when display_in_app_notifications feature flag is Disabled`() =
runTest {
// Arrange
val testSubject = createTestSubject(
featureFlagProvider = { key ->
when (key) {
FeatureFlagKey.DisplayInAppNotifications -> FeatureFlagResult.Disabled
else -> FeatureFlagResult.Enabled
}
},
)
// Act
val outcome = testSubject.execute()
// Assert
assertThat(outcome)
.isInstanceOf<Outcome.Failure<Failure<InAppNotification>>>()
.prop("error") { it.error }
.all {
prop(Failure<InAppNotification>::command)
.isEqualTo(testSubject)
prop(Failure<InAppNotification>::throwable)
.isInstanceOf<NotificationCommandException>()
.hasMessage(
"${FeatureFlagKey.DisplayInAppNotifications.key} feature flag is not enabled",
)
}
}
@Test
fun `execute should return Failure when display_in_app_notifications feature flag is Unavailable`() =
runTest {
// Arrange
val testSubject = createTestSubject(
featureFlagProvider = { key ->
when (key) {
FeatureFlagKey.DisplayInAppNotifications -> FeatureFlagResult.Unavailable
else -> FeatureFlagResult.Enabled
}
},
)
// Act
val outcome = testSubject.execute()
// Assert
assertThat(outcome)
.isInstanceOf<Outcome.Failure<Failure<InAppNotification>>>()
.prop("error") { it.error }
.all {
prop(Failure<InAppNotification>::command)
.isEqualTo(testSubject)
prop(Failure<InAppNotification>::throwable)
.isInstanceOf<NotificationCommandException>()
.hasMessage(
"${FeatureFlagKey.DisplayInAppNotifications.key} feature flag is not enabled",
)
}
}
@Test
fun `execute should return Success when display_in_app_notifications feature flag is Enabled`() =
runTest {
// Arrange
val notification = FakeNotification(
severity = NotificationSeverity.Information,
)
val notifier = spy(FakeInAppNotificationNotifier())
val testSubject = createTestSubject(
notification = notification,
notifier = notifier,
)
// Act
val outcome = testSubject.execute()
// Assert
assertThat(outcome)
.isInstanceOf<Outcome.Success<Success<InAppNotification>>>()
.prop("data") { it.data }
.all {
prop(Success<InAppNotification>::command)
.isEqualTo(testSubject)
}
verifySuspend(exactly(1)) {
notifier.show(id = any(), notification)
}
}
private fun createTestSubject(
notification: InAppNotification = FakeNotification(),
featureFlagProvider: FeatureFlagProvider = FeatureFlagProvider { FeatureFlagResult.Enabled },
notifier: NotificationNotifier<InAppNotification> = FakeInAppNotificationNotifier(),
notificationRegistry: NotificationRegistry = FakeNotificationRegistry(),
): InAppNotificationCommand {
val logger = TestLogger()
return InAppNotificationCommand(
logger = logger,
featureFlagProvider = featureFlagProvider,
notificationRegistry = notificationRegistry,
notification = notification,
notifier = notifier,
)
}
}

View file

@ -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<Outcome.Failure<Failure<SystemNotification>>>()
.prop("error") { it.error }
.all {
prop(Failure<SystemNotification>::command)
.isEqualTo(testSubject)
prop(Failure<SystemNotification>::throwable)
.isInstanceOf<NotificationCommandException>()
.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<Outcome.Failure<Failure<SystemNotification>>>()
.prop("error") { it.error }
.all {
prop(Failure<SystemNotification>::command)
.isEqualTo(testSubject)
prop(Failure<SystemNotification>::throwable)
.isInstanceOf<NotificationCommandException>()
.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<Outcome.Failure<Failure<SystemNotification>>>()
.prop("error") { it.error }
.all {
prop(Failure<SystemNotification>::command)
.isEqualTo(testSubject)
prop(Failure<SystemNotification>::throwable)
.isInstanceOf<NotificationCommandException>()
.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<Outcome.Success<Success<SystemNotification>>>()
.prop("data") { it.data }
.all {
prop(Success<SystemNotification>::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<Outcome.Success<Success<SystemNotification>>>()
.prop("data") { it.data }
.all {
prop(Success<SystemNotification>::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<Outcome.Success<Success<SystemNotification>>>()
.prop("data") { it.data }
.all {
prop(Success<SystemNotification>::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<Outcome.Success<Success<SystemNotification>>>()
.prop("data") { it.data }
.all {
prop(Success<SystemNotification>::command)
.isEqualTo(testSubject)
}
verifySuspend(exactly(1)) {
notifier.show(any(), notification)
}
}
private fun createTestSubject(
notification: SystemNotification = FakeNotification(),
featureFlagProvider: FeatureFlagProvider = FeatureFlagProvider { FeatureFlagResult.Enabled },
notifier: NotificationNotifier<SystemNotification> = 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<NotificationId, Notification>
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<SystemNotification> {
override suspend fun show(
id: NotificationId,
notification: SystemNotification,
) = Unit
override fun dispose() = Unit
}

View file

@ -0,0 +1,73 @@
package net.thunderbird.feature.notification.impl.receiver
import dev.mokkery.matcher.any
import dev.mokkery.spy
import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
import kotlin.test.Test
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.logging.testing.TestLogger
import net.thunderbird.feature.notification.api.NotificationId
import net.thunderbird.feature.notification.api.NotificationRegistry
import net.thunderbird.feature.notification.api.content.Notification
import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification
class InAppNotificationNotifierTest {
@Test
fun `show should not publish event when notification is already present in NotificationRegistry`() = runTest {
// Arrange
val notificationId = NotificationId(value = 1)
val notification = FakeInAppOnlyNotification()
val registrar = mapOf(notificationId to notification)
val eventBus = spy(InAppNotificationEventBus())
val testSubject = createTestSubject(registrar, eventBus)
// Act
testSubject.show(notificationId, notification)
// Assert
verifySuspend(exactly(1)) {
eventBus.publish(any())
}
}
@Test
fun `show should publish event when notification is not present in NotificationRegistry`() = runTest {
// Arrange
val notificationId = NotificationId(value = Int.MAX_VALUE)
val notification = FakeInAppOnlyNotification()
val registrar = buildMap<NotificationId, Notification> {
repeat(times = 100) { index ->
put(NotificationId(index), FakeInAppOnlyNotification(title = "fake title $index"))
}
}
val eventBus = spy(InAppNotificationEventBus())
val testSubject = createTestSubject(registrar, eventBus)
// Act
testSubject.show(notificationId, notification)
// Assert
verifySuspend(exactly(0)) {
eventBus.publish(any())
}
}
private fun createTestSubject(
registrar: Map<NotificationId, Notification>,
eventBus: InAppNotificationEventBus,
): InAppNotificationNotifier {
return InAppNotificationNotifier(
logger = TestLogger(),
notificationRegistry = object : NotificationRegistry {
override val registrar: Map<NotificationId, Notification> = registrar
override fun get(notificationId: NotificationId): Notification? = error("Not yet implemented")
override fun get(notification: Notification): NotificationId? = error("Not yet implemented")
override suspend fun register(notification: Notification): NotificationId = error("Not yet implemented")
override fun unregister(notificationId: NotificationId) = error("Not yet implemented")
override fun unregister(notification: Notification) = error("Not yet implemented")
},
inAppNotificationEventBus = eventBus,
)
}
}

View file

@ -0,0 +1,5 @@
package net.thunderbird.feature.notification.impl.inject
import org.koin.dsl.module
internal actual val platformFeatureNotificationModule = module { }