Skip to content

Commit

Permalink
Poll for new notifications (#5119)
Browse files Browse the repository at this point in the history
* initial commit

* run on startup

* detekt

* move vals

* remote resource implementation

* comments

* detekt

* Validate file before saving

* cache path

* observer implementation

* deserialize notifs from file

* detekt

* remove unused interface

* internal class

* Fix observer

* etag singleton state component

* add telemetry

* atomicBoolean

* initialize once per IDE startup

* code scan

* Omit (Unit)

* specify etag storage location

* detekt

* fix detekt issues

* basic tests

* no star imports

* coroutine scope delay instead of thread.sleep

* feedback fixes

* test fix

* Application Exists for tests

* endpoint object

* detekt

* detekt fixes

* boolean flag

* boolean flag

* update tests

* move startup flag handling to processBase

* fix delay

* fix delay

* Notification dismissal state tracking  (#5129)

* split notifications into separated lists.

* add persistent notification dismissal state logic

* boolean changes

* group persistant states

* comments

* Service initialized automatically

* isStartup global

* Deserialized notification schedule type

* tests

* persistent state syntax

* convert to light services

* Remove state from companion object

* detekt

* endpoint as registryKey

* detekt

* fix startup issues

* Expiry issues
  • Loading branch information
samgst-amazon authored Nov 27, 2024
1 parent 5f43277 commit 33bfa44
Show file tree
Hide file tree
Showing 14 changed files with 619 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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}" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@

<postStartupActivity implementation="software.aws.toolkits.jetbrains.core.plugin.PluginAutoUpdater"/>
<postStartupActivity implementation="software.aws.toolkits.jetbrains.core.AwsTelemetryPrompter"/>
<postStartupActivity implementation="software.aws.toolkits.jetbrains.services.telemetry.AwsToolkitStartupMetrics"/>

<registryKey key="aws.dev.useDAG" description="True if DAG should be used instead of authorization_grant with PKCE"
defaultValue="false" restartRequired="false"/>
Expand All @@ -77,6 +78,9 @@
restartRequired="true"/>
<registryKey key="aws.toolkit.developerMode" description="Enables features to facilitate development of the toolkit" restartRequired="false"
defaultValue="false"/>
<registryKey key="aws.toolkit.notification.endpoint" description="Endpoint for AWS Toolkit notifications"
defaultValue="https://idetoolkits-hostedfiles.amazonaws.com/Notifications/Jetbrains/emergency/1.x.json" restartRequired="true"/>


<notificationGroup id="aws.plugin.version.mismatch" displayType="STICKY_BALLOON" key="aws.settings.title"/>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ class NotConditionDeserializer : JsonDeserializer<NotificationExpression.NotCond
}
}

// Create a custom deserializer if needed
class NotificationTypeDeserializer : JsonDeserializer<NotificationScheduleType>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): NotificationScheduleType =
NotificationScheduleType.fromString(p.valueAsString)
}

private fun JsonNode.toNotificationExpressions(p: JsonParser): List<NotificationExpression> = this.map { element ->
val parser = element.traverse(p.codec)
parser.nextToken()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,32 @@ 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,
WARNING,
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<NotificationsList>(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<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()
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<NotificationPollingService>()
fun getInstance(): NotificationPollingService =
ApplicationManager.getApplication().getService(NotificationPollingService::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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<NotificationDismissalConfiguration> {
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<String> = mutableSetOf(),
)

@Service
@State(name = "notificationEtag", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)])
class NotificationEtagState : PersistentStateComponent<NotificationEtagConfiguration> {
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<String, BannerContent>()

fun addNotification(id: String, content: BannerContent) {
notifications[id] = content
}

fun getNotifications(): Map<String, BannerContent> = notifications

fun removeNotification(id: String) {
notifications.remove(id)
}

companion object {
fun getInstance(): BannerNotificationService =
service()
}
}
Loading

0 comments on commit 33bfa44

Please sign in to comment.