Skip to content
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
754a269
initial commit
samgst-amazon Nov 19, 2024
bca91eb
run on startup
samgst-amazon Nov 20, 2024
d894d86
detekt
samgst-amazon Nov 20, 2024
6beeadc
move vals
samgst-amazon Nov 20, 2024
c233764
remote resource implementation
samgst-amazon Nov 20, 2024
6f6dd6d
comments
samgst-amazon Nov 20, 2024
6bf7007
detekt
samgst-amazon Nov 20, 2024
9d32c86
Validate file before saving
samgst-amazon Nov 21, 2024
7952d9b
cache path
samgst-amazon Nov 21, 2024
fd2d1fb
observer implementation
samgst-amazon Nov 21, 2024
cff580f
deserialize notifs from file
samgst-amazon Nov 21, 2024
2d2ffaf
detekt
samgst-amazon Nov 21, 2024
a08194c
remove unused interface
samgst-amazon Nov 22, 2024
17b49fc
internal class
samgst-amazon Nov 22, 2024
c5718f7
Fix observer
samgst-amazon Nov 22, 2024
691d131
etag singleton state component
samgst-amazon Nov 22, 2024
4e588bf
add telemetry
samgst-amazon Nov 22, 2024
a4427ab
atomicBoolean
samgst-amazon Nov 22, 2024
2ce16c3
initialize once per IDE startup
samgst-amazon Nov 22, 2024
c54884d
code scan
samgst-amazon Nov 22, 2024
5f140d4
Omit (Unit)
samgst-amazon Nov 22, 2024
b8b6950
specify etag storage location
samgst-amazon Nov 22, 2024
098bb78
detekt
samgst-amazon Nov 22, 2024
ce834c8
fix detekt issues
samgst-amazon Nov 22, 2024
50ef8fd
basic tests
samgst-amazon Nov 23, 2024
6c76ec4
no star imports
samgst-amazon Nov 23, 2024
cc02c33
coroutine scope delay instead of thread.sleep
samgst-amazon Nov 25, 2024
f04da83
feedback fixes
samgst-amazon Nov 25, 2024
f81d96f
test fix
samgst-amazon Nov 25, 2024
9375226
Application Exists for tests
samgst-amazon Nov 25, 2024
7add727
endpoint object
samgst-amazon Nov 25, 2024
35a2b69
merge conflict
samgst-amazon Nov 25, 2024
aacc8e8
detekt
samgst-amazon Nov 25, 2024
d75d8e9
detekt fixes
samgst-amazon Nov 25, 2024
a14e6c1
boolean flag
samgst-amazon Nov 25, 2024
cbc2f4d
boolean flag
samgst-amazon Nov 25, 2024
580674e
update tests
samgst-amazon Nov 25, 2024
fcd46a6
move startup flag handling to processBase
samgst-amazon Nov 26, 2024
8fba3a3
fix delay
samgst-amazon Nov 26, 2024
84ed1ef
fix delay
samgst-amazon Nov 26, 2024
26caf31
Notification dismissal state tracking (#5129)
samgst-amazon Nov 27, 2024
8546d4a
endpoint as registryKey
samgst-amazon Nov 27, 2024
22420b1
detekt
samgst-amazon Nov 27, 2024
01bb9ef
Merge branch 'feature/ideNotifs' into samgst/notificationPolling
samgst-amazon Nov 27, 2024
7097fd9
fix startup issues
samgst-amazon Nov 27, 2024
49924d5
Expiry issues
samgst-amazon Nov 27, 2024
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
@@ -0,0 +1,191 @@
// 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.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
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.jetbrains.annotations.VisibleForTesting
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

Check warning on line 27 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Usage of redundant or deprecated syntax or deprecated symbols

'ToolkitTelemetry' is deprecated. Use type-safe metric builders

Check warning on line 27 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Usage of redundant or deprecated syntax or deprecated symbols

Remove deprecated symbol import

Check warning

Code scanning / QDJVMC

Usage of redundant or deprecated syntax or deprecated symbols Warning

Remove deprecated symbol import

Check warning

Code scanning / QDJVMC

Usage of redundant or deprecated syntax or deprecated symbols Warning

'ToolkitTelemetry' is deprecated. Use type-safe metric builders
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<NotificationsList>(data)
true
} catch (e: Exception) {
false
}
}

