Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c3cc2bf
Display toast notifications with actions
manodnyab Nov 13, 2024
09729c7
Condition matcher for displaying notifications
manodnyab Nov 15, 2024
f0dbe5b
initial commit
samgst-amazon Nov 19, 2024
e909a0e
Modified deserialization cases and added tests
manodnyab Nov 20, 2024
5a84e39
Merge branch 'feature/ideNotifs' into manodnyb/checkRulesForNotificat…
manodnyab Nov 20, 2024
56c1888
not required file change
manodnyab Nov 20, 2024
a981227
Merge remote-tracking branch 'origin/manodnyb/checkRulesForNotificati…
manodnyab Nov 20, 2024
bd354fc
run on startup
samgst-amazon Nov 20, 2024
fea410a
detekt
samgst-amazon Nov 20, 2024
746e5ab
move vals
samgst-amazon Nov 20, 2024
b038d48
Merge main into feature/ideNotifs-polling
aws-toolkit-automation Nov 20, 2024
1d6e7d2
Merge main into feature/ideNotifs-polling
aws-toolkit-automation Nov 20, 2024
c4f3cac
remote resource implementation
samgst-amazon Nov 20, 2024
867bcda
comments
samgst-amazon Nov 20, 2024
b7f336e
Merge branch 'feature/ideNotifs' into feature/ideNotifs-polling
samgst-amazon Nov 20, 2024
1883530
detekt
samgst-amazon Nov 20, 2024
dbfcefc
feedback 1
manodnyab Nov 20, 2024
823ea7f
Merge remote-tracking branch 'origin/feature/ideNotifs' into manodnyb…
manodnyab Nov 20, 2024
218c8ed
modified the base class
manodnyab Nov 20, 2024
285650f
Merge main into feature/ideNotifs-polling
aws-toolkit-automation Nov 21, 2024
d2b7a6c
Merge branch 'manodnyb/checkRulesForNotifications' into feature/ideNo…
samgst-amazon Nov 21, 2024
c82ee66
Validate file before saving
samgst-amazon Nov 21, 2024
4c397f4
cache path
samgst-amazon Nov 21, 2024
033a3a9
merge conflicts
samgst-amazon Nov 21, 2024
b01283e
feat(amazonq): Introduce auto trigger changes officially (#5080)
andrewyuq Nov 21, 2024
5506ca8
Merge main into feature/ideNotifs-polling
aws-toolkit-automation Nov 21, 2024
dd72d29
observer implementation
samgst-amazon Nov 21, 2024
d6e7aa7
deserialize notifs from file
samgst-amazon Nov 21, 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,222 @@
// 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.databind.DeserializationFeature

Check warning

Code scanning / QDJVMC

Unused import directive Warning

Unused import directive
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper

Check warning

Code scanning / QDJVMC

Unused import directive Warning

Unused import directive
import com.fasterxml.jackson.module.kotlin.readValue
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.components.Service
import com.intellij.util.Alarm
import com.intellij.util.AlarmFactory
import com.intellij.util.io.HttpRequests
import software.aws.toolkits.core.utils.debug
import software.aws.toolkits.core.utils.error
import software.aws.toolkits.core.utils.getLogger
import java.io.IOException
import java.nio.file.Path
import java.time.Duration
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.readText
import kotlin.io.path.writeText

private const val NOTIFICATION_ENDPOINT = "https://idetoolkits-hostedfiles.amazonaws.com/Notifications/JetBrains/1.json" // TODO: Replace with actual endpoint
private const val NOTIFICATIONS_RESOURCE_PATH = "/software/aws/toolkits/resources/notifications.json"
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L

interface NotificationPollingService {
fun startPolling()
fun dispose()
}

@Service(Service.Level.APP)
class NotificationPollingServiceImpl : NotificationPollingService, Disposable {

private val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
private var currentETag: String? = null // todo Persistant state? add an init possibly
private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this)
private val pollingIntervalMs = Duration.ofMinutes(10).toMillis()

override fun dispose() {
alarm.dispose()
}

override fun startPolling() {
pollForNotifications()

alarm.addRequest(
{ startPolling() },
pollingIntervalMs
)
}

/**
* Main polling function that checks for updates and downloads if necessary
* Returns the parsed notifications if successful, null otherwise
*/
private fun pollForNotifications(): NotificationFile? {
var retryCount = 0
var lastException: Exception? = null

while (retryCount < MAX_RETRIES) {
try {
// Check if there are updates available
val newETag = getNotificationETag()

if (newETag == currentETag) {
LOG.debug { "No updates available for notifications" }
return loadLocalNotifications()
}

// Download and process new notifications
val notifications = downloadAndProcessNotifications()
currentETag = newETag
return notifications
} 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))
Thread.sleep(backoffDelay)
}
}
}

