Skip to content

Commit 33bfa44

Browse files
Poll for new notifications (#5119)
* 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
1 parent 5f43277 commit 33bfa44

File tree

14 files changed

+619
-20
lines changed

14 files changed

+619
-20
lines changed

plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ class DefaultRemoteResourceResolver(
3232
private fun internalResolve(resource: RemoteResource): Path {
3333
val expectedLocation = cacheBasePath.resolve(resource.name)
3434
val current = expectedLocation.existsOrNull()
35-
if (current != null && !isExpired(current, resource)) {
36-
LOG.debug { "Existing file ($current) for ${resource.name} is present and not expired - using it." }
37-
return current
35+
if (resource.name != "notifications.json") {
36+
if ((current != null && !isExpired(current, resource))) {
37+
LOG.debug { "Existing file ($current) for ${resource.name} is present and not expired - using it." }
38+
return current
39+
}
3840
}
3941

4042
LOG.debug { "Current file for ${resource.name} does not exist or is expired. Attempting to fetch from ${resource.urls}" }

plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666

6767
<postStartupActivity implementation="software.aws.toolkits.jetbrains.core.plugin.PluginAutoUpdater"/>
6868
<postStartupActivity implementation="software.aws.toolkits.jetbrains.core.AwsTelemetryPrompter"/>
69+
<postStartupActivity implementation="software.aws.toolkits.jetbrains.services.telemetry.AwsToolkitStartupMetrics"/>
6970

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

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

plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ class NotConditionDeserializer : JsonDeserializer<NotificationExpression.NotCond
114114
}
115115
}
116116

117+
// Create a custom deserializer if needed
118+
class NotificationTypeDeserializer : JsonDeserializer<NotificationScheduleType>() {
119+
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): NotificationScheduleType =
120+
NotificationScheduleType.fromString(p.valueAsString)
121+
}
122+
117123
private fun JsonNode.toNotificationExpressions(p: JsonParser): List<NotificationExpression> = this.map { element ->
118124
val parser = element.traverse(p.codec)
119125
parser.nextToken()

plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,32 @@ data class NotificationData(
2727
)
2828

2929
data class NotificationSchedule(
30-
val type: String,
31-
)
30+
@JsonDeserialize(using = NotificationTypeDeserializer::class)
31+
val type: NotificationScheduleType,
32+
) {
33+
constructor(type: String) : this(NotificationScheduleType.fromString(type))
34+
}
3235

3336
enum class NotificationSeverity {
3437
INFO,
3538
WARNING,
3639
CRITICAL,
3740
}
3841

42+
enum class NotificationScheduleType {
43+
STARTUP,
44+
EMERGENCY,
45+
;
46+
47+
companion object {
48+
fun fromString(value: String): NotificationScheduleType =
49+
when (value.lowercase()) {
50+
"startup" -> STARTUP
51+
else -> EMERGENCY
52+
}
53+
}
54+
}
55+
3956
data class NotificationContentDescriptionLocale(
4057
@JsonProperty("en-US")
4158
val locale: NotificationContentDescription,

plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPanel.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@ class NotificationPanel : BorderLayoutPanel() {
1515
init {
1616
isOpaque = false
1717
addToCenter(wrapper)
18-
ProcessNotificationsBase.showBannerNotification.forEach {
19-
updateNotificationPanel(it.value)
18+
BannerNotificationService.getInstance().getNotifications().forEach { (_, content) ->
19+
updateNotificationPanel(content)
2020
}
2121
}
2222

2323
private fun removeNotificationPanel(notificationId: String) = runInEdt {
24-
ProcessNotificationsBase.showBannerNotification.remove(notificationId) // TODO: add id to dismissed notification list
24+
BannerNotificationService.getInstance().removeNotification(notificationId)
25+
NotificationDismissalState.getInstance().dismissNotification(notificationId)
2526
wrapper.removeAll()
2627
}
2728

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.core.notifications
5+
6+
import com.fasterxml.jackson.module.kotlin.readValue
7+
import com.intellij.openapi.Disposable
8+
import com.intellij.openapi.application.ApplicationManager
9+
import com.intellij.openapi.components.Service
10+
import com.intellij.openapi.util.registry.Registry
11+
import com.intellij.util.Alarm
12+
import com.intellij.util.AlarmFactory
13+
import com.intellij.util.io.HttpRequests
14+
import kotlinx.coroutines.delay
15+
import kotlinx.coroutines.runBlocking
16+
import software.aws.toolkits.core.utils.RemoteResolveParser
17+
import software.aws.toolkits.core.utils.RemoteResource
18+
import software.aws.toolkits.core.utils.error
19+
import software.aws.toolkits.core.utils.getLogger
20+
import software.aws.toolkits.core.utils.warn
21+
import software.aws.toolkits.jetbrains.core.DefaultRemoteResourceResolverProvider
22+
import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider
23+
import software.aws.toolkits.telemetry.Component
24+
import software.aws.toolkits.telemetry.ToolkitTelemetry
25+
import java.io.InputStream
26+
import java.time.Duration
27+
import java.util.concurrent.atomic.AtomicBoolean
28+
29+
private const val MAX_RETRIES = 3
30+
private const val RETRY_DELAY_MS = 1000L
31+
32+
object NotificationFileValidator : RemoteResolveParser {
33+
override fun canBeParsed(data: InputStream): Boolean =
34+
try {
35+
NotificationMapperUtil.mapper.readValue<NotificationsList>(data)
36+
true
37+
} catch (e: Exception) {
38+
false
39+
}
40+
}
41+
42+
object NotificationEndpoint {
43+
fun getEndpoint(): String =
44+
Registry.get("aws.toolkit.notification.endpoint").asString()
45+
}
46+
47+
@Service(Service.Level.APP)
48+
internal final class NotificationPollingService : Disposable {
49+
private val isFirstPoll = AtomicBoolean(true)
50+
private val observers = mutableListOf<() -> Unit>()
51+
private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this)
52+
private val pollingIntervalMs = Duration.ofMinutes(10).toMillis()
53+
private val resourceResolver: RemoteResourceResolverProvider = DefaultRemoteResourceResolverProvider()
54+
private val notificationsResource = object : RemoteResource {
55+
override val name: String = "notifications.json"
56+
override val urls: List<String> = listOf(NotificationEndpoint.getEndpoint())
57+
override val remoteResolveParser: RemoteResolveParser = NotificationFileValidator
58+
}
59+
60+
fun startPolling() {
61+
val newNotifications = runBlocking { pollForNotifications() }
62+
if (newNotifications) {
63+
notifyObservers()
64+
}
65+
alarm.addRequest(
66+
{ startPolling() },
67+
pollingIntervalMs
68+
)
69+
}
70+
71+
/**
72+
* Main polling function that checks for updates and downloads if necessary
73+
* Returns the parsed notifications if successful, null otherwise
74+
*/
75+
private suspend fun pollForNotifications(): Boolean {
76+
var retryCount = 0
77+
var lastException: Exception? = null
78+
79+
while (retryCount < MAX_RETRIES) {
80+
try {
81+
val newETag = getNotificationETag()
82+
if (newETag == NotificationEtagState.getInstance().etag) {
83+
// for when we need to notify on first poll even when there's no new ETag
84+
if (isFirstPoll.compareAndSet(true, false)) {
85+
notifyObservers()
86+
}
87+
return false
88+
}
89+
resourceResolver.get()
90+
.resolve(notificationsResource)
91+
.toCompletableFuture()
92+
.get()
93+
NotificationEtagState.getInstance().etag = newETag
94+
return true
95+
} catch (e: Exception) {
96+
lastException = e
97+
LOG.error(e) { "Failed to poll for notifications (attempt ${retryCount + 1}/$MAX_RETRIES)" }
98+
retryCount++
99+
if (retryCount < MAX_RETRIES) {
100+
val backoffDelay = RETRY_DELAY_MS * (1L shl (retryCount - 1))
101+
delay(backoffDelay)
102+
}
103+
}
104+
}
105+
emitFailureMetric(lastException)
106+
return false
107+
}
108+
109+
private fun getNotificationETag(): String =
110+
try {
111+
HttpRequests.request(NotificationEndpoint.getEndpoint())
112+
.userAgent("AWS Toolkit for JetBrains")
113+
.connect { request ->
114+
request.connection.headerFields["ETag"]?.firstOrNull().orEmpty()
115+
}
116+
} catch (e: Exception) {
117+
LOG.warn { "Failed to fetch notification ETag: $e.message" }
118+
throw e
119+
}
120+
121+
private fun emitFailureMetric(e: Exception?) {
122+
ToolkitTelemetry.showNotification(
123+
project = null,
124+
component = Component.Filesystem,
125+
id = "",
126+
reason = "Failed to poll for notifications",
127+
success = false,
128+
reasonDesc = "${e?.javaClass?.simpleName ?: "Unknown"}: ${e?.message ?: "No message"}",
129+
)
130+
}
131+
132+
fun addObserver(observer: () -> Unit) = observers.add(observer)
133+
134+
private fun notifyObservers() {
135+
observers.forEach { observer ->
136+
observer()
137+
}
138+
}
139+
140+
override fun dispose() {
141+
alarm.dispose()
142+
}
143+
144+
companion object {
145+
private val LOG = getLogger<NotificationPollingService>()
146+
fun getInstance(): NotificationPollingService =
147+
ApplicationManager.getApplication().getService(NotificationPollingService::class.java)
148+
}
149+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.core.notifications
5+
6+
import com.intellij.openapi.application.ApplicationManager
7+
import com.intellij.openapi.project.Project
8+
import com.intellij.openapi.startup.ProjectActivity
9+
import java.util.concurrent.atomic.AtomicBoolean
10+
11+
internal class NotificationServiceInitializer : ProjectActivity {
12+
13+
private val initialized = AtomicBoolean(false)
14+
15+
override suspend fun execute(project: Project) {
16+
if (ApplicationManager.getApplication().isUnitTestMode) return
17+
if (initialized.compareAndSet(false, true)) {
18+
val service = NotificationPollingService.getInstance()
19+
service.startPolling()
20+
}
21+
}
22+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.core.notifications
5+
6+
import com.intellij.openapi.components.PersistentStateComponent
7+
import com.intellij.openapi.components.RoamingType
8+
import com.intellij.openapi.components.Service
9+
import com.intellij.openapi.components.State
10+
import com.intellij.openapi.components.Storage
11+
import com.intellij.openapi.components.service
12+
13+
@Service
14+
@State(name = "notificationDismissals", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)])
15+
class NotificationDismissalState : PersistentStateComponent<NotificationDismissalConfiguration> {
16+
private val state = NotificationDismissalConfiguration()
17+
18+
override fun getState(): NotificationDismissalConfiguration = state
19+
20+
override fun loadState(state: NotificationDismissalConfiguration) {
21+
this.state.dismissedNotificationIds.clear()
22+
this.state.dismissedNotificationIds.addAll(state.dismissedNotificationIds)
23+
}
24+
25+
fun isDismissed(notificationId: String): Boolean =
26+
state.dismissedNotificationIds.contains(notificationId)
27+
28+
fun dismissNotification(notificationId: String) {
29+
state.dismissedNotificationIds.add(notificationId)
30+
}
31+
32+
companion object {
33+
fun getInstance(): NotificationDismissalState =
34+
service()
35+
}
36+
}
37+
38+
data class NotificationDismissalConfiguration(
39+
var dismissedNotificationIds: MutableSet<String> = mutableSetOf(),
40+
)
41+
42+
@Service
43+
@State(name = "notificationEtag", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)])
44+
class NotificationEtagState : PersistentStateComponent<NotificationEtagConfiguration> {
45+
private val state = NotificationEtagConfiguration()
46+
47+
override fun getState(): NotificationEtagConfiguration = state
48+
49+
override fun loadState(state: NotificationEtagConfiguration) {
50+
this.state.etag = state.etag
51+
}
52+
53+
var etag: String?
54+
get() = state.etag
55+
set(value) {
56+
state.etag = value
57+
}
58+
59+
companion object {
60+
fun getInstance(): NotificationEtagState =
61+
service()
62+
}
63+
}
64+
65+
data class NotificationEtagConfiguration(
66+
var etag: String? = null,
67+
)
68+
69+
@Service
70+
class BannerNotificationService {
71+
private val notifications = mutableMapOf<String, BannerContent>()
72+
73+
fun addNotification(id: String, content: BannerContent) {
74+
notifications[id] = content
75+
}
76+
77+
fun getNotifications(): Map<String, BannerContent> = notifications
78+
79+
fun removeNotification(id: String) {
80+
notifications.remove(id)
81+
}
82+
83+
companion object {
84+
fun getInstance(): BannerNotificationService =
85+
service()
86+
}
87+
}

0 commit comments

Comments
 (0)