Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
35
feature/notification/impl/build.gradle.kts
Normal file
35
feature/notification/impl/build.gradle.kts
Normal 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"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
</manifest>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package net.thunderbird.feature.notification.impl.inject
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
internal actual val platformFeatureNotificationModule = module { }
|
||||
Loading…
Add table
Add a link
Reference in a new issue