Skip to content

Commit 19cf477

Browse files
committed
feat(notification): add NotificationRegistry to manage notifications associating them to its corresponding ids
1 parent 60372f9 commit 19cf477

File tree

5 files changed

+319
-1
lines changed

5 files changed

+319
-1
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package net.thunderbird.feature.notification.api
2+
3+
import net.thunderbird.feature.notification.api.content.Notification
4+
5+
/**
6+
* A registry for managing notifications and their corresponding IDs.
7+
*
8+
* It establishes and maintains the correlation between a [Notification] object
9+
* and its unique [NotificationId].
10+
* This can also be used to track which notifications are currently being displayed
11+
* to the user.
12+
*/
13+
interface NotificationRegistry {
14+
/**
15+
* A [Map] off all the current notifications, associated with their IDs,
16+
* being displayed to the user.
17+
*/
18+
val registrar: Map<NotificationId, Notification>
19+
20+
/**
21+
* Retrieves a [Notification] object based on its [notificationId].
22+
*
23+
* @param notificationId The ID of the notification to retrieve.
24+
* @return The [Notification] object associated with the given [notificationId],
25+
* or `null` if no such notification exists.
26+
*/
27+
operator fun get(notificationId: NotificationId): Notification?
28+
29+
/**
30+
* Retrieves the [NotificationId] associated with the given [notification].
31+
*
32+
* @param notification The notification for which to retrieve the ID.
33+
* @return The [NotificationId] if the notification is registered, or `null` otherwise.
34+
*/
35+
operator fun get(notification: Notification): NotificationId?
36+
37+
/**
38+
* Registers a notification and returns its unique ID.
39+
*
40+
* If the provided [notification] is already registered, this function will effectively
41+
* return its known [NotificationId].
42+
*
43+
* @param notification The [Notification] object to register.
44+
* @return The unique [NotificationId] assigned to the registered notification.
45+
*/
46+
suspend fun register(notification: Notification): NotificationId
47+
48+
/**
49+
* Unregisters a [Notification] by its [NotificationId].
50+
*
51+
* @param notificationId The ID of the notification to unregister.
52+
*/
53+
fun unregister(notificationId: NotificationId)
54+
55+
/**
56+
* Unregisters a previously registered notification.
57+
*
58+
* @param notification The [Notification] object to unregister.
59+
*/
60+
fun unregister(notification: Notification)
61+
}

feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AppNotification.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ sealed interface Notification {
4949
* @property actions A set of actions that can be performed on the notification. Defaults to an empty set.
5050
* @see Notification
5151
*/
52-
sealed class AppNotification : Notification {
52+
abstract class AppNotification : Notification {
5353
override val accessibilityText: String = title
5454

5555
@OptIn(ExperimentalTime::class)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package net.thunderbird.feature.notification.impl
2+
3+
import kotlin.concurrent.atomics.AtomicInt
4+
import kotlin.concurrent.atomics.ExperimentalAtomicApi
5+
import kotlin.concurrent.atomics.incrementAndFetch
6+
import kotlinx.coroutines.sync.Mutex
7+
import kotlinx.coroutines.sync.withLock
8+
import net.thunderbird.feature.notification.api.NotificationId
9+
import net.thunderbird.feature.notification.api.NotificationRegistry
10+
import net.thunderbird.feature.notification.api.content.Notification
11+
12+
@OptIn(ExperimentalAtomicApi::class)
13+
class DefaultNotificationRegistry : NotificationRegistry {
14+
private val mutex = Mutex()
15+
16+
// We use a MutableMap<Notification, NotificationId>, rather than MutableMap<NotificationId, Notification>,
17+
// allowing for quick lookups (O(1) on average for MutableMap) to check if a notification is already present
18+
// during registration.
19+
private val _registrar = mutableMapOf<Notification, NotificationId>()
20+
private val rawId = AtomicInt(value = 0)
21+
22+
override val registrar: Map<NotificationId, Notification> get() = _registrar
23+
.entries
24+
.associate { (notification, notificationId) -> notificationId to notification }
25+
26+
override fun get(notificationId: NotificationId): Notification? {
27+
return _registrar
28+
.entries
29+
.firstOrNull { (_, value) -> value == notificationId }
30+
?.key
31+
}
32+
33+
override fun get(notification: Notification): NotificationId? {
34+
return _registrar[notification]
35+
}
36+
37+
override suspend fun register(notification: Notification): NotificationId {
38+
return mutex.withLock {
39+
val existingNotificationId = get(notification)
40+
if (existingNotificationId != null) {
41+
return@withLock existingNotificationId
42+
}
43+
44+
val id = rawId.incrementAndFetch()
45+
val notificationId = NotificationId(id)
46+
_registrar.put(notification, notificationId)
47+
48+
notificationId
49+
}
50+
}
51+
52+
override fun unregister(notificationId: NotificationId) {
53+
val notification = get(notificationId)
54+
_registrar.remove(notification)
55+
}
56+
57+
override fun unregister(notification: Notification) {
58+
_registrar.remove(notification)
59+
}
60+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package net.thunderbird.feature.notification.impl.inject
22

3+
import net.thunderbird.feature.notification.api.NotificationRegistry
34
import net.thunderbird.feature.notification.api.sender.NotificationSender
5+
import net.thunderbird.feature.notification.impl.DefaultNotificationRegistry
46
import net.thunderbird.feature.notification.impl.command.NotificationCommandFactory
57
import net.thunderbird.feature.notification.impl.receiver.InAppNotificationNotifier
68
import net.thunderbird.feature.notification.impl.receiver.SystemNotificationNotifier
@@ -29,4 +31,6 @@ val featureNotificationModule = module {
2931
commandFactory = get(),
3032
)
3133
}
34+
35+
single<NotificationRegistry> { DefaultNotificationRegistry() }
3236
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package net.thunderbird.feature.notification.impl
2+
3+
import androidx.compose.ui.graphics.vector.ImageVector
4+
import androidx.compose.ui.unit.dp
5+
import assertk.assertThat
6+
import assertk.assertions.containsAtLeast
7+
import assertk.assertions.hasSize
8+
import assertk.assertions.isEqualTo
9+
import assertk.assertions.isNotNull
10+
import assertk.assertions.isNull
11+
import kotlin.concurrent.thread
12+
import kotlin.test.Test
13+
import kotlinx.coroutines.runBlocking
14+
import kotlinx.coroutines.test.runTest
15+
import net.thunderbird.feature.notification.api.NotificationId
16+
import net.thunderbird.feature.notification.api.NotificationSeverity
17+
import net.thunderbird.feature.notification.api.content.AppNotification
18+
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon
19+
20+
@Suppress("MaxLineLength")
21+
class DefaultNotificationRegistryTest {
22+
@Test
23+
fun `register should return NotificationId given notification`() = runTest {
24+
// Arrange
25+
val notification = FakeNotification()
26+
val registry = DefaultNotificationRegistry()
27+
28+
// Act
29+
val notificationId = registry.register(notification)
30+
31+
// Assert
32+
assertThat(registry[notificationId])
33+
.isNotNull()
34+
.isEqualTo(notification)
35+
}
36+
37+
@Test
38+
fun `register should return same NotificationId when registering the same notification multiple times`() = runTest {
39+
// Arrange
40+
val notification = FakeNotification()
41+
val registry = DefaultNotificationRegistry()
42+
43+
// Act
44+
val notificationId1 = registry.register(notification)
45+
val notificationId2 = registry.register(notification)
46+
47+
// Assert
48+
assertThat(notificationId1)
49+
.isEqualTo(notificationId2)
50+
assertThat(registry[notificationId1])
51+
.isNotNull()
52+
.isEqualTo(notification)
53+
assertThat(registry[notificationId2])
54+
.isNotNull()
55+
.isEqualTo(notification)
56+
}
57+
58+
@Test
59+
fun `register should not register duplicated notifications when running concurrently`() = runTest {
60+
// Arrange
61+
val notificationSize = 100
62+
val registerTries = 50
63+
val notifications = List(size = notificationSize) { index ->
64+
FakeNotification(
65+
title = "fake notification $index",
66+
)
67+
}
68+
val expectedNotificationIds = List(size = notificationSize) { index ->
69+
NotificationId(value = index + 1)
70+
}
71+
val registry = DefaultNotificationRegistry()
72+
73+
// Act
74+
List(size = registerTries) {
75+
thread(start = true) {
76+
notifications.forEach { notification ->
77+
runBlocking {
78+
registry.register(notification)
79+
}
80+
}
81+
}
82+
}.forEach {
83+
it.join()
84+
}
85+
86+
// Assert
87+
val registrar = registry.registrar
88+
assertThat(registrar).hasSize(notificationSize)
89+
assertThat(registrar)
90+
.containsAtLeast(elements = expectedNotificationIds.zip(notifications).toTypedArray())
91+
}
92+
93+
@Test
94+
fun `operator get Notification should return NotificationId when notification is in the registrar`() = runTest {
95+
// Arrange
96+
val notification = FakeNotification()
97+
val registry = DefaultNotificationRegistry()
98+
registry.register(notification)
99+
100+
// Act
101+
val notificationId = registry[notification]
102+
103+
// Assert
104+
assertThat(notificationId).isNotNull()
105+
}
106+
107+
@Test
108+
fun `operator get Notification should return null when notification is NOT in the registrar`() = runTest {
109+
// Arrange
110+
val notification = FakeNotification()
111+
val notRegisteredNotification = FakeNotification(title = "that is not registered!!")
112+
val registry = DefaultNotificationRegistry()
113+
registry.register(notification)
114+
115+
// Act
116+
val notificationId = registry[notRegisteredNotification]
117+
118+
// Assert
119+
assertThat(notificationId).isNull()
120+
}
121+
122+
@Test
123+
fun `operator get NotificationId should return Notification when notification is in the registrar`() = runTest {
124+
// Arrange
125+
val notification = FakeNotification()
126+
val registry = DefaultNotificationRegistry()
127+
val notificationId = registry.register(notification)
128+
129+
// Act
130+
val registrarNotification = registry[notificationId]
131+
132+
// Assert
133+
assertThat(registrarNotification).isNotNull()
134+
}
135+
136+
@Test
137+
fun `operator get NotificationId should return null when notification is NOT in the registrar`() = runTest {
138+
// Arrange
139+
val registry = DefaultNotificationRegistry()
140+
val notification = FakeNotification()
141+
registry.register(notification)
142+
143+
// Act
144+
val notificationId = registry[NotificationId(value = Int.MAX_VALUE)]
145+
146+
// Assert
147+
assertThat(notificationId).isNull()
148+
}
149+
150+
@Test
151+
fun `unregister should remove notification from registrar when given a notification object and Notification is in registrar`() =
152+
runTest {
153+
// Arrange
154+
val registry = DefaultNotificationRegistry()
155+
val notification = FakeNotification()
156+
registry.register(notification)
157+
158+
// Act
159+
registry.unregister(notification)
160+
161+
// Assert
162+
assertThat(registry[notification]).isNull()
163+
}
164+
165+
@Test
166+
fun `unregister should remove notification from registrar when given a notification id and Notification is in registrar`() =
167+
runTest {
168+
// Arrange
169+
val registry = DefaultNotificationRegistry()
170+
val notification = FakeNotification()
171+
val notificationId = registry.register(notification)
172+
173+
// Act
174+
registry.unregister(notificationId)
175+
176+
// Assert
177+
assertThat(registry[notification]).isNull()
178+
}
179+
180+
data class FakeNotification(
181+
override val title: String = "fake title",
182+
override val contentText: String? = "fake content",
183+
override val severity: NotificationSeverity = NotificationSeverity.Information,
184+
override val icon: NotificationIcon = NotificationIcon(
185+
inAppNotificationIcon = ImageVector.Builder(
186+
defaultWidth = 0.dp,
187+
defaultHeight = 0.dp,
188+
viewportWidth = 0f,
189+
viewportHeight = 0f,
190+
).build(),
191+
),
192+
) : AppNotification()
193+
}

0 commit comments

Comments
 (0)