Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import app.k9mail.core.ui.compose.common.koin.koinPreview
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.flowOf
import net.thunderbird.core.common.resources.StringsResourceManager
import net.thunderbird.core.outcome.Outcome
Expand All @@ -16,6 +17,8 @@ import net.thunderbird.feature.mail.account.api.BaseAccount
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.receiver.InAppNotificationEvent
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
import net.thunderbird.feature.notification.api.sender.NotificationSender

@PreviewLightDark
Expand Down Expand Up @@ -47,6 +50,10 @@ private fun SecretDebugSettingsScreenPreview() {
): Flow<Outcome<Success<Notification>, Failure<Notification>>> =
error("not implemented")
},
notificationReceiver = object : InAppNotificationReceiver {
override val events: SharedFlow<InAppNotificationEvent>
get() = error("not implemented")
},
)
}
} WithContent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ val featureDebugSettingsModule = module {
stringsResourceManager = get(),
accountManager = get(),
notificationSender = get(),
notificationReceiver = get(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.thunderbird.core.common.resources.StringsResourceManager
Expand All @@ -27,12 +28,14 @@ import net.thunderbird.feature.notification.api.content.MailNotification
import net.thunderbird.feature.notification.api.content.Notification
import net.thunderbird.feature.notification.api.content.PushServiceNotification
import net.thunderbird.feature.notification.api.content.SystemNotification
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
import net.thunderbird.feature.notification.api.sender.NotificationSender

internal class DebugNotificationSectionViewModel(
private val stringsResourceManager: StringsResourceManager,
private val accountManager: AccountManager<BaseAccount>,
private val notificationSender: NotificationSender,
private val notificationReceiver: InAppNotificationReceiver,
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : BaseViewModel<State, Event, Effect>(initialState = State()), DebugNotificationSectionContract.ViewModel {
Expand Down Expand Up @@ -76,6 +79,18 @@ internal class DebugNotificationSectionViewModel(
}
}
}

viewModelScope.launch {
notificationReceiver
.events
.collectLatest { event ->
updateState { state ->
state.copy(
notificationStatusLog = state.notificationStatusLog + " In-app notification event: $event",
)
}
}
}
}

override fun event(event: Event) {
Expand Down
1 change: 1 addition & 0 deletions feature/notification/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.internal.config.LanguageFeature

plugins {
id(ThunderbirdPlugins.Library.kmpCompose)
alias(libs.plugins.dev.mokkery)
}

kotlin {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package net.thunderbird.feature.notification.api.receiver

import kotlinx.coroutines.flow.SharedFlow
import net.thunderbird.feature.notification.api.content.InAppNotification

/**
* Interface for receiving in-app notification events.
*
* This interface provides a [SharedFlow] of [InAppNotificationEvent]s that can be observed
* by UI components or other parts of the application to react to in-app notifications.
*/
interface InAppNotificationReceiver {
val events: SharedFlow<InAppNotificationEvent>
}

sealed interface InAppNotificationEvent {
data class Show(val notification: InAppNotification) : InAppNotificationEvent
data class Dismiss(val notification: InAppNotification) : InAppNotificationEvent
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package net.thunderbird.feature.notification.api.receiver.compat

import androidx.annotation.Discouraged
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver

/**
* A compatibility class for [InAppNotificationReceiver] that allows Java classes to observe notification events.
*
* This class is discouraged for use in Kotlin code. Use [InAppNotificationReceiver] directly instead.
*
* @param notificationReceiver The [InAppNotificationReceiver] instance to delegate to.
* @param listener A callback function that will be invoked when a new [InAppNotificationEvent] is received.
* @param mainImmediateDispatcher The [CoroutineDispatcher] to use for observing events.
*/
@Discouraged("Only for usage within a Java class. Use InAppNotificationReceiver instead.")
class InAppNotificationReceiverCompat(
private val notificationReceiver: InAppNotificationReceiver,
listener: OnReceiveEventListener,
mainImmediateDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
) : InAppNotificationReceiver by notificationReceiver, DisposableHandle {
private val scope = CoroutineScope(SupervisorJob() + mainImmediateDispatcher)

init {
scope.launch {
events.collect { event ->
listener.onReceiveEvent(event)
}
}
}

override fun dispose() {
scope.cancel()
}

fun interface OnReceiveEventListener {
fun onReceiveEvent(event: InAppNotificationEvent)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package net.thunderbird.feature.notification.api.receiver.compat

import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import assertk.assertThat
import assertk.assertions.containsExactlyInAnyOrder
import dev.mokkery.matcher.any
import dev.mokkery.spy
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.exactly
import kotlin.test.Test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import net.thunderbird.feature.notification.api.NotificationSeverity
import net.thunderbird.feature.notification.api.content.AppNotification
import net.thunderbird.feature.notification.api.content.InAppNotification
import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
import net.thunderbird.feature.notification.api.receiver.compat.InAppNotificationReceiverCompat.OnReceiveEventListener
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon

class InAppNotificationReceiverCompatTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `onReceiveEvent should be triggered when an event is received into InAppNotificationReceiver`() = runTest {
// Arrange
val inAppNotificationReceiver = FakeInAppNotificationReceiver()
val eventsTriggered = mutableListOf<InAppNotificationEvent>()
val expectedEvents = List(size = 100) { index ->
val title = "notification $index"
when {
index % 2 == 0 -> {
InAppNotificationEvent.Show(FakeNotification(title = title))
}

else -> InAppNotificationEvent.Dismiss(FakeNotification(title = title))
}
}.toTypedArray()
val onReceiveEventListener = spy<OnReceiveEventListener>(
OnReceiveEventListener { event ->
eventsTriggered += event
},
)
InAppNotificationReceiverCompat(
notificationReceiver = inAppNotificationReceiver,
listener = onReceiveEventListener,
mainImmediateDispatcher = UnconfinedTestDispatcher(),
)

// Act
expectedEvents.forEach { event -> inAppNotificationReceiver.trigger(event) }

// Assert
verify(exactly(100)) {
onReceiveEventListener.onReceiveEvent(event = any())
}
assertThat(eventsTriggered).containsExactlyInAnyOrder(elements = expectedEvents)
}

private class FakeInAppNotificationReceiver : InAppNotificationReceiver {
private val _events = MutableSharedFlow<InAppNotificationEvent>()
override val events: SharedFlow<InAppNotificationEvent> = _events.asSharedFlow()

suspend fun trigger(event: InAppNotificationEvent) {
_events.emit(event)
}
}

data class FakeNotification(
override val title: String = "fake title",
override val contentText: String? = "fake content",
override val severity: NotificationSeverity = NotificationSeverity.Information,
override val icon: NotificationIcon = NotificationIcon(
inAppNotificationIcon = ImageVector.Builder(
defaultWidth = 0.dp,
defaultHeight = 0.dp,
viewportWidth = 0f,
viewportHeight = 0f,
).build(),
),
) : AppNotification(), InAppNotification
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
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.
*
Expand All @@ -16,13 +23,42 @@ import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
*/
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 {
"TODO: Implementation on GitHub Issue #9245. Notification = $notification."
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.")))
}
}
return Outcome.success(data = Success(command = this))
}

// 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import net.thunderbird.feature.notification.api.content.InAppNotification
import net.thunderbird.feature.notification.api.content.Notification
import net.thunderbird.feature.notification.api.content.SystemNotification
import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
import net.thunderbird.feature.notification.impl.receiver.InAppNotificationNotifier

/**
* A factory for creating a set of notification commands based on a given notification.
Expand All @@ -18,7 +17,7 @@ internal class NotificationCommandFactory(
private val featureFlagProvider: FeatureFlagProvider,
private val notificationRegistry: NotificationRegistry,
private val systemNotificationNotifier: NotificationNotifier<SystemNotification>,
private val inAppNotificationNotifier: InAppNotificationNotifier,
private val inAppNotificationNotifier: NotificationNotifier<InAppNotification>,
) {
/**
* Creates a set of [NotificationCommand]s for the given [notification].
Expand Down Expand Up @@ -47,6 +46,8 @@ internal class NotificationCommandFactory(
commands.add(
InAppNotificationCommand(
logger = logger,
featureFlagProvider = featureFlagProvider,
notificationRegistry = notificationRegistry,
notification = notification,
notifier = inAppNotificationNotifier,
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
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)

factory { InAppNotificationNotifier() }
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(),
notificationRegistry = get(),
featureFlagProvider = get(),
notificationRegistry = get(),
systemNotificationNotifier = get(named<SystemNotificationNotifier>()),
inAppNotificationNotifier = get(),
inAppNotificationNotifier = get(named<InAppNotificationNotifier>()),
)
}

Expand All @@ -33,6 +49,4 @@ val featureNotificationModule = module {
commandFactory = get(),
)
}

single<NotificationRegistry> { DefaultNotificationRegistry() }
}
Loading