diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt index 2b6adce2720..ef0316b3187 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt @@ -114,6 +114,12 @@ class NotConditionDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): NotificationScheduleType = + NotificationScheduleType.fromString(p.valueAsString) +} + private fun JsonNode.toNotificationExpressions(p: JsonParser): List = this.map { element -> val parser = element.traverse(p.codec) parser.nextToken() diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt index 425eaf4c00a..f582ce7d320 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt @@ -27,8 +27,11 @@ data class NotificationData( ) data class NotificationSchedule( - val type: String, -) + @JsonDeserialize(using = NotificationTypeDeserializer::class) + val type: NotificationScheduleType, +) { + constructor(type: String) : this(NotificationScheduleType.fromString(type)) +} enum class NotificationSeverity { INFO, @@ -36,6 +39,20 @@ enum class NotificationSeverity { CRITICAL, } +enum class NotificationScheduleType { + STARTUP, + EMERGENCY, + ; + + companion object { + fun fromString(value: String): NotificationScheduleType = + when (value.lowercase()) { + "startup" -> STARTUP + else -> EMERGENCY + } + } +} + data class NotificationContentDescriptionLocale( @JsonProperty("en-US") val locale: NotificationContentDescription, diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPanel.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPanel.kt index 8658d67df27..e9b6564adc8 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPanel.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPanel.kt @@ -15,13 +15,14 @@ class NotificationPanel : BorderLayoutPanel() { init { isOpaque = false addToCenter(wrapper) - ProcessNotificationsBase.showBannerNotification.forEach { - updateNotificationPanel(it.value) + BannerNotificationService.getInstance().getNotifications().forEach { (_, content) -> + updateNotificationPanel(content) } } private fun removeNotificationPanel(notificationId: String) = runInEdt { - ProcessNotificationsBase.showBannerNotification.remove(notificationId) // TODO: add id to dismissed notification list + BannerNotificationService.getInstance().removeNotification(notificationId) + NotificationDismissalState.getInstance().dismissNotification(notificationId) wrapper.removeAll() } diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt index 70f85bb3645..be387445723 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt @@ -6,10 +6,7 @@ package software.aws.toolkits.jetbrains.core.notifications import com.fasterxml.jackson.module.kotlin.readValue import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.components.PersistentStateComponent import com.intellij.openapi.components.Service -import com.intellij.openapi.components.State -import com.intellij.openapi.components.Storage import com.intellij.util.Alarm import com.intellij.util.AlarmFactory import com.intellij.util.io.HttpRequests @@ -63,32 +60,6 @@ object NotificationEndpoint { private const val DEFAULT_ENDPOINT = "" // TODO: Replace with actual endpoint } -@State(name = "notificationEtag", storages = [Storage("aws.xml")]) -class NotificationEtagState : PersistentStateComponent { - private var state = NotificationEtagConfiguration() - - override fun getState(): NotificationEtagConfiguration = state - - override fun loadState(state: NotificationEtagConfiguration) { - this.state = state - } - - var etag: String? - get() = state.etag - set(value) { - state.etag = value - } - - companion object { - fun getInstance(): NotificationEtagState = - ApplicationManager.getApplication().getService(NotificationEtagState::class.java) - } -} - -data class NotificationEtagConfiguration( - var etag: String? = null, -) - @Service(Service.Level.APP) internal final class NotificationPollingService : Disposable { private val isFirstPoll = AtomicBoolean(true) diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationServiceInitializer.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationServiceInitializer.kt index 5d63d907ba0..163390fddb3 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationServiceInitializer.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationServiceInitializer.kt @@ -14,7 +14,6 @@ internal class NotificationServiceInitializer : ProjectActivity { override suspend fun execute(project: Project) { if (initialized.compareAndSet(false, true)) { val service = NotificationPollingService.getInstance() - ProcessNotificationsBase() service.startPolling() } } diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationStateUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationStateUtils.kt new file mode 100644 index 00000000000..ce53118e249 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationStateUtils.kt @@ -0,0 +1,87 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.RoamingType +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +@Service +@State(name = "notificationDismissals", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)]) +class NotificationDismissalState : PersistentStateComponent { + private val state = NotificationDismissalConfiguration() + + override fun getState(): NotificationDismissalConfiguration = state + + override fun loadState(state: NotificationDismissalConfiguration) { + this.state.dismissedNotificationIds.clear() + this.state.dismissedNotificationIds.addAll(state.dismissedNotificationIds) + } + + fun isDismissed(notificationId: String): Boolean = + state.dismissedNotificationIds.contains(notificationId) + + fun dismissNotification(notificationId: String) { + state.dismissedNotificationIds.add(notificationId) + } + + companion object { + fun getInstance(): NotificationDismissalState = + service() + } +} + +data class NotificationDismissalConfiguration( + var dismissedNotificationIds: MutableSet = mutableSetOf(), +) + +@Service +@State(name = "notificationEtag", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)]) +class NotificationEtagState : PersistentStateComponent { + private val state = NotificationEtagConfiguration() + + override fun getState(): NotificationEtagConfiguration = state + + override fun loadState(state: NotificationEtagConfiguration) { + this.state.etag = state.etag + } + + var etag: String? + get() = state.etag + set(value) { + state.etag = value + } + + companion object { + fun getInstance(): NotificationEtagState = + service() + } +} + +data class NotificationEtagConfiguration( + var etag: String? = null, +) + +@Service +class BannerNotificationService { + private val notifications = mutableMapOf() + + fun addNotification(id: String, content: BannerContent) { + notifications[id] = content + } + + fun getNotifications(): Map = notifications + + fun removeNotification(id: String) { + notifications.remove(id) + } + + companion object { + fun getInstance(): BannerNotificationService = + service() + } +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt index b5aa5560e6c..5bbf34bba00 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt @@ -4,6 +4,7 @@ package software.aws.toolkits.jetbrains.core.notifications import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.intellij.notification.NotificationType @@ -15,16 +16,20 @@ import com.intellij.openapi.project.Project import software.aws.toolkits.core.utils.inputStream import software.aws.toolkits.jetbrains.utils.notifyStickyWithData import java.nio.file.Paths +import java.util.concurrent.atomic.AtomicBoolean object NotificationMapperUtil { - val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + val mapper: ObjectMapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) } +private var isStartup: AtomicBoolean = AtomicBoolean(true) @Service(Service.Level.PROJECT) -class ProcessNotificationsBase { +class ProcessNotificationsBase( + private val project: Project, +) { private val notifListener = mutableListOf() init { - NotificationPollingService.getInstance().addObserver { -> + NotificationPollingService.getInstance().addObserver { retrieveStartupAndEmergencyNotifications() } } @@ -39,8 +44,23 @@ class ProcessNotificationsBase { } fun retrieveStartupAndEmergencyNotifications() { - // TODO: separates notifications into startup and emergency - // iterates through the 2 lists and processes each notification(if it isn't dismissed) + val isStartupPoll = isStartup.compareAndSet(true, false) + val notifications = getNotificationsFromFile() + notifications?.let { notificationsList -> + val activeNotifications = notificationsList.notifications + ?.filter { notification -> + // Keep notification if: + // - it's not a startup notification, OR + // - it is a startup notification AND this is the first poll + notification.schedule.type != NotificationScheduleType.STARTUP || isStartupPoll + } + ?.filter { notification -> + !NotificationDismissalState.getInstance().isDismissed(notification.id) + } + .orEmpty() + + activeNotifications.forEach { processNotification(project, it) } + } } fun processNotification(project: Project, notificationData: NotificationData) { @@ -63,7 +83,7 @@ class ProcessNotificationsBase { ) if (severity == "Critical") { val bannerContent = BannerContent(notificationContent.title, notificationContent.description, followupActions, notificationData.id) - showBannerNotification[notificationData.id] = bannerContent + BannerNotificationService.getInstance().addNotification(notificationData.id, bannerContent) notifyListenerForNotification(bannerContent) } } @@ -87,7 +107,6 @@ class ProcessNotificationsBase { companion object { fun getInstance(project: Project): ProcessNotificationsBase = project.service() - val showBannerNotification = mutableMapOf() private const val NOTIFICATIONS_PATH = "aws-static-resources/notifications.json" } } diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt index 12377a83792..5040a199f55 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt @@ -17,7 +17,8 @@ import com.intellij.ui.ScrollPaneFactory import org.slf4j.LoggerFactory import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.core.help.HelpIds -import software.aws.toolkits.jetbrains.core.notifications.ProcessNotificationsBase +import software.aws.toolkits.jetbrains.core.notifications.BannerNotificationService +import software.aws.toolkits.jetbrains.core.notifications.NotificationDismissalState import software.aws.toolkits.resources.AwsCoreBundle import javax.swing.JLabel import javax.swing.JTextArea @@ -66,8 +67,8 @@ fun notifyStickyWithData( createNotificationExpiringAction( object : AnAction("Dismiss") { override fun actionPerformed(e: AnActionEvent) { - ProcessNotificationsBase.showBannerNotification.remove(id) - // TODO: add id to dismissed notification list + BannerNotificationService.getInstance().removeNotification(id) + NotificationDismissalState.getInstance().dismissNotification(id) } } ) diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBaseTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBaseTest.kt new file mode 100644 index 00000000000..01b77f2234a --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBaseTest.kt @@ -0,0 +1,161 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.openapi.project.Project +import com.intellij.testFramework.ApplicationExtension +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.util.concurrent.atomic.AtomicBoolean + +@ExtendWith(ApplicationExtension::class) +class ProcessNotificationsBaseTest { + private lateinit var sut: ProcessNotificationsBase + private lateinit var project: Project + private lateinit var dismissalState: NotificationDismissalState + + @BeforeEach + fun setUp() { + project = mockk() + dismissalState = spyk(NotificationDismissalState()) + + mockkObject(NotificationDismissalState) + every { NotificationDismissalState.getInstance() } returns dismissalState + + sut = spyk( + objToCopy = ProcessNotificationsBase(project) + ) + } + + @Test + fun `startup notifications are only processed on first poll`() { + resetIsStartup() + val startupNotification = createNotification("startup-1", NotificationScheduleType.STARTUP) + every { sut["getNotificationsFromFile"]() } returns createNotificationsList(startupNotification) + every { dismissalState.isDismissed(any()) } returns false + + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 1) { sut.processNotification(project, startupNotification) } + + // Second poll + sut.retrieveStartupAndEmergencyNotifications() + + // Verify processNotification wasn't called again + verify(exactly = 1) { sut.processNotification(project, any()) } + } + + @Test + fun `non startup notifications are processed on every poll`() { + val emergencyNotification = createNotification("emergency-1", NotificationScheduleType.EMERGENCY) + every { sut["getNotificationsFromFile"]() } returns createNotificationsList(emergencyNotification) + every { dismissalState.isDismissed(any()) } returns false + + // First poll + sut.retrieveStartupAndEmergencyNotifications() + // Second poll + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 2) { sut.processNotification(project, emergencyNotification) } + } + + @Test + fun `dismissed notifications are not processed`() { + val notification = createNotification("toBeDismissed-1", NotificationScheduleType.EMERGENCY) + every { sut["getNotificationsFromFile"]() } returns createNotificationsList(notification) + + // first poll results in showing/dismissal + sut.retrieveStartupAndEmergencyNotifications() + NotificationDismissalState.getInstance().dismissNotification(notification.id) + + // second poll skips processing + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 1) { sut.processNotification(project, any()) } + } + + @Test + fun `null notifications list is handled gracefully`() { + every { sut["getNotificationsFromFile"]() } returns null + + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 0) { sut.processNotification(project, any()) } + } + + @Test + fun `empty notifications list is handled gracefully`() { + every { sut["getNotificationsFromFile"]() } returns createNotificationsList() + + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 0) { sut.processNotification(project, any()) } + } + + @Test + fun `multiple notifications are processed correctly`() { + val startupNotification = createNotification("startup-1", NotificationScheduleType.STARTUP) + val emergencyNotification = createNotification("emergency-1", NotificationScheduleType.EMERGENCY) + + every { sut["getNotificationsFromFile"]() } returns createNotificationsList( + startupNotification, + emergencyNotification + ) + every { dismissalState.isDismissed(any()) } returns false + + // First poll - both should be processed + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 1) { sut.processNotification(project, startupNotification) } + verify(exactly = 1) { sut.processNotification(project, emergencyNotification) } + + // Second poll - only emergency should be processed + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 1) { sut.processNotification(project, startupNotification) } + verify(exactly = 2) { sut.processNotification(project, emergencyNotification) } + } + + // Helper functions to create test data + private fun createNotification(id: String, type: NotificationScheduleType) = NotificationData( + id = id, + schedule = NotificationSchedule(type = type), + severity = "INFO", + condition = null, + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ), + actions = emptyList() + ) + + private fun createNotificationsList(vararg notifications: NotificationData) = NotificationsList( + schema = Schema("1.0"), + notifications = notifications.toList() + ) + + private fun resetIsStartup() { + val clazz = Class.forName("software.aws.toolkits.jetbrains.core.notifications.ProcessNotificationsBaseKt") + val field = clazz.getDeclaredField("isStartup") + field.isAccessible = true + + val value = field.get(null) as AtomicBoolean + value.set(true) + } + + @AfterEach + fun tearDown() { + unmockkAll() + } +} diff --git a/plugins/core/src/main/resources/META-INF/plugin.xml b/plugins/core/src/main/resources/META-INF/plugin.xml index 20b68ee869d..df372ada393 100644 --- a/plugins/core/src/main/resources/META-INF/plugin.xml +++ b/plugins/core/src/main/resources/META-INF/plugin.xml @@ -20,7 +20,6 @@ -