diff --git a/plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt b/plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt
index ce0603bf0fe..c4efe685884 100644
--- a/plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt
+++ b/plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt
@@ -32,9 +32,11 @@ class DefaultRemoteResourceResolver(
private fun internalResolve(resource: RemoteResource): Path {
val expectedLocation = cacheBasePath.resolve(resource.name)
val current = expectedLocation.existsOrNull()
- if (current != null && !isExpired(current, resource)) {
- LOG.debug { "Existing file ($current) for ${resource.name} is present and not expired - using it." }
- return current
+ if (resource.name != "notifications.json") {
+ if ((current != null && !isExpired(current, resource))) {
+ LOG.debug { "Existing file ($current) for ${resource.name} is present and not expired - using it." }
+ return current
+ }
}
LOG.debug { "Current file for ${resource.name} does not exist or is expired. Attempting to fetch from ${resource.urls}" }
diff --git a/plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml b/plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml
index 2ae2fdc75ab..ec52967c5b7 100644
--- a/plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml
+++ b/plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml
@@ -66,6 +66,7 @@
+
@@ -77,6 +78,9 @@
restartRequired="true"/>
+
+
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
new file mode 100644
index 00000000000..357cf7e8d99
--- /dev/null
+++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt
@@ -0,0 +1,149 @@
+// 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.fasterxml.jackson.module.kotlin.readValue
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.util.registry.Registry
+import com.intellij.util.Alarm
+import com.intellij.util.AlarmFactory
+import com.intellij.util.io.HttpRequests
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import software.aws.toolkits.core.utils.RemoteResolveParser
+import software.aws.toolkits.core.utils.RemoteResource
+import software.aws.toolkits.core.utils.error
+import software.aws.toolkits.core.utils.getLogger
+import software.aws.toolkits.core.utils.warn
+import software.aws.toolkits.jetbrains.core.DefaultRemoteResourceResolverProvider
+import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider
+import software.aws.toolkits.telemetry.Component
+import software.aws.toolkits.telemetry.ToolkitTelemetry
+import java.io.InputStream
+import java.time.Duration
+import java.util.concurrent.atomic.AtomicBoolean
+
+private const val MAX_RETRIES = 3
+private const val RETRY_DELAY_MS = 1000L
+
+object NotificationFileValidator : RemoteResolveParser {
+ override fun canBeParsed(data: InputStream): Boolean =
+ try {
+ NotificationMapperUtil.mapper.readValue(data)
+ true
+ } catch (e: Exception) {
+ false
+ }
+}
+
+object NotificationEndpoint {
+ fun getEndpoint(): String =
+ Registry.get("aws.toolkit.notification.endpoint").asString()
+}
+
+@Service(Service.Level.APP)
+internal final class NotificationPollingService : Disposable {
+ private val isFirstPoll = AtomicBoolean(true)
+ private val observers = mutableListOf<() -> Unit>()
+ private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this)
+ private val pollingIntervalMs = Duration.ofMinutes(10).toMillis()
+ private val resourceResolver: RemoteResourceResolverProvider = DefaultRemoteResourceResolverProvider()
+ private val notificationsResource = object : RemoteResource {
+ override val name: String = "notifications.json"
+ override val urls: List = listOf(NotificationEndpoint.getEndpoint())
+ override val remoteResolveParser: RemoteResolveParser = NotificationFileValidator
+ }
+
+ fun startPolling() {
+ val newNotifications = runBlocking { pollForNotifications() }
+ if (newNotifications) {
+ notifyObservers()
+ }
+ alarm.addRequest(
+ { startPolling() },
+ pollingIntervalMs
+ )
+ }
+
+ /**
+ * Main polling function that checks for updates and downloads if necessary
+ * Returns the parsed notifications if successful, null otherwise
+ */
+ private suspend fun pollForNotifications(): Boolean {
+ var retryCount = 0
+ var lastException: Exception? = null
+
+ while (retryCount < MAX_RETRIES) {
+ try {
+ val newETag = getNotificationETag()
+ if (newETag == NotificationEtagState.getInstance().etag) {
+ // for when we need to notify on first poll even when there's no new ETag
+ if (isFirstPoll.compareAndSet(true, false)) {
+ notifyObservers()
+ }
+ return false
+ }
+ resourceResolver.get()
+ .resolve(notificationsResource)
+ .toCompletableFuture()
+ .get()
+ NotificationEtagState.getInstance().etag = newETag
+ return true
+ } catch (e: Exception) {
+ lastException = e
+ LOG.error(e) { "Failed to poll for notifications (attempt ${retryCount + 1}/$MAX_RETRIES)" }
+ retryCount++
+ if (retryCount < MAX_RETRIES) {
+ val backoffDelay = RETRY_DELAY_MS * (1L shl (retryCount - 1))
+ delay(backoffDelay)
+ }
+ }
+ }
+ emitFailureMetric(lastException)
+ return false
+ }
+
+ private fun getNotificationETag(): String =
+ try {
+ HttpRequests.request(NotificationEndpoint.getEndpoint())
+ .userAgent("AWS Toolkit for JetBrains")
+ .connect { request ->
+ request.connection.headerFields["ETag"]?.firstOrNull().orEmpty()
+ }
+ } catch (e: Exception) {
+ LOG.warn { "Failed to fetch notification ETag: $e.message" }
+ throw e
+ }
+
+ private fun emitFailureMetric(e: Exception?) {
+ ToolkitTelemetry.showNotification(
+ project = null,
+ component = Component.Filesystem,
+ id = "",
+ reason = "Failed to poll for notifications",
+ success = false,
+ reasonDesc = "${e?.javaClass?.simpleName ?: "Unknown"}: ${e?.message ?: "No message"}",
+ )
+ }
+
+ fun addObserver(observer: () -> Unit) = observers.add(observer)
+
+ private fun notifyObservers() {
+ observers.forEach { observer ->
+ observer()
+ }
+ }
+
+ override fun dispose() {
+ alarm.dispose()
+ }
+
+ companion object {
+ private val LOG = getLogger()
+ fun getInstance(): NotificationPollingService =
+ ApplicationManager.getApplication().getService(NotificationPollingService::class.java)
+ }
+}
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
new file mode 100644
index 00000000000..c8b84909ce5
--- /dev/null
+++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationServiceInitializer.kt
@@ -0,0 +1,22 @@
+// 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.application.ApplicationManager
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.startup.ProjectActivity
+import java.util.concurrent.atomic.AtomicBoolean
+
+internal class NotificationServiceInitializer : ProjectActivity {
+
+ private val initialized = AtomicBoolean(false)
+
+ override suspend fun execute(project: Project) {
+ if (ApplicationManager.getApplication().isUnitTestMode) return
+ if (initialized.compareAndSet(false, true)) {
+ val service = NotificationPollingService.getInstance()
+ 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 8911c79db76..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
@@ -3,27 +3,64 @@
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
import com.intellij.openapi.actionSystem.AnAction
+import com.intellij.openapi.application.PathManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
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: 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 {
- // TODO: install a listener for the polling class
+ NotificationPollingService.getInstance().addObserver {
+ retrieveStartupAndEmergencyNotifications()
+ }
}
- fun getNotificationsFromFile() {
- // TODO: returns a notification list
+ private fun getNotificationsFromFile(): NotificationsList? {
+ val path = Paths.get(PathManager.getSystemPath(), NOTIFICATIONS_PATH)
+ val content = path.inputStream().bufferedReader().use { it.readText() }
+ if (content.isEmpty()) {
+ return null
+ }
+ return NotificationMapperUtil.mapper.readValue(content)
}
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) {
@@ -46,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)
}
}
@@ -70,7 +107,7 @@ 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/NotificationPollingServiceTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingServiceTest.kt
new file mode 100644
index 00000000000..3e16c4dd28e
--- /dev/null
+++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingServiceTest.kt
@@ -0,0 +1,111 @@
+// 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.testFramework.ApplicationExtension
+import com.intellij.util.io.HttpRequests
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+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 software.aws.toolkits.core.utils.RemoteResourceResolver
+import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider
+import java.nio.file.Path
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.atomic.AtomicBoolean
+
+@ExtendWith(ApplicationExtension::class)
+class NotificationPollingServiceTest {
+ private lateinit var sut: NotificationPollingService
+ private lateinit var mockResolver: RemoteResourceResolver
+ private lateinit var mockProvider: RemoteResourceResolverProvider
+ private lateinit var observer: () -> Unit
+ private val testPath = Path.of("/test/path")
+
+ @BeforeEach
+ fun setUp() {
+ sut = NotificationPollingService()
+
+ mockResolver = mockk {
+ every { resolve(any()) } returns CompletableFuture.completedFuture(testPath)
+ }
+
+ mockProvider = mockk {
+ every { get() } returns mockResolver
+ }
+
+ val providerField = NotificationPollingService::class.java
+ .getDeclaredField("resourceResolver")
+ providerField.isAccessible = true
+ providerField.set(sut, mockProvider)
+
+ // Create mock observers
+ observer = mockk<() -> Unit>()
+ every { observer.invoke() } just Runs
+
+ val observersField = NotificationPollingService::class.java
+ .getDeclaredField("observers")
+ .apply { isAccessible = true }
+
+ observersField.set(sut, mutableListOf(observer))
+ }
+
+ @AfterEach
+ fun tearDown() {
+ sut.dispose()
+ }
+
+ @Test
+ fun `test pollForNotifications when ETag matches - no new notifications`() {
+ NotificationEtagState.getInstance().etag = "same"
+ val firstPollField = NotificationPollingService::class.java
+ .getDeclaredField("isFirstPoll")
+ .apply { isAccessible = true }
+ firstPollField.set(sut, AtomicBoolean(false))
+
+ mockkStatic(HttpRequests::class) {
+ every {
+ HttpRequests.request(any())
+ .userAgent(any())
+ .connect(any())
+ } returns "same"
+ sut.startPolling()
+ }
+ verify(exactly = 0) { observer.invoke() }
+ }
+
+ @Test
+ fun `test pollForNotifications when ETag matches on startup - notify observers`() {
+ NotificationEtagState.getInstance().etag = "same"
+ mockkStatic(HttpRequests::class) {
+ every {
+ HttpRequests.request(any())
+ .userAgent(any())
+ .connect(any())
+ } returns "same"
+ sut.startPolling()
+ }
+ verify(exactly = 1) { observer.invoke() }
+ }
+
+ @Test
+ fun `test pollForNotifications when ETag different - notify observers`() {
+ NotificationEtagState.getInstance().etag = "oldETag"
+ mockkStatic(HttpRequests::class) {
+ every {
+ HttpRequests.request(any())
+ .userAgent(any())
+ .connect(any())
+ } returns "newEtag"
+ sut.startPolling()
+ }
+ verify(exactly = 1) { observer.invoke() }
+ }
+}
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 86639d85020..9703141c302 100644
--- a/plugins/core/src/main/resources/META-INF/plugin.xml
+++ b/plugins/core/src/main/resources/META-INF/plugin.xml
@@ -22,6 +22,8 @@
+
+
diff --git a/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml b/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml
index 5172c1fc686..969365d6d9c 100644
--- a/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml
+++ b/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml
@@ -196,7 +196,6 @@
-