Skip to content

Commit f0dbe5b

Browse files
committed
initial commit
1 parent 0b4a5d9 commit f0dbe5b

File tree

1 file changed

+218
-0
lines changed

1 file changed

+218
-0
lines changed
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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.databind.DeserializationFeature
7+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
8+
import com.fasterxml.jackson.module.kotlin.readValue
9+
import com.intellij.openapi.Disposable
10+
import com.intellij.openapi.application.PathManager
11+
import com.intellij.openapi.components.Service
12+
import com.intellij.util.Alarm
13+
import com.intellij.util.AlarmFactory
14+
import com.intellij.util.io.HttpRequests
15+
import software.aws.toolkits.core.utils.debug
16+
import software.aws.toolkits.core.utils.error
17+
import software.aws.toolkits.core.utils.getLogger
18+
import java.io.IOException
19+
import java.nio.file.Path
20+
import java.time.Duration
21+
import kotlin.io.path.createDirectories
22+
import kotlin.io.path.exists
23+
import kotlin.io.path.readText
24+
import kotlin.io.path.writeText
25+
26+
private const val NOTIFICATION_ENDPOINT = "https://idetoolkits-hostedfiles.amazonaws.com/Notifications/JetBrains/1.json" // TODO: Replace with actual endpoint
27+
private const val MAX_RETRIES = 3
28+
private const val RETRY_DELAY_MS = 1000L
29+
30+
@Service
31+
class NotificationPollingService : Disposable {
32+
/**
33+
* Data class representing the structure of notifications from the endpoint
34+
*/
35+
data class NotificationFile(
36+
val notifications: List<Notification>,
37+
val version: String,
38+
)
39+
40+
data class Notification(
41+
val id: String,
42+
val message: String,
43+
val criteria: NotificationCriteria,
44+
)
45+
46+
data class NotificationCriteria(
47+
val minVersion: String?,
48+
val maxVersion: String?,
49+
val regions: List<String>?,
50+
val ideType: String?,
51+
val pluginVersion: String?,
52+
val os: String?,
53+
val authType: String?,
54+
val authRegion: String?,
55+
val authState: String?,
56+
val authScopes: List<String>?,
57+
val installedPlugins: List<String>?,
58+
val computeEnvironment: String?,
59+
val messageType: String?,
60+
)
61+
private val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
62+
private val LOG = getLogger<NotificationPollingService>()
63+
private var currentETag: String? = null
64+
private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this)
65+
66+
init {
67+
startPolling()
68+
}
69+
70+
override fun dispose() {
71+
alarm.dispose()
72+
}
73+
74+
private fun startPolling() {
75+
pollForNotifications()
76+
77+
alarm.addRequest(
78+
{ startPolling() },
79+
Duration.ofMinutes(10).toMillis()
80+
)
81+
}
82+
83+
/**
84+
* Main polling function that checks for updates and downloads if necessary
85+
* Returns the parsed notifications if successful, null otherwise
86+
*/
87+
private fun pollForNotifications(): NotificationFile? {
88+
var retryCount = 0
89+
var lastException: Exception? = null
90+
91+
while (retryCount < MAX_RETRIES) {
92+
try {
93+
// Check if there are updates available
94+
val newETag = getNotificationETag()
95+
96+
if (newETag == currentETag) {
97+
LOG.debug { "No updates available for notifications" }
98+
return loadLocalNotifications()
99+
}
100+
101+
// Download and process new notifications
102+
val notifications = downloadAndProcessNotifications(newETag)
103+
currentETag = newETag
104+
return notifications
105+
} catch (e: Exception) {
106+
lastException = e
107+
LOG.error(e) { "Failed to poll for notifications (attempt ${retryCount + 1}/$MAX_RETRIES)" }
108+
retryCount++
109+
if (retryCount < MAX_RETRIES) {
110+
val backoffDelay = RETRY_DELAY_MS * (1L shl (retryCount - 1))
111+
Thread.sleep(backoffDelay)
112+
}
113+
}
114+
}
115+
116+
// After all retries failed, emit metric and return null
117+
emitFailureMetric(lastException)
118+
return loadLocalNotifications()
119+
}
120+
121+
private fun getNotificationETag(): String =
122+
HttpRequests.request(NOTIFICATION_ENDPOINT)
123+
.userAgent("AWS Toolkit for JetBrains")
124+
.connect { request ->
125+
request.connection.headerFields["ETag"]?.firstOrNull() ?: ""
126+
}
127+
128+
private fun downloadAndProcessNotifications(newETag: String): NotificationFile {
129+
val content = HttpRequests.request(NOTIFICATION_ENDPOINT)
130+
.userAgent("AWS Toolkit for JetBrains")
131+
.readString()
132+
133+
// Save to local file for backup
134+
saveLocalNotifications(content)
135+
136+
return deserializeNotifications(content)
137+
}
138+
139+
/**
140+
* Deserializes the notification content with error handling
141+
*/
142+
private fun deserializeNotifications(content: String): NotificationFile {
143+
try {
144+
return mapper.readValue(content)
145+
} catch (e: Exception) {
146+
LOG.error(e) { "Failed to deserialize notifications" }
147+
throw e
148+
}
149+
}
150+
151+
/**
152+
* Loads notifications, preferring the latest downloaded version if available,
153+
* falling back to bundled resource if no downloaded version exists
154+
*/
155+
private fun loadLocalNotifications(): NotificationFile? {
156+
// First try to load from system directory (latest downloaded version)
157+
getLocalNotificationsPath().let { path ->
158+
if (path.exists()) {
159+
try {
160+
val content = path.readText()
161+
return deserializeNotifications(content)
162+
} catch (e: Exception) {
163+
LOG.error(e) { "Failed to load downloaded notifications, falling back to bundled resource" }
164+
}
165+
}
166+
}
167+
168+
// Fall back to bundled resource if no downloaded version exists
169+
return try {
170+
val resourceStream = javaClass.getResourceAsStream(NOTIFICATIONS_RESOURCE_PATH)
171+
?: return null
172+
173+
val content = resourceStream.use { stream ->
174+
stream.bufferedReader().readText()
175+
}
176+
177+
deserializeNotifications(content)
178+
} catch (e: Exception) {
179+
LOG.error(e) { "Failed to load notifications from bundled resources" }
180+
null
181+
}
182+
}
183+
184+
/**
185+
* Saves downloaded notifications to system directory
186+
*/
187+
private fun saveLocalNotifications(content: String) {
188+
try {
189+
val path = getLocalNotificationsPath()
190+
path.parent.createDirectories()
191+
path.writeText(content)
192+
} catch (e: IOException) {
193+
LOG.error(e) { "Failed to save notifications to local storage" }
194+
}
195+
}
196+
197+
/**
198+
* Gets the path for downloaded notifications in IntelliJ's system directory
199+
*/
200+
private fun getLocalNotificationsPath(): Path {
201+
return Path.of(PathManager.getSystemPath())
202+
.resolve("aws-toolkit")
203+
.resolve("notifications")
204+
.resolve("notifications.json")
205+
}
206+
207+
/**
208+
* Emits telemetry metric for polling failures
209+
*/
210+
private fun emitFailureMetric(exception: Exception?) {
211+
// todo: add metric
212+
// toolkit
213+
}
214+
215+
companion object {
216+
private const val NOTIFICATIONS_RESOURCE_PATH = "/software/aws/toolkits/resources/notifications.json"
217+
}
218+
}

0 commit comments

Comments
 (0)