// After all retries failed, emit metric and return null
emitFailureMetric(lastException)
return loadLocalNotifications()
}

private fun getNotificationETag(): String =
HttpRequests.request(NOTIFICATION_ENDPOINT)
.userAgent("AWS Toolkit for JetBrains")
.connect { request ->
request.connection.headerFields["ETag"]?.firstOrNull() ?: ""
}

private fun downloadAndProcessNotifications(): NotificationFile {
val content = HttpRequests.request(NOTIFICATION_ENDPOINT)
.userAgent("AWS Toolkit for JetBrains")
.readString()

// Save to local file for backup
saveLocalNotifications(content)

return deserializeNotifications(content)
}

/**
* Deserializes the notification content with error handling
*/
private fun deserializeNotifications(content: String): NotificationFile {
try {
return mapper.readValue(content)
} catch (e: Exception) {
LOG.error(e) { "Failed to deserialize notifications" }
throw e
}
}

/**
* Loads notifications, preferring the latest downloaded version if available,
* falling back to bundled resource if no downloaded version exists
*/
private fun loadLocalNotifications(): NotificationFile? {
// First try to load from system directory (latest downloaded version)
getLocalNotificationsPath().let { path ->
if (path.exists()) {
try {
val content = path.readText()
return deserializeNotifications(content)
} catch (e: Exception) {
LOG.error(e) { "Failed to load downloaded notifications, falling back to bundled resource" }
}
}
}

// Fall back to bundled resource if no downloaded version exists
return try {
val resourceStream = javaClass.getResourceAsStream(NOTIFICATIONS_RESOURCE_PATH)
?: return null

val content = resourceStream.use { stream ->
stream.bufferedReader().readText()
}

deserializeNotifications(content)
} catch (e: Exception) {
LOG.error(e) { "Failed to load notifications from bundled resources" }
null
}
}

Copy link
Contributor

Choose a reason for hiding this comment

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

isnt this just the RemoteResource system

/**
* Saves downloaded notifications to system directory
*/
private fun saveLocalNotifications(content: String) {
try {
val path = getLocalNotificationsPath()
path.parent.createDirectories()
path.writeText(content)
} catch (e: IOException) {
LOG.error(e) { "Failed to save notifications to local storage" }
}
}

/**
* Gets the path for downloaded notifications in IntelliJ's system directory
*/
private fun getLocalNotificationsPath(): Path {
return Path.of(PathManager.getSystemPath())
.resolve("aws-toolkit")
.resolve("notifications")
.resolve("notifications.json")
}

/**
* Emits telemetry metric for polling failures
*/
private fun emitFailureMetric(exception: Exception?) {
// todo: add metric
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this already exists in common, we can add this metric

// toolkit
}

companion object {
private val LOG = getLogger<NotificationPollingServiceImpl>()
fun getInstance(): NotificationPollingService =
ApplicationManager.getApplication().getService(NotificationPollingService::class.java)
}
}

data class NotificationFile(
val notifications: List<Notification>,
val version: String,
)

data class Notification(
val id: String,
val message: String,
val criteria: NotificationCriteria,
)

data class NotificationCriteria(
val minVersion: String?,
val maxVersion: String?,
val regions: List<String>?,
val ideType: String?,
val pluginVersion: String?,
val os: String?,
val authType: String?,
val authRegion: String?,
val authState: String?,
val authScopes: List<String>?,
val installedPlugins: List<String>?,
val computeEnvironment: String?,
val messageType: String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// 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.ide.util.RunOnceUtil
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity

class NotificationServiceInitializer : ProjectActivity {

Check warning

Code scanning / QDJVMC

Extension class should be final and non-public Warning

Extension class should not be public

override suspend fun execute(project: Project) {
val service = ApplicationManager.getApplication().getService(NotificationPollingService::class.java)
Copy link
Contributor

Choose a reason for hiding this comment

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

can we use the getInstance method here?

RunOnceUtil.runOnceForApp(this::class.qualifiedName.toString()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need this? Wont the startup activity anyway run just once?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added this to explicitly only run once per IDE instance and not per-project. It's what I understood to work, don't know about a better way yet

service.startPolling()
}
}
}
4 changes: 4 additions & 0 deletions plugins/core/src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
<!-- each plugin needs its own instance of these -->
<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"/>
<applicationService
serviceInterface="software.aws.toolkits.jetbrains.core.notifications.NotificationPollingService"
serviceImplementation="software.aws.toolkits.jetbrains.core.notifications.NotificationPollingServiceImpl"/>
</extensions>
<projectListeners>
<listener class="software.aws.toolkits.jetbrains.services.telemetry.OpenedFileTypesMetricsListener" topic="com.intellij.openapi.fileEditor.FileEditorManagerListener"/>
Expand Down
Loading