Skip to content

Commit 9ef3417

Browse files
committed
feat(notification): trigger in-app notification using InAppNotificationNotifier and event bus
1 parent ad26777 commit 9ef3417

File tree

12 files changed

+312
-11
lines changed

12 files changed

+312
-11
lines changed

feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreenPreview.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
77
import app.k9mail.core.ui.compose.common.koin.koinPreview
88
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark
99
import kotlinx.coroutines.flow.Flow
10+
import kotlinx.coroutines.flow.SharedFlow
1011
import kotlinx.coroutines.flow.flowOf
1112
import net.thunderbird.core.common.resources.StringsResourceManager
1213
import net.thunderbird.core.outcome.Outcome
@@ -16,6 +17,8 @@ import net.thunderbird.feature.mail.account.api.BaseAccount
1617
import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure
1718
import net.thunderbird.feature.notification.api.command.NotificationCommand.Success
1819
import net.thunderbird.feature.notification.api.content.Notification
20+
import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
21+
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
1922
import net.thunderbird.feature.notification.api.sender.NotificationSender
2023

2124
@PreviewLightDark
@@ -47,6 +50,10 @@ private fun SecretDebugSettingsScreenPreview() {
4750
): Flow<Outcome<Success<Notification>, Failure<Notification>>> =
4851
error("not implemented")
4952
},
53+
notificationReceiver = object : InAppNotificationReceiver {
54+
override val events: SharedFlow<InAppNotificationEvent>
55+
get() = error("not implemented")
56+
},
5057
)
5158
}
5259
} WithContent {

feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ val featureDebugSettingsModule = module {
1313
stringsResourceManager = get(),
1414
accountManager = get(),
1515
notificationSender = get(),
16+
notificationReceiver = get(),
1617
)
1718
}
1819
}

feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import kotlinx.collections.immutable.persistentListOf
88
import kotlinx.collections.immutable.toPersistentList
99
import kotlinx.coroutines.CoroutineDispatcher
1010
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.flow.collectLatest
1112
import kotlinx.coroutines.launch
1213
import kotlinx.coroutines.withContext
1314
import net.thunderbird.core.common.resources.StringsResourceManager
@@ -27,12 +28,14 @@ import net.thunderbird.feature.notification.api.content.MailNotification
2728
import net.thunderbird.feature.notification.api.content.Notification
2829
import net.thunderbird.feature.notification.api.content.PushServiceNotification
2930
import net.thunderbird.feature.notification.api.content.SystemNotification
31+
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
3032
import net.thunderbird.feature.notification.api.sender.NotificationSender
3133

3234
internal class DebugNotificationSectionViewModel(
3335
private val stringsResourceManager: StringsResourceManager,
3436
private val accountManager: AccountManager<BaseAccount>,
3537
private val notificationSender: NotificationSender,
38+
private val notificationReceiver: InAppNotificationReceiver,
3639
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
3740
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
3841
) : BaseViewModel<State, Event, Effect>(initialState = State()), DebugNotificationSectionContract.ViewModel {
@@ -76,6 +79,18 @@ internal class DebugNotificationSectionViewModel(
7679
}
7780
}
7881
}
82+
83+
viewModelScope.launch {
84+
notificationReceiver
85+
.events
86+
.collectLatest { event ->
87+
updateState { state ->
88+
state.copy(
89+
notificationStatusLog = state.notificationStatusLog + " In-app notification event: $event",
90+
)
91+
}
92+
}
93+
}
7994
}
8095