object NotificationEndpoint {
private var overriddenEndpoint: String? = null

fun getEndpoint(): String = overriddenEndpoint ?: DEFAULT_ENDPOINT

@VisibleForTesting
fun setTestEndpoint(endpoint: String) {

Check warning on line 51 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Unused symbol

Function "setTestEndpoint" is never used
overriddenEndpoint = endpoint
}

@VisibleForTesting
fun resetEndpoint() {

Check warning on line 56 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Unused symbol

Function "resetEndpoint" is never used
overriddenEndpoint = null
}

private const val DEFAULT_ENDPOINT = "" // TODO: Replace with actual endpoint
}

@State(name = "notificationEtag", storages = [Storage("aws.xml")])
class NotificationEtagState : PersistentStateComponent<NotificationEtagConfiguration> {

Check warning on line 64 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Extension class should be final and non-public

Service implementation should not be public. If a service is supposed to be used outside its module, extract an interface from it and specify it as serviceInterface in plugin.xml.
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 {

Check warning on line 90 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Redundant modality modifier

Redundant modality modifier

Check warning

Code scanning / QDJVMC

Redundant modality modifier Warning

Redundant modality modifier
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<String> = 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()

Check warning on line 134 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Possibly blocking call in non-blocking context

Possibly blocking call in non-blocking context could lead to thread starvation

Check warning

Code scanning / QDJVMC

Possibly blocking call in non-blocking context Warning

Possibly blocking call in non-blocking context could lead to thread starvation
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(

Check warning on line 164 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Usage of redundant or deprecated syntax or deprecated symbols

'ToolkitTelemetry' is deprecated. Use type-safe metric builders

Check warning

Code scanning / QDJVMC

Usage of redundant or deprecated syntax or deprecated symbols Warning

'ToolkitTelemetry' is deprecated. Use type-safe metric builders
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<NotificationPollingService>()
fun getInstance(): NotificationPollingService =
ApplicationManager.getApplication().getService(NotificationPollingService::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// 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.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 (initialized.compareAndSet(false, true)) {
val service = NotificationPollingService.getInstance()
ProcessNotificationsBase()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initialize through platform service system

service.startPolling()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,42 @@

package software.aws.toolkits.jetbrains.core.notifications

import com.fasterxml.jackson.databind.DeserializationFeature
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

object NotificationMapperUtil {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)

Check notice on line 20 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Function or property has platform type

Declaration has type inferred from a platform call, which can lead to unchecked nullability issues. Specify type explicitly as nullable or non-nullable.
}

@Service(Service.Level.PROJECT)
class ProcessNotificationsBase {
private val notifListener = mutableListOf<NotifListener>()
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() {

Check notice on line 41 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Class member can have 'private' visibility

Function 'retrieveStartupAndEmergencyNotifications' could be private
// TODO: separates notifications into startup and emergency
// iterates through the 2 lists and processes each notification(if it isn't dismissed)
}
Expand Down Expand Up @@ -71,6 +88,7 @@
fun getInstance(project: Project): ProcessNotificationsBase = project.service()

val showBannerNotification = mutableMapOf<String, BannerContent>()
private const val NOTIFICATIONS_PATH = "aws-static-resources/notifications.json"
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<RemoteResourceResolver> {
every { resolve(any()) } returns CompletableFuture.completedFuture(testPath)
}

mockProvider = mockk<RemoteResourceResolverProvider> {
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<String>())
.userAgent(any())
.connect<String>(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<String>())
.userAgent(any())
.connect<String>(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<String>())
.userAgent(any())
.connect<String>(any())
} returns "newEtag"
sut.startPolling()
}
verify(exactly = 1) { observer.invoke() }
}
}
2 changes: 2 additions & 0 deletions plugins/core/src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@

<extensions defaultExtensionNs="com.intellij">
<!-- each plugin needs its own instance of these -->
<applicationService serviceImplementation="software.aws.toolkits.jetbrains.core.notifications.NotificationEtagState"/>
<applicationService serviceImplementation="migration.software.aws.toolkits.jetbrains.core.coroutines.PluginCoroutineScopeTracker"/>
<projectService serviceImplementation="migration.software.aws.toolkits.jetbrains.core.coroutines.PluginCoroutineScopeTracker"/>
<postStartupActivity implementation = "software.aws.toolkits.jetbrains.core.notifications.NotificationServiceInitializer"/>
</extensions>
<projectListeners>
<listener class="software.aws.toolkits.jetbrains.services.telemetry.OpenedFileTypesMetricsListener" topic="com.intellij.openapi.fileEditor.FileEditorManagerListener"/>
Expand Down
Loading