8196
override fun event(event: Event) {

feature/notification/api/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.internal.config.LanguageFeature
22

33
plugins {
44
id(ThunderbirdPlugins.Library.kmpCompose)
5+
alias(libs.plugins.dev.mokkery)
56
}
67

78
kotlin {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package net.thunderbird.feature.notification.api.receiver
2+
3+
import kotlinx.coroutines.flow.SharedFlow
4+
import net.thunderbird.feature.notification.api.content.InAppNotification
5+
6+
/**
7+
* Interface for receiving in-app notification events.
8+
*
9+
* This interface provides a [SharedFlow] of [InAppNotificationEvent]s that can be observed
10+
* by UI components or other parts of the application to react to in-app notifications.
11+
*/
12+
interface InAppNotificationReceiver {
13+
val events: SharedFlow<InAppNotificationEvent>
14+
}
15+
16+
sealed interface InAppNotificationEvent {
17+
data class Show(val notification: InAppNotification) : InAppNotificationEvent
18+
data class Dismiss(val notification: InAppNotification) : InAppNotificationEvent
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package net.thunderbird.feature.notification.api.receiver.compat
2+
3+
import androidx.annotation.Discouraged
4+
import kotlinx.coroutines.CoroutineDispatcher
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.Dispatchers
7+
import kotlinx.coroutines.DisposableHandle
8+
import kotlinx.coroutines.SupervisorJob
9+
import kotlinx.coroutines.cancel
10+
import kotlinx.coroutines.launch
11+
import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
12+
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
13+
14+
/**
15+
* A compatibility class for [InAppNotificationReceiver] that allows Java classes to observe notification events.
16+
*
17+
* This class is discouraged for use in Kotlin code. Use [InAppNotificationReceiver] directly instead.
18+
*
19+
* @param notificationReceiver The [InAppNotificationReceiver] instance to delegate to.
20+
* @param listener A callback function that will be invoked when a new [InAppNotificationEvent] is received.
21+
* @param mainImmediateDispatcher The [CoroutineDispatcher] to use for observing events.
22+
*/
23+
@Discouraged("Only for usage within a Java class. Use InAppNotificationReceiver instead.")
24+
class InAppNotificationReceiverCompat(
25+
private val notificationReceiver: InAppNotificationReceiver,
26+
listener: OnReceiveEventListener,
27+
mainImmediateDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
28+
) : InAppNotificationReceiver by notificationReceiver, DisposableHandle {
29+
private val scope = CoroutineScope(SupervisorJob() + mainImmediateDispatcher)
30+
31+
init {
32+
scope.launch {
33+
events.collect { event ->
34+
listener.onReceiveEvent(event)
35+
}
36+
}
37+
}
38+
39+
override fun dispose() {
40+
scope.cancel()
41+
}
42+
43+
fun interface OnReceiveEventListener {
44+
fun onReceiveEvent(event: InAppNotificationEvent)
45+
}
46+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package net.thunderbird.feature.notification.api.receiver.compat
2+
3+
import androidx.compose.ui.graphics.vector.ImageVector
4+
import androidx.compose.ui.unit.dp
5+
import assertk.assertThat
6+
import assertk.assertions.containsExactlyInAnyOrder
7+
import dev.mokkery.matcher.any
8+
import dev.mokkery.spy
9+
import dev.mokkery.verify
10+
import dev.mokkery.verify.VerifyMode.Companion.exactly
11+
import kotlin.test.Test
12+
import kotlinx.coroutines.ExperimentalCoroutinesApi
13+
import kotlinx.coroutines.flow.MutableSharedFlow
14+
import kotlinx.coroutines.flow.SharedFlow
15+
import kotlinx.coroutines.flow.asSharedFlow
16+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
17+
import kotlinx.coroutines.test.runTest
18+
import net.thunderbird.feature.notification.api.NotificationSeverity
19+
import net.thunderbird.feature.notification.api.content.AppNotification
20+
import net.thunderbird.feature.notification.api.content.InAppNotification
21+
import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
22+
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
23+
import net.thunderbird.feature.notification.api.receiver.compat.InAppNotificationReceiverCompat.OnReceiveEventListener
24+
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon
25+
26+
class InAppNotificationReceiverCompatTest {
27+
@OptIn(ExperimentalCoroutinesApi::class)
28+
@Test
29+
fun `onReceiveEvent should be triggered when an event is received into InAppNotificationReceiver`() = runTest {
30+
// Arrange
31+
val inAppNotificationReceiver = FakeInAppNotificationReceiver()
32+
val eventsTriggered = mutableListOf<InAppNotificationEvent>()
33+
val expectedEvents = List(size = 100) { index ->
34+
val title = "notification $index"
35+
when {
36+
index % 2 == 0 -> {
37+
InAppNotificationEvent.Show(FakeNotification(title = title))
38+
}
39+
40+
else -> InAppNotificationEvent.Dismiss(FakeNotification(title = title))
41+
}
42+
}.toTypedArray()
43+
val onReceiveEventListener = spy<OnReceiveEventListener>(
44+
OnReceiveEventListener { event ->
45+
eventsTriggered += event
46+
},
47+
)
48+
InAppNotificationReceiverCompat(
49+
notificationReceiver = inAppNotificationReceiver,
50+
listener = onReceiveEventListener,
51+
mainImmediateDispatcher = UnconfinedTestDispatcher(),
52+
)
53+
54+
// Act
55+
expectedEvents.forEach { event -> inAppNotificationReceiver.trigger(event) }
56+
57+
// Assert
58+
verify(exactly(100)) {
59+
onReceiveEventListener.onReceiveEvent(event = any())
60+
}
61+
assertThat(eventsTriggered).containsExactlyInAnyOrder(elements = expectedEvents)
62+
}
63+
64+
private class FakeInAppNotificationReceiver : InAppNotificationReceiver {
65+
private val _events = MutableSharedFlow<InAppNotificationEvent>()
66+
override val events: SharedFlow<InAppNotificationEvent> = _events.asSharedFlow()
67+
68+
suspend fun trigger(event: InAppNotificationEvent) {
69+
_events.emit(event)
70+
}
71+
}
72+
73+
data class FakeNotification(
74+
override val title: String = "fake title",
75+
override val contentText: String? = "fake content",
76+
override val severity: NotificationSeverity = NotificationSeverity.Information,
77+
override val icon: NotificationIcon = NotificationIcon(
78+
inAppNotificationIcon = ImageVector.Builder(
79+
defaultWidth = 0.dp,
80+
defaultHeight = 0.dp,
81+
viewportWidth = 0f,
82+
viewportHeight = 0f,
83+
).build(),
84+
),
85+
) : AppNotification(), InAppNotification
86+
}

feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import net.thunderbird.feature.notification.api.content.InAppNotification
88
import net.thunderbird.feature.notification.api.content.Notification
99
import net.thunderbird.feature.notification.api.content.SystemNotification
1010
import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
11-
import net.thunderbird.feature.notification.impl.receiver.InAppNotificationNotifier
1211
import net.thunderbird.feature.notification.impl.receiver.SystemNotificationNotifier
1312

1413
/**

feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,38 @@ package net.thunderbird.feature.notification.impl.inject
22

33
import net.thunderbird.feature.notification.api.NotificationRegistry
44
import net.thunderbird.feature.notification.api.content.InAppNotification
5+
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
56
import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
67
import net.thunderbird.feature.notification.api.sender.NotificationSender
78
import net.thunderbird.feature.notification.impl.DefaultNotificationRegistry
89
import net.thunderbird.feature.notification.impl.command.NotificationCommandFactory
10+
import net.thunderbird.feature.notification.impl.receiver.InAppNotificationEventBus
911
import net.thunderbird.feature.notification.impl.receiver.InAppNotificationNotifier
1012
import net.thunderbird.feature.notification.impl.receiver.SystemNotificationNotifier
1113
import net.thunderbird.feature.notification.impl.sender.DefaultNotificationSender
1214
import org.koin.core.module.Module
1315
import org.koin.core.qualifier.named
16+
import org.koin.dsl.bind
1417
import org.koin.dsl.module
1518

1619
internal expect val platformFeatureNotificationModule: Module
1720

1821
val featureNotificationModule = module {
1922
includes(platformFeatureNotificationModule)
2023

24+
single<NotificationRegistry> { DefaultNotificationRegistry() }
25+
2126
factory { SystemNotificationNotifier() }
22-
factory<NotificationNotifier<InAppNotification>>(named<InAppNotificationNotifier>()) {
23-
InAppNotificationNotifier()
27+
28+
single { InAppNotificationEventBus() }
29+
.bind(InAppNotificationReceiver::class)
30+
31+
single<NotificationNotifier<InAppNotification>>(named<InAppNotificationNotifier>()) {
32+
InAppNotificationNotifier(
33+
logger = get(),
34+
notificationRegistry = get(),
35+
inAppNotificationEventBus = get(),
36+
)
2437
}
2538

2639
factory<NotificationCommandFactory> {
@@ -38,6 +51,4 @@ val featureNotificationModule = module {
3851
commandFactory = get(),
3952
)
4053
}
41-
42-
single<NotificationRegistry> { DefaultNotificationRegistry() }
4354
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package net.thunderbird.feature.notification.impl.receiver
2+
3+
import kotlinx.coroutines.flow.MutableSharedFlow
4+
import kotlinx.coroutines.flow.SharedFlow
5+
import kotlinx.coroutines.flow.asSharedFlow
6+
import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
7+
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
8+
9+
/**
10+
* An event bus for in-app notifications.
11+
*
12+
* This interface extends [InAppNotificationReceiver] to allow listening for notification events,
13+
* and adds a [publish] method to send new notification events.
14+
*/
15+
internal interface InAppNotificationEventBus : InAppNotificationReceiver {
16+
/**
17+
* Publishes an in-app notification event to the event bus.
18+
*
19+
* @param event The [InAppNotificationEvent] to be published.
20+
*/
21+
suspend fun publish(event: InAppNotificationEvent)
22+
}
23+
24+
internal fun InAppNotificationEventBus(): InAppNotificationEventBus = object : InAppNotificationEventBus {
25+
private val _events = MutableSharedFlow<InAppNotificationEvent>(replay = 1)
26+
override val events: SharedFlow<InAppNotificationEvent> = _events.asSharedFlow()
27+
28+
override suspend fun publish(event: InAppNotificationEvent) {
29+
_events.emit(event)
30+
}
31+
}

0 commit comments

Comments
 (0)