diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9751d160..f77e694f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,3 +74,10 @@ jobs: - name: Build Plugin run: ./gradlew buildPlugin + - name: Verify Plugin Compatibility Range + run: | + if grep -R 'until-build=' build/resources/main/META-INF/plugin.xml build/tmp/patchPluginXml/plugin.xml; then + echo "Plugin XML must not define an upper until-build cap." + exit 1 + fi + diff --git a/.gitignore b/.gitignore index 92e99a7e..0ad9b951 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,10 @@ out/ # Gradle .gradle/ +.gradle-user-home/ build/ classes/ +.kotlin/ ./club_members/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ce5c71..86699a74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ # AMII Changelog +## [Unreleased] - 2026-05-05 + +### Added + +- Custom asset browser in Settings: asynchronous scanning, selectable editor pane, and improved asset list rendering. +- Project lifecycle registry to prevent duplicate listener registration across project opens. + +### Changed + +- Make asset downloads and asset-definition updates non-blocking on the UI thread. +- Ensure meme display and related services run on the EDT safely; added defensive invokes. +- Simplified REST/Asset API calls to avoid unnecessary thread-hopping. +- Refactored `PluginSettingsUI` to add parsing and bitmask helpers and reduce duplicated logic. +- Improved GIF handling with proper reader disposal and bounded cache size. + +### Fixed + +- Avoid synchronous network or file I/O on the EDT that could hang the IDE. +- Prevent duplicate project listener registration and related resource leaks. +- Various UI stability and threading fixes. + ## [1.5.0] ### Added diff --git a/build.gradle.kts b/build.gradle.kts index 867828c9..2e4c105a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType plugins { id("java") // Java support @@ -31,7 +32,7 @@ dependencies { implementation("com.googlecode.soundlibs:mp3spi:1.9.5.4") implementation("io.sentry:sentry:6.28.0") testImplementation("org.assertj:assertj-core:3.25.3") - testImplementation("io.mockk:mockk:1.13.8") + testImplementation("io.mockk:mockk:1.14.9") compileOnly(files("lib/instrumented-doki-theme-jetbrains-88.5-1.11.0.jar")) testImplementation(libs.junit) testImplementation(libs.opentest4j) @@ -66,7 +67,9 @@ intellijPlatform { ideaVersion { sinceBuild = providers.gradleProperty("pluginSinceBuild") - untilBuild = providers.gradleProperty("pluginUntilBuild") + providers.gradleProperty("pluginUntilBuild").orNull?.takeIf { it.isNotBlank() }?.let { + untilBuild = it + } } } @@ -86,7 +89,9 @@ intellijPlatform { pluginVerification { ides { - recommended() + create(IntelliJPlatformType.IntellijIdeaUltimate, "2025.1") + create(IntelliJPlatformType.IntellijIdeaUltimate, "2026.1") + create(IntelliJPlatformType.Rider, "2026.1") } } } diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5033d616..bf1142fa 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -1,3 +1,12 @@ -### Added +## Unreleased (2026-05-05) -- 2024.3π Build Support +Highlights + +- Non-blocking asset management: asset downloads and updates no longer run synchronously on the UI thread. +- New custom asset browser in Settings with an editor pane and asynchronous scanning. +- Improved robustness: safer EDT handling for meme display and services, retrying listener subscriptions. +- Added regression tests for asset managers and project lifecycle. + +Build Support + +- 2025.2 Build Support diff --git a/gradle.properties b/gradle.properties index 8d1d3065..a4f61390 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,13 +1,12 @@ # IntelliJ Platform Artifacts Repositories -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html pluginGroup=io.unthrottled -pluginVersion=1.6.0 +pluginVersion=1.6.1 pluginSinceBuild=251 -pluginUntilBuild=251.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension platformType = IU -platformVersion = 2025.1 +platformVersion = 2026.1 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP @@ -16,7 +15,7 @@ platformPlugins = Dart:251.23774.318,io.flutter:85.2.4 platformBundledPlugins = NodeJS # Gradle Releases -> https://github.com/gradle/gradle/releases -gradleVersion = 8.13 +gradleVersion = 9.0.0 # Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib kotlin.stdlib.default.dependency = false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a437e02..fc8b08b0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,8 +4,8 @@ junit = "4.13.2" opentest4j = "1.3.0" # plugins -intelliJPlatform = "2.5.0" -kotlin = "2.1.20" +intelliJPlatform = "2.14.0" +kotlin = "2.3.21" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -13,4 +13,4 @@ opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "ope [plugins] intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" } -kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } \ No newline at end of file +kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b1..2a84e188 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/io/unthrottled/amii/config/ui/CustomMemeList.form b/src/main/java/io/unthrottled/amii/config/ui/CustomMemeList.form index 77ee9bef..ad57f012 100644 --- a/src/main/java/io/unthrottled/amii/config/ui/CustomMemeList.form +++ b/src/main/java/io/unthrottled/amii/config/ui/CustomMemeList.form @@ -10,7 +10,7 @@ - + diff --git a/src/main/java/io/unthrottled/amii/config/ui/CustomMemeList.java b/src/main/java/io/unthrottled/amii/config/ui/CustomMemeList.java index c1f003e6..ee7a13b0 100644 --- a/src/main/java/io/unthrottled/amii/config/ui/CustomMemeList.java +++ b/src/main/java/io/unthrottled/amii/config/ui/CustomMemeList.java @@ -16,6 +16,7 @@ import com.intellij.openapi.ui.TextComponentAccessor; import com.intellij.openapi.ui.TextFieldWithBrowseButton; import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.ui.components.JBList; import io.unthrottled.amii.assets.AssetFetchOptions; import io.unthrottled.amii.assets.LocalVisualContentManager; import io.unthrottled.amii.assets.MemeAsset; @@ -25,13 +26,27 @@ import io.unthrottled.amii.tools.PluginMessageBundle; import org.jetbrains.annotations.NotNull; -import javax.swing.BoxLayout; +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; import javax.swing.JCheckBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JList; import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; import javax.swing.SwingUtilities; +import java.awt.BorderLayout; +import java.awt.Component; +import java.net.URI; +import java.nio.file.Paths; import java.util.Arrays; +import java.util.Comparator; +import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; +import java.util.stream.Collectors; public class CustomMemeList { private static final Logger logger = Logger.getInstance(CustomMemeList.class); @@ -45,6 +60,10 @@ public class CustomMemeList { private JCheckBox onlyShowUntaggedItemsCheckBox; private JCheckBox allowSuggestiveContentCheckBox; private JCheckBox onlyUseCustomAssetsCheckBox; + private final DefaultListModel assetListModel = new DefaultListModel<>(); + private final AtomicInteger scanGeneration = new AtomicInteger(); + private JBList assetList; + private JPanel assetEditorPanel; public CustomMemeList( Consumer onTest, @@ -52,7 +71,7 @@ public CustomMemeList( ) { this.onTest = onTest; this.pluginSettingsModel = pluginSettingsModel; - ayyLmao.setLayout(new BoxLayout(ayyLmao, BoxLayout.PAGE_AXIS)); + initializeAssetBrowser(); onlyShowUntaggedItemsCheckBox.addActionListener(a -> populateDirectory(textFieldWithBrowseButton.getText())); allowSuggestiveContentCheckBox.addActionListener(a -> { @@ -69,43 +88,112 @@ public CustomMemeList( private void populateDirectory(String workingDirectory) { if (workingDirectory.isBlank()) { + clearAssetBrowser(); return; } - removePreExistingStuff(); + showLoadingState(); + int currentGeneration = scanGeneration.incrementAndGet(); + boolean includeLewds = this.pluginSettingsModel.getAllowLewds(); + boolean onlyShowUntaggedItems = onlyShowUntaggedItemsCheckBox.isSelected(); ApplicationManager.getApplication().executeOnPooledThread(() -> { Set visualAssetRepresentations = LocalVisualContentManager.supplyAllVisualAssetDefinitionsFromWorkingDirectory( new AssetFetchOptions( workingDirectory, - this.pluginSettingsModel.getAllowLewds() + includeLewds ) ); VisualEntityRepository.Companion.getInstance().refreshLocalAssets(); - // this makes it run on the Dialog's separate - // AWT Thread SwingUtilities.invokeLater(()->{ - visualAssetRepresentations.stream() + if (currentGeneration != scanGeneration.get()) { + return; + } + + List assets = visualAssetRepresentations.stream() .filter(rep -> - !onlyShowUntaggedItemsCheckBox.isSelected() || + !onlyShowUntaggedItems || rep.getCat().isEmpty() ) - .forEach(visualAssetRepresentation -> { - CustomMemePanel customMemePanel = new CustomMemePanel( - this.onTest, - visualAssetRepresentation - ); - ayyLmao.add(customMemePanel.getComponent()); - }); + .sorted(Comparator.comparing(VisualAssetRepresentation::getPath)) + .collect(Collectors.toList()); + updateAssetList(assets); }); }); } - private void removePreExistingStuff() { - while (ayyLmao.getComponentCount() > 0) { - ayyLmao.remove(0); + private void initializeAssetBrowser() { + ayyLmao.setLayout(new BorderLayout()); + + assetList = new JBList<>(assetListModel); + assetList.setCellRenderer(new AssetListCellRenderer()); + assetList.setSelectionMode(javax.swing.ListSelectionModel.SINGLE_SELECTION); + assetList.addListSelectionListener(event -> { + if (!event.getValueIsAdjusting()) { + showSelectedAsset(assetList.getSelectedValue()); + } + }); + + assetEditorPanel = new JPanel(new BorderLayout()); + assetEditorPanel.add(createPlaceholder("Select a custom asset to edit."), BorderLayout.CENTER); + + JSplitPane assetBrowser = new JSplitPane( + JSplitPane.HORIZONTAL_SPLIT, + new JScrollPane(assetList), + assetEditorPanel + ); + assetBrowser.setResizeWeight(0.25); + ayyLmao.add(assetBrowser, BorderLayout.CENTER); + } + + private void clearAssetBrowser() { + scanGeneration.incrementAndGet(); + assetListModel.clear(); + showSelectedAsset(null); + } + + private void showLoadingState() { + assetListModel.clear(); + showPlaceholder("Scanning custom assets..."); + } + + private void updateAssetList(List assets) { + assetListModel.clear(); + assets.forEach(assetListModel::addElement); + if (assets.isEmpty()) { + showPlaceholder("No custom GIF assets found."); + } else { + assetList.setSelectedIndex(0); + } + } + + private void showSelectedAsset(VisualAssetRepresentation asset) { + assetEditorPanel.removeAll(); + if (asset == null) { + assetEditorPanel.add(createPlaceholder("Select a custom asset to edit."), BorderLayout.CENTER); + } else { + CustomMemePanel customMemePanel = new CustomMemePanel( + this.onTest, + asset + ); + assetEditorPanel.add(customMemePanel.getComponent(), BorderLayout.CENTER); } + assetEditorPanel.revalidate(); + assetEditorPanel.repaint(); + } + + private void showPlaceholder(String message) { + assetEditorPanel.removeAll(); + assetEditorPanel.add(createPlaceholder(message), BorderLayout.CENTER); + assetEditorPanel.revalidate(); + assetEditorPanel.repaint(); + } + + private JComponent createPlaceholder(String message) { + JPanel panel = new JPanel(new BorderLayout()); + panel.add(new JLabel(message), BorderLayout.CENTER); + return panel; } public void setPluginSettingsModel(ConfigSettingsModel pluginSettingsModel) { @@ -176,4 +264,34 @@ public void load() { loaded = true; } } + + private static class AssetListCellRenderer extends DefaultListCellRenderer { + @Override + public Component getListCellRendererComponent( + JList list, + Object value, + int index, + boolean isSelected, + boolean cellHasFocus + ) { + JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof VisualAssetRepresentation asset) { + label.setText(getDisplayName(asset)); + label.setToolTipText(asset.getPath()); + } + return label; + } + + private String getDisplayName(VisualAssetRepresentation asset) { + try { + return Paths.get(URI.create(asset.getPath())).getFileName().toString(); + } catch (RuntimeException ignored) { + try { + return Paths.get(asset.getPath()).getFileName().toString(); + } catch (RuntimeException ignoredAgain) { + return asset.getPath(); + } + } + } + } } diff --git a/src/main/java/io/unthrottled/amii/config/ui/PluginSettingsUI.java b/src/main/java/io/unthrottled/amii/config/ui/PluginSettingsUI.java index cad792e2..f30df22c 100644 --- a/src/main/java/io/unthrottled/amii/config/ui/PluginSettingsUI.java +++ b/src/main/java/io/unthrottled/amii/config/ui/PluginSettingsUI.java @@ -18,16 +18,11 @@ import com.intellij.util.ui.UIUtil; import io.unthrottled.amii.assets.CharacterEntity; import io.unthrottled.amii.assets.Gender; -import io.unthrottled.amii.assets.MemeAssetCategory; -import io.unthrottled.amii.assets.VisualAssetDefinitionService; -import io.unthrottled.amii.assets.VisualEntityRepository; -import io.unthrottled.amii.assets.VisualMemeContent; import io.unthrottled.amii.config.Config; import io.unthrottled.amii.config.ConfigListener; import io.unthrottled.amii.config.ConfigSettingsModel; import io.unthrottled.amii.config.PluginSettings; import io.unthrottled.amii.memes.MemeFactory; -import io.unthrottled.amii.memes.MemeMetadata; import io.unthrottled.amii.memes.MemeService; import io.unthrottled.amii.memes.PanelDismissalOptions; import io.unthrottled.amii.services.CharacterGatekeeper; @@ -51,12 +46,9 @@ import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import javax.swing.event.HyperlinkEvent; -import java.awt.Dimension; import java.awt.event.ActionListener; -import java.net.URI; -import java.net.URISyntaxException; import java.util.Arrays; -import java.util.Map; +import java.util.List; import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -70,7 +62,6 @@ import static io.unthrottled.amii.events.UserEvents.TEST; import static io.unthrottled.amii.memes.PanelDismissalOptions.FOCUS_LOSS; import static io.unthrottled.amii.memes.PanelDismissalOptions.TIMED; -import static io.unthrottled.amii.tools.AssetTools.getDimensionCappingStyle; import static java.util.Optional.ofNullable; public class PluginSettingsUI implements SearchableConfigurable, Configurable.NoScroll, DumbAware { @@ -155,7 +146,6 @@ private void createUIComponents() { blacklistCharacters.setPreferredSize(JBUI.size(800, 600)); blacklistCharacters.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); - VisualEntityRepository.Companion.getInstance().refreshLocalAssets(); customMemeListModel = new CustomMemeList( memeContent -> Arrays.stream(ProjectManager.getInstance().getOpenProjects()) @@ -165,9 +155,6 @@ private void createUIComponents() { .ifPresent(meme -> project.getService(MemeService.class) .displayMeme( meme.withDismissalMode(TIMED) - .withMetaData(Map.of( - MemeMetadata.RUN_ON_NON_UI_THREAD.name(), true - )) .build() ) ) @@ -199,9 +186,7 @@ public void setValue(Integer s, String value) { currentRowIndex < exitCodeListModel.getRowCount()) { exitCodeListModel.removeRow(currentRowIndex); } else { - exitCodeListModel.insertRow(currentRowIndex, Integer.parseInt(value)); - exitCodeListModel.removeRow(currentRowIndex + 1); - exitCodeTable.transferFocus(); + updateExitCodeValue(exitCodeTable, exitCodeListModel, currentRowIndex, value); } } @@ -244,9 +229,7 @@ public void setValue(Integer s, String value) { currentRowIndex < positiveExitCodeListModel.getRowCount()) { positiveExitCodeListModel.removeRow(currentRowIndex); } else { - positiveExitCodeListModel.insertRow(currentRowIndex, Integer.parseInt(value)); - positiveExitCodeListModel.removeRow(currentRowIndex + 1); - positiveExitCodeTable.transferFocus(); + updateExitCodeValue(positiveExitCodeTable, positiveExitCodeListModel, currentRowIndex, value); } } @@ -271,7 +254,6 @@ public boolean isCellEditable(Integer info) { generalLinks.setEditable(false); generalLinks.setContentType("text/html"); generalLinks.setBackground(UIUtil.getPanelBackground()); - String aniMeme = getSettingsAniMeme(); generalLinks.setText( "\n" + "\n" + @@ -299,10 +281,7 @@ public boolean isCellEditable(Integer info) { "View Documentation

\n" + "See Changelog

\n" + "Report Issue

\n" + - "
\n" + - aniMeme + - "

Thanks for using AMII!

\n" + - "
\n" + + "

Thanks for using AMII!

\n" + "\n" + "" ); @@ -313,32 +292,6 @@ public boolean isCellEditable(Integer info) { }); } - @NotNull - private String getSettingsAniMeme() { - if (Config.getInstance().getDiscreetMode()) return ""; - - String asset = VisualAssetDefinitionService.INSTANCE - .getRandomAssetByCategory(MemeAssetCategory.HAPPY) - .map(VisualMemeContent::getFilePath) - .map(URI::toString) - .orElse("https://waifu.assets.unthrottled.io/visuals/smug/smug_kurumi_ebisuzawa.gif"); - String extraStyles = - getFilePath(asset) - .map(fileUrl -> getDimensionCappingStyle(fileUrl, new Dimension(100, 100))) - .orElse(""); - String aniMeme = "\n"; - return aniMeme; - } - - @NotNull - private Optional getFilePath(String asset) { - try { - return Optional.of(new URI(asset)); - } catch (URISyntaxException e) { - return Optional.empty(); - } - } - @Override public @NotNull String getId() { return "io.unthrottled.amii.config.PluginSettings"; @@ -571,22 +524,38 @@ private void updateFrustrationComponents() { eventsBeforeFrustrationSpinner.setEnabled(allowFrustrationCheckBox.isSelected()); } + private void updateExitCodeValue( + JBTable table, + ListTableModel model, + int currentRowIndex, + String value + ) { + if (currentRowIndex < 0 || currentRowIndex >= model.getRowCount()) { + return; + } + + try { + model.insertRow(currentRowIndex, Integer.parseInt(value.trim())); + model.removeRow(currentRowIndex + 1); + table.transferFocus(); + } catch (NumberFormatException ignored) { + table.repaint(); + } + } + private void updateGenderPreference(int value, boolean selected) { - int preferredGenders = pluginSettingsModel.getPreferredGenders(); - pluginSettingsModel.setPreferredGenders( - selected ? - preferredGenders | value : - preferredGenders ^ value - ); + pluginSettingsModel.setPreferredGenders(updateBitmask(pluginSettingsModel.getPreferredGenders(), value, selected)); } private void updateEventPreference(int eventCode, boolean selected) { - int enabledEvents = pluginSettingsModel.getEnabledEvents(); - pluginSettingsModel.setEnabledEvents( + pluginSettingsModel.setEnabledEvents(updateBitmask(pluginSettingsModel.getEnabledEvents(), eventCode, selected)); + } + + static int updateBitmask(int currentValue, int value, boolean selected) { + return selected ? - enabledEvents | eventCode : - enabledEvents ^ eventCode - ); + currentValue | value : + currentValue & ~value; } private void initFromState() { @@ -634,13 +603,30 @@ private void extracted(ListTableModel positiveExitCodeListModel, String IntStream.range(0, preExistingRows) .forEach(idx -> positiveExitCodeListModel.removeRow(0)); } - Arrays.stream(positiveExitCodes - .split(Config.DEFAULT_DELIMITER)) - .filter(code -> !StringUtil.isEmpty(code)) - .map(Integer::parseInt) + parseExitCodes(positiveExitCodes) .forEach(positiveExitCodeListModel::addRow); } + @NotNull + static Optional parseExitCode(String code) { + try { + return Optional.of(Integer.parseInt(code.trim())); + } catch (NumberFormatException ignored) { + return Optional.empty(); + } + } + + @NotNull + static List parseExitCodes(String exitCodes) { + return Arrays.stream(exitCodes.split(Config.DEFAULT_DELIMITER)) + .filter(code -> !StringUtil.isEmpty(code)) + .map(PluginSettingsUI::parseExitCode) + .flatMap(Optional::stream) + .distinct() + .sorted() + .collect(Collectors.toList()); + } + private boolean isGenderSelected(int genderCode) { return (initialSettings.getPreferredGenders() & genderCode) == genderCode; } @@ -734,4 +720,11 @@ private String giveMeTheCode(ListTableModel exitCodeListModel) { .mapToObj(String::valueOf) .collect(Collectors.joining(Config.DEFAULT_DELIMITER)); } + + @Override + public void disposeUIResources() { + ofNullable(characterModel).ifPresent(PreferredCharacterPanel::dispose); + ofNullable(blacklistedCharacterModel).ifPresent(PreferredCharacterPanel::dispose); + rootPanel = null; + } } diff --git a/src/main/java/io/unthrottled/amii/listeners/IDEPluginInstallListener.java b/src/main/java/io/unthrottled/amii/listeners/IDEPluginInstallListener.java new file mode 100644 index 00000000..64e3e7fb --- /dev/null +++ b/src/main/java/io/unthrottled/amii/listeners/IDEPluginInstallListener.java @@ -0,0 +1,17 @@ +package io.unthrottled.amii.listeners; + +import com.intellij.ide.plugins.DynamicPluginListener; +import com.intellij.ide.plugins.IdeaPluginDescriptor; +import com.intellij.openapi.application.ApplicationManager; +import io.unthrottled.amii.PluginMaster; + +import static io.unthrottled.amii.config.Constants.PLUGIN_ID; + +public class IDEPluginInstallListener implements DynamicPluginListener { + @Override + public void pluginLoaded(IdeaPluginDescriptor pluginDescriptor) { + if (PLUGIN_ID.equals(pluginDescriptor.getPluginId().getIdString())) { + ApplicationManager.getApplication().invokeLater(() -> PluginMaster.Companion.getInstance().onUpdate()); + } + } +} diff --git a/src/main/java/io/unthrottled/amii/tools/PluginIds.java b/src/main/java/io/unthrottled/amii/tools/PluginIds.java new file mode 100644 index 00000000..8f2401ba --- /dev/null +++ b/src/main/java/io/unthrottled/amii/tools/PluginIds.java @@ -0,0 +1,12 @@ +package io.unthrottled.amii.tools; + +import com.intellij.openapi.extensions.PluginId; + +public final class PluginIds { + private PluginIds() { + } + + public static PluginId getId(String id) { + return PluginId.getId(id); + } +} diff --git a/src/main/kotlin/io/unthrottled/amii/PluginMaster.kt b/src/main/kotlin/io/unthrottled/amii/PluginMaster.kt index 8b03753d..5b2ab592 100644 --- a/src/main/kotlin/io/unthrottled/amii/PluginMaster.kt +++ b/src/main/kotlin/io/unthrottled/amii/PluginMaster.kt @@ -31,6 +31,7 @@ class PluginMaster : Disposable, Logging { } private val projectListeners: ConcurrentMap = ConcurrentHashMap() + private val projectRegistry = ProjectLifecycleRegistry() init { CacheWarmingService.instance.init() @@ -41,15 +42,17 @@ class PluginMaster : Disposable, Logging { registerListenersForProject(project) } + @Synchronized private fun registerListenersForProject(project: Project) { - UserOnBoarding.attemptToPerformNewUpdateActions(project) + if (project.isDisposed) return + val projectId = project.locationHash - if (projectListeners.containsKey(projectId).not()) { - WelcomeService.greetUser(project) - projectListeners[projectId] = - ProjectListeners(project) - checkIfInGoodState(project) - } + if (projectRegistry.markProjectOpened(projectId, project.isDisposed).not()) return + + projectListeners[projectId] = ProjectListeners(project) + UserOnBoarding.attemptToPerformNewUpdateActions(project) + WelcomeService.greetUser(project) + checkIfInGoodState(project) } private fun checkIfInGoodState(project: Project) { @@ -70,8 +73,8 @@ class PluginMaster : Disposable, Logging { } fun projectClosed(project: Project) { - projectListeners[project.locationHash]?.dispose() - projectListeners.remove(project.locationHash) + projectRegistry.markProjectClosed(project.locationHash) + projectListeners.remove(project.locationHash)?.dispose() } override fun dispose() { @@ -81,10 +84,26 @@ class PluginMaster : Disposable, Logging { fun onUpdate() { ProjectManager.getInstance().openProjects + .filter { it.isDisposed.not() } .forEach { registerListenersForProject(it) } } } +internal class ProjectLifecycleRegistry { + private val openProjects = ConcurrentHashMap.newKeySet() + + @Synchronized + fun markProjectOpened(projectId: String, isDisposed: Boolean): Boolean { + if (isDisposed) return false + + return openProjects.add(projectId) + } + + fun markProjectClosed(projectId: String) { + openProjects.remove(projectId) + } +} + internal data class ProjectListeners( private val project: Project ) : Disposable { diff --git a/src/main/kotlin/io/unthrottled/amii/assets/APIAssetManager.kt b/src/main/kotlin/io/unthrottled/amii/assets/APIAssetManager.kt index 98c74f2e..360dfc4c 100644 --- a/src/main/kotlin/io/unthrottled/amii/assets/APIAssetManager.kt +++ b/src/main/kotlin/io/unthrottled/amii/assets/APIAssetManager.kt @@ -118,21 +118,36 @@ object APIAssetManager : Logging { apiPath: String ): Optional { LocalStorageService.createDirectories(localAssetPath) - return AssetAPI.getAsset(apiPath) { inputStream -> - Files.newOutputStream( - localAssetPath, - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING - ).use { bufferedWriter -> - IOUtils.copy(inputStream, bufferedWriter) - } + val download = { + AssetAPI.getAsset(apiPath) { inputStream -> + Files.newOutputStream( + localAssetPath, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ).use { bufferedWriter -> + IOUtils.copy(inputStream, bufferedWriter) + } - ApplicationManager.getApplication().messageBus - .syncPublisher(APIAssetListener.TOPIC) - .onDownload(apiPath) + ApplicationManager.getApplication().messageBus + .syncPublisher(APIAssetListener.TOPIC) + .onDownload(apiPath) - localAssetPath.toUri() + localAssetPath.toUri() + } + } + + if (ApplicationManager.getApplication().isDispatchThread) { + ApplicationManager.getApplication().executeOnPooledThread { + download() + } + return if (Files.exists(localAssetPath)) { + localAssetPath.toUri().toOptional() + } else { + Optional.empty() + } } + + return download() } private fun downloadAndUpdateAssetDefinitions( @@ -141,50 +156,61 @@ object APIAssetManager : Logging { assetConverter: (InputStream) -> Optional> ): URI = runSafelyWithResult({ - AssetAPI.getAsset(apiPath) { inputStream -> - assetConverter(inputStream) - } - .flatMap { it } - .flatMap { newAssets -> - assetConverter(Files.newInputStream(localAssetPath)) - .map { existingAssets -> newAssets to existingAssets } + val update = { + AssetAPI.getAsset(apiPath) { inputStream -> + assetConverter(inputStream) } - .map { (newAssets, existingAssets) -> - val seenAssets = ConcurrentHashMap.newKeySet() - val deletedAssetIds = newAssets - .filter { it.del ?: false } - .map { it.id } - .toSet() - - val updatedAssets = Stream.concat( - newAssets.stream(), - existingAssets.stream() - ) - .filter { it.del != true } - .filter { deletedAssetIds.contains(it.id).not() } - .filter { - seenAssets.add(it.id) - }.filter { it != null }.collect(Collectors.toList()) - - Files.newBufferedWriter( - localAssetPath, - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING - ).use { bufferedWriter -> - bufferedWriter.write( - Gson().toJson(updatedAssets) + .flatMap { it } + .flatMap { newAssets -> + assetConverter(Files.newInputStream(localAssetPath)) + .map { existingAssets -> newAssets to existingAssets } + } + .map { (newAssets, existingAssets) -> + val seenAssets = ConcurrentHashMap.newKeySet() + val deletedAssetIds = newAssets + .filter { it.del ?: false } + .map { it.id } + .toSet() + + val updatedAssets = Stream.concat( + newAssets.stream(), + existingAssets.stream() ) + .filter { it.del != true } + .filter { deletedAssetIds.contains(it.id).not() } + .filter { + seenAssets.add(it.id) + }.filter { it != null }.collect(Collectors.toList()) + + Files.newBufferedWriter( + localAssetPath, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ).use { bufferedWriter -> + bufferedWriter.write( + Gson().toJson(updatedAssets) + ) + } + + ApplicationManager.getApplication().messageBus + .syncPublisher(APIAssetListener.TOPIC) + .onUpdate(apiPath) + + localAssetPath.toUri() } + .orElseGet { + localAssetPath.toUri() + } + } - ApplicationManager.getApplication().messageBus - .syncPublisher(APIAssetListener.TOPIC) - .onUpdate(apiPath) - - localAssetPath.toUri() - } - .orElseGet { - localAssetPath.toUri() + if (ApplicationManager.getApplication().isDispatchThread) { + ApplicationManager.getApplication().executeOnPooledThread { + update() } + localAssetPath.toUri() + } else { + update() + } }) { logger().warn("Unable to update asset $apiPath", it) localAssetPath.toUri() diff --git a/src/main/kotlin/io/unthrottled/amii/assets/AssetAPI.kt b/src/main/kotlin/io/unthrottled/amii/assets/AssetAPI.kt index 1f5790fc..a95a6e27 100644 --- a/src/main/kotlin/io/unthrottled/amii/assets/AssetAPI.kt +++ b/src/main/kotlin/io/unthrottled/amii/assets/AssetAPI.kt @@ -1,10 +1,8 @@ package io.unthrottled.amii.assets -import com.intellij.openapi.application.ApplicationManager import io.unthrottled.amii.integrations.RestTools import java.io.InputStream import java.util.Optional -import java.util.concurrent.Callable object AssetAPI { private val API_URL = System.getenv().getOrDefault( @@ -16,12 +14,8 @@ object AssetAPI { path: String, bodyExtractor: (InputStream) -> T ): Optional = - ApplicationManager.getApplication().executeOnPooledThread( - Callable { - RestTools.performRequest( - "$API_URL$path", - bodyExtractor - ) - } - ).get() + RestTools.performRequest( + "$API_URL$path", + bodyExtractor + ) } diff --git a/src/main/kotlin/io/unthrottled/amii/assets/ContentAssetManager.kt b/src/main/kotlin/io/unthrottled/amii/assets/ContentAssetManager.kt index cd4eb170..13719b95 100644 --- a/src/main/kotlin/io/unthrottled/amii/assets/ContentAssetManager.kt +++ b/src/main/kotlin/io/unthrottled/amii/assets/ContentAssetManager.kt @@ -10,7 +10,6 @@ import java.nio.file.Path import java.nio.file.Paths import java.nio.file.StandardOpenOption import java.util.Optional -import java.util.concurrent.Callable enum class AssetCategory(val category: String) { VISUALS("visuals"), @@ -29,6 +28,12 @@ enum class AssetCategory(val category: String) { } object ContentAssetManager { + internal var isDispatchThread: () -> Boolean = { + ApplicationManager.getApplication()?.isDispatchThread == true + } + internal var executeInBackground: (() -> Unit) -> Unit = { runnable -> + ApplicationManager.getApplication()?.executeOnPooledThread(runnable) ?: runnable() + } val assetSource: String = System.getenv().getOrDefault( "ASSET_SOURCE", @@ -81,19 +86,30 @@ object ContentAssetManager { remoteAssetUrl: String ): Optional { LocalStorageService.createDirectories(localAssetPath) - return ApplicationManager.getApplication().executeOnPooledThread( - Callable { - RestTools.performRequest(remoteAssetUrl) { inputStream -> - Files.newOutputStream( - localAssetPath, - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING - ).use { bufferedWriter -> - IOUtils.copy(inputStream, bufferedWriter) - } - localAssetPath.toUri() + val download = { + RestTools.performRequest(remoteAssetUrl) { inputStream -> + Files.newOutputStream( + localAssetPath, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ).use { bufferedWriter -> + IOUtils.copy(inputStream, bufferedWriter) } + localAssetPath.toUri() + } + } + + if (isDispatchThread()) { + executeInBackground { + download() } - ).get() + return if (Files.exists(localAssetPath)) { + localAssetPath.toUri().toOptional() + } else { + Optional.empty() + } + } + + return download() } } diff --git a/src/main/kotlin/io/unthrottled/amii/assets/LocalVisualContentManager.kt b/src/main/kotlin/io/unthrottled/amii/assets/LocalVisualContentManager.kt index bac77414..28a7ad2b 100644 --- a/src/main/kotlin/io/unthrottled/amii/assets/LocalVisualContentManager.kt +++ b/src/main/kotlin/io/unthrottled/amii/assets/LocalVisualContentManager.kt @@ -136,37 +136,40 @@ object LocalVisualContentManager : Logging, Disposable, ConfigListener { Map> = getAutoTagDirectories(workingDirectory) .flatMap { autoTagDir -> try { - walkDirectoryForAssets( + withAssetsInDirectory( autoTagDir.path.toString() - ) - .map { assetPath: Path? -> - // todo: probably shouldn't calculate md5 hash. - allLocalAssets[ - calculateMD5Hash( - assetPath!! + ) { paths -> + paths + .map { assetPath: Path? -> + allLocalAssets[ + calculateMD5Hash( + assetPath!! + ) + ] + } + .filter { obj: VisualAssetRepresentation? -> + Objects.nonNull( + obj ) - ] - } - .filter { obj: VisualAssetRepresentation? -> - Objects.nonNull( - obj - ) - } - .map { it!! } - .map { rep -> - var modified = false - val memeAssetCategoryValue = autoTagDir.category.value - var usableRep = rep - if (!usableRep.cat.contains(memeAssetCategoryValue)) { - usableRep.cat.add(memeAssetCategoryValue) - modified = true } - - if (autoTagDir.isLewd && usableRep.lewd?.not() == true) { - usableRep = usableRep.copy(lewd = true) + .map { it!! } + .map { rep -> + var modified = false + val memeAssetCategoryValue = autoTagDir.category.value + var usableRep = rep + if (!usableRep.cat.contains(memeAssetCategoryValue)) { + usableRep.cat.add(memeAssetCategoryValue) + modified = true + } + + if (autoTagDir.isLewd && usableRep.lewd?.not() == true) { + usableRep = usableRep.copy(lewd = true) + } + + usableRep to modified } - - usableRep to modified + .collect(Collectors.toList()) + .stream() } } catch (e: RuntimeException) { logger().warn("Unable to auto tag assets for dir ${autoTagDir.path}", e) @@ -225,26 +228,28 @@ object LocalVisualContentManager : Logging, Disposable, ConfigListener { private fun readDirectory(assetFetchOptions: AssetFetchOptions): Set { val workingDirectory = assetFetchOptions.workingDirectory return runSafelyWithResult({ - walkDirectoryForAssets(workingDirectory) - .map { path -> - val id = calculateMD5Hash(path) - val savedAsset = ledger.savedVisualAssets[id] - savedAsset?.duplicateWithNewPath(path.toUri().toString()) - ?: VisualAssetRepresentation( - id, - path.toUri().toString(), - "", - ArrayList(), - ArrayList(), - "", - false - ) - } - .filter { rep -> - rep.lewd != true || - assetFetchOptions.includeLewds - } - .collect(Collectors.toSet()) + withAssetsInDirectory(workingDirectory) { paths -> + paths + .map { path -> + val id = calculateMD5Hash(path) + val savedAsset = ledger.savedVisualAssets[id] + savedAsset?.duplicateWithNewPath(path.toUri().toString()) + ?: VisualAssetRepresentation( + id, + path.toUri().toString(), + "", + ArrayList(), + ArrayList(), + "", + false + ) + } + .filter { rep -> + rep.lewd != true || + assetFetchOptions.includeLewds + } + .collect(Collectors.toSet()) + } }) { this.logger().warn("Unable to walk custom working directory for raisins.", it) emptySet() @@ -252,6 +257,31 @@ object LocalVisualContentManager : Logging, Disposable, ConfigListener { } @JvmStatic + fun walkDirectoryForAssets(workingDirectory: String): Stream = + LocalVisualAssetScanner.walkDirectoryForAssets(workingDirectory) + + fun withAssetsInDirectory( + workingDirectory: String, + assetConsumer: (Stream) -> T + ): T = + LocalVisualAssetScanner.withAssetsInDirectory(workingDirectory, assetConsumer) + + override fun dispose() { + messageBusConnection.dispose() + } + + override fun pluginConfigUpdated(config: Config) { + ApplicationManager.getApplication().executeOnPooledThread { + rescanDirectory() + } + } + + fun init() { + // to warm up + } +} + +object LocalVisualAssetScanner { fun walkDirectoryForAssets(workingDirectory: String): Stream = Files.walk( Paths.get(workingDirectory) @@ -270,19 +300,11 @@ object LocalVisualContentManager : Logging, Disposable, ConfigListener { path.fileName.toString().endsWith(".gif") } - override fun dispose() { - messageBusConnection.dispose() - } - - override fun pluginConfigUpdated(config: Config) { - ApplicationManager.getApplication().executeOnPooledThread { - rescanDirectory() - } - } - - fun init() { - // to warm up - } + fun withAssetsInDirectory( + workingDirectory: String, + assetConsumer: (Stream) -> T + ): T = + walkDirectoryForAssets(workingDirectory).use(assetConsumer) } data class AutoTagDirectory( diff --git a/src/main/kotlin/io/unthrottled/amii/core/MIKU.kt b/src/main/kotlin/io/unthrottled/amii/core/MIKU.kt index b16e8bd0..55b4763c 100644 --- a/src/main/kotlin/io/unthrottled/amii/core/MIKU.kt +++ b/src/main/kotlin/io/unthrottled/amii/core/MIKU.kt @@ -3,6 +3,7 @@ package io.unthrottled.amii.core import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.AppExecutorUtil import io.unthrottled.amii.config.Config import io.unthrottled.amii.config.ConfigListener import io.unthrottled.amii.config.ConfigListener.Companion.CONFIG_TOPIC @@ -30,6 +31,7 @@ import io.unthrottled.amii.tools.Logging import io.unthrottled.amii.tools.PluginMessageBundle import io.unthrottled.amii.tools.logger import io.unthrottled.amii.tools.runSafely +import java.util.concurrent.TimeUnit // Meme Inference Knowledge Unit class MIKU(private val project: Project) : @@ -41,6 +43,7 @@ class MIKU(private val project: Project) : companion object { private const val DEBOUNCE_INTERVAL = 80 + private const val SUBSCRIPTION_RETRY_DELAY_MILLIS = 500L val USER_TRIGGERED_EVENTS = setOf( UserEvents.TEST, UserEvents.TASK, @@ -63,8 +66,8 @@ class MIKU(private val project: Project) : init { ApplicationManager.getApplication().invokeLater { - attemptToSubscribe { projectMessageBusConnection.subscribe(EMOTION_TOPIC, this) } - attemptToSubscribe { projectMessageBusConnection.subscribe(EMOTIONAL_MUTATION_TOPIC, this) } + attemptToSubscribe("emotion events") { projectMessageBusConnection.subscribe(EMOTION_TOPIC, this) } + attemptToSubscribe("emotion mutations") { projectMessageBusConnection.subscribe(EMOTIONAL_MUTATION_TOPIC, this) } attemptToSubscribe { messageBusConnection.subscribe( CONFIG_TOPIC, @@ -77,19 +80,37 @@ class MIKU(private val project: Project) : } } - private fun attemptToSubscribe(subscribingFunction: () -> Unit) { + private fun attemptToSubscribe( + subscriptionName: String = "configuration updates", + subscribingFunction: () -> Unit + ) { runSafely(subscribingFunction) { - logger().warn("Unable to subscribe for reasons", it) - runSafely(subscribingFunction) { - logger().warn("Second subscription attempt failed", it) - UpdateNotification.sendMessage( - PluginMessageBundle.message("miku.startup.error.title"), - PluginMessageBundle.message("miku.startup.error.body") - ) - } + logger().warn("Unable to subscribe to $subscriptionName", it) + scheduleSubscriptionRetry(subscriptionName, subscribingFunction) } } + private fun scheduleSubscriptionRetry( + subscriptionName: String, + subscribingFunction: () -> Unit + ) { + AppExecutorUtil.getAppScheduledExecutorService().schedule( + { + ApplicationManager.getApplication().invokeLater { + runSafely(subscribingFunction) { + logger().warn("Retry for $subscriptionName subscription failed", it) + UpdateNotification.sendMessage( + PluginMessageBundle.message("miku.startup.error.title"), + PluginMessageBundle.message("miku.startup.error.body") + ) + } + } + }, + SUBSCRIPTION_RETRY_DELAY_MILLIS, + TimeUnit.MILLISECONDS + ) + } + override fun onDispatch(userEvent: UserEvent) { logger().debug("Seen user event $userEvent") if (Config.instance.eventEnabled(userEvent.type).not()) return diff --git a/src/main/kotlin/io/unthrottled/amii/integrations/RestClient.kt b/src/main/kotlin/io/unthrottled/amii/integrations/RestClient.kt index c1985c19..034d6c14 100644 --- a/src/main/kotlin/io/unthrottled/amii/integrations/RestClient.kt +++ b/src/main/kotlin/io/unthrottled/amii/integrations/RestClient.kt @@ -1,6 +1,5 @@ package io.unthrottled.amii.integrations -import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger import com.intellij.util.io.HttpRequests import io.unthrottled.amii.integrations.RestTools.performRequest @@ -11,23 +10,18 @@ import io.unthrottled.amii.tools.runSafelyWithResult import io.unthrottled.amii.tools.toOptional import java.io.InputStream import java.util.Optional -import java.util.concurrent.Callable object RestClient : Logging { fun performGet(url: String): Optional = - ApplicationManager.getApplication().executeOnPooledThread( - Callable { - runSafelyWithResult({ - performRequest(url) { responseBody -> - String(responseBody.readAllTheBytes()) - } - }) { - logger().warn("Unable to complete request for $url for raisins.", it) - Optional.empty() - } + runSafelyWithResult({ + performRequest(url) { responseBody -> + String(responseBody.readAllTheBytes()) } - ).get() + }) { + logger().warn("Unable to complete request for $url for raisins.", it) + Optional.empty() + } } object RestTools { diff --git a/src/main/kotlin/io/unthrottled/amii/listeners/IDEPluginInstallListener.kt b/src/main/kotlin/io/unthrottled/amii/listeners/IDEPluginInstallListener.kt deleted file mode 100644 index 1a59e4e4..00000000 --- a/src/main/kotlin/io/unthrottled/amii/listeners/IDEPluginInstallListener.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.unthrottled.amii.listeners - -import com.intellij.ide.plugins.DynamicPluginListener -import com.intellij.ide.plugins.IdeaPluginDescriptor -import com.intellij.openapi.application.ApplicationManager -import io.unthrottled.amii.PluginMaster -import io.unthrottled.amii.config.Constants.PLUGIN_ID -import io.unthrottled.amii.tools.Logging - -class IDEPluginInstallListener : DynamicPluginListener, Logging { - - override fun beforePluginLoaded(pluginDescriptor: IdeaPluginDescriptor) { - } - - override fun beforePluginUnload( - pluginDescriptor: IdeaPluginDescriptor, - isUpdate: Boolean - ) { - } - - override fun checkUnloadPlugin(pluginDescriptor: IdeaPluginDescriptor) { - } - - override fun pluginLoaded(pluginDescriptor: IdeaPluginDescriptor) { - if (pluginDescriptor.pluginId.idString == PLUGIN_ID) { - ApplicationManager.getApplication().invokeLater { - PluginMaster.instance.onUpdate() - } - } - } - - override fun pluginUnloaded( - pluginDescriptor: IdeaPluginDescriptor, - isUpdate: Boolean - ) { - } -} diff --git a/src/main/kotlin/io/unthrottled/amii/listeners/ProjectListener.kt b/src/main/kotlin/io/unthrottled/amii/listeners/ProjectListener.kt index 311cc15c..995fa9c3 100644 --- a/src/main/kotlin/io/unthrottled/amii/listeners/ProjectListener.kt +++ b/src/main/kotlin/io/unthrottled/amii/listeners/ProjectListener.kt @@ -3,17 +3,12 @@ package io.unthrottled.amii.listeners import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManagerListener import com.intellij.openapi.startup.ProjectActivity -import com.intellij.openapi.startup.StartupActivity import io.unthrottled.amii.PluginMaster import io.unthrottled.amii.tools.Logging -internal class PluginPostStartUpActivity : StartupActivity, ProjectActivity { - override fun runActivity(project: Project) { - PluginMaster.instance.projectOpened(project) - } - +internal class PluginPostStartUpActivity : ProjectActivity { override suspend fun execute(project: Project) { - runActivity(project) + PluginMaster.instance.projectOpened(project) } } diff --git a/src/main/kotlin/io/unthrottled/amii/memes/Meme.kt b/src/main/kotlin/io/unthrottled/amii/memes/Meme.kt index ebc44bfa..f381ea8e 100644 --- a/src/main/kotlin/io/unthrottled/amii/memes/Meme.kt +++ b/src/main/kotlin/io/unthrottled/amii/memes/Meme.kt @@ -151,19 +151,18 @@ class Meme( } } - if (metadata[MemeMetadata.RUN_ON_NON_UI_THREAD.name] == true) { - // allows the meme to show up when a Dialog is open :) - ApplicationManager.getApplication().executeOnPooledThread { - displayMeme() - } - } else { - ApplicationManager.getApplication().invokeLater { - displayMeme() - } + ApplicationManager.getApplication().invokeLater { + displayMeme() } } private fun displayMeme() { + val application = ApplicationManager.getApplication() + if (!application.isDispatchThread) { + application.invokeLater { displayMeme() } + return + } + memePanel.display( object : MemeLifecycleListener { override fun onDisplay() { diff --git a/src/main/kotlin/io/unthrottled/amii/memes/MemeService.kt b/src/main/kotlin/io/unthrottled/amii/memes/MemeService.kt index 51911938..810482ef 100644 --- a/src/main/kotlin/io/unthrottled/amii/memes/MemeService.kt +++ b/src/main/kotlin/io/unthrottled/amii/memes/MemeService.kt @@ -1,6 +1,7 @@ package io.unthrottled.amii.memes import com.intellij.openapi.project.Project +import com.intellij.openapi.application.ApplicationManager import io.unthrottled.amii.tools.getRootPane import io.unthrottled.amii.tools.toOptional import javax.swing.JLayeredPane @@ -13,6 +14,12 @@ class MemeService(private val project: Project) { } fun displayMeme(meme: Meme) { + val application = ApplicationManager.getApplication() + if (!application.isDispatchThread) { + application.invokeLater { displayMeme(meme) } + return + } + // be paranoid about existing memes // hanging around for some reason https://github.com/ani-memes/AMII/issues/108 project.getRootPane().toOptional().ifPresent { dismissAllMemesInPane(it) } @@ -21,6 +28,12 @@ class MemeService(private val project: Project) { } fun clearMemes() { + val application = ApplicationManager.getApplication() + if (!application.isDispatchThread) { + application.invokeLater { clearMemes() } + return + } + project.getRootPane().toOptional() .ifPresent { rootPane -> dismissAllMemesInPane(rootPane) diff --git a/src/main/kotlin/io/unthrottled/amii/onboarding/UserOnBoarding.kt b/src/main/kotlin/io/unthrottled/amii/onboarding/UserOnBoarding.kt index 396ff017..5add17f2 100644 --- a/src/main/kotlin/io/unthrottled/amii/onboarding/UserOnBoarding.kt +++ b/src/main/kotlin/io/unthrottled/amii/onboarding/UserOnBoarding.kt @@ -2,13 +2,13 @@ package io.unthrottled.amii.onboarding import com.intellij.ide.plugins.PluginManagerCore import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.project.Project import com.intellij.openapi.startup.StartupManager import io.unthrottled.amii.config.Config import io.unthrottled.amii.config.Constants.PLUGIN_ID import io.unthrottled.amii.platform.UpdateAssetsListener import io.unthrottled.amii.promotion.PromotionManager +import io.unthrottled.amii.tools.PluginIds import io.unthrottled.amii.tools.toOptional import java.util.Optional import java.util.UUID @@ -44,7 +44,7 @@ object UserOnBoarding { .filter { it != Config.instance.version } fun getVersion(): Optional = - PluginManagerCore.getPlugin(PluginId.getId(PLUGIN_ID)) + PluginManagerCore.getPlugin(PluginIds.getId(PLUGIN_ID)) .toOptional() .map { it.version } } diff --git a/src/main/kotlin/io/unthrottled/amii/promotion/MemePromotionDialog.kt b/src/main/kotlin/io/unthrottled/amii/promotion/MemePromotionDialog.kt index 48b91ab6..6fe2b7c0 100644 --- a/src/main/kotlin/io/unthrottled/amii/promotion/MemePromotionDialog.kt +++ b/src/main/kotlin/io/unthrottled/amii/promotion/MemePromotionDialog.kt @@ -1,7 +1,6 @@ package io.unthrottled.amii.promotion import com.intellij.ide.BrowserUtil -import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.ui.DialogWrapper import com.intellij.openapi.ui.DoNotAskOption import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.installAndEnable @@ -10,6 +9,7 @@ import com.intellij.util.ui.UIUtil import io.unthrottled.amii.onboarding.UpdateNotification import io.unthrottled.amii.tools.Logging import io.unthrottled.amii.tools.PluginMessageBundle +import io.unthrottled.amii.tools.PluginIds import io.unthrottled.amii.tools.logger import io.unthrottled.amii.tools.runSafely import java.awt.Dimension @@ -87,7 +87,7 @@ class AniMemePromotionDialog( override fun actionPerformed(e: ActionEvent) { val pluginIds = setOf( - PluginId.getId(promotionDefinition.pluginId) + PluginIds.getId(promotionDefinition.pluginId) ) val onSuccess = Runnable { close(INSTALLED_EXIT_CODE, true) diff --git a/src/main/kotlin/io/unthrottled/amii/promotion/PluginService.kt b/src/main/kotlin/io/unthrottled/amii/promotion/PluginService.kt index 85b1515b..e3e01b74 100644 --- a/src/main/kotlin/io/unthrottled/amii/promotion/PluginService.kt +++ b/src/main/kotlin/io/unthrottled/amii/promotion/PluginService.kt @@ -13,6 +13,7 @@ import com.intellij.util.Urls import com.intellij.util.io.HttpRequests import io.unthrottled.amii.config.Constants import io.unthrottled.amii.tools.Logging +import io.unthrottled.amii.tools.PluginIds import io.unthrottled.amii.tools.logger import io.unthrottled.amii.tools.runSafelyWithResult import io.unthrottled.amii.tools.toOptional @@ -35,11 +36,11 @@ object PluginService : Logging { private val COMPATIBLE_UPDATE_URL by lazy { "$PLUGIN_MANAGER_URL/api/search/compatibleUpdates" } fun isRiderExtensionInstalled(): Boolean = PluginManagerCore.isPluginInstalled( - PluginId.getId(Constants.RIDER_EXTENSION_ID) + PluginIds.getId(Constants.RIDER_EXTENSION_ID) ) fun isAndroidExtensionInstalled(): Boolean = PluginManagerCore.isPluginInstalled( - PluginId.getId(Constants.ANDROID_EXTENSION_ID) + PluginIds.getId(Constants.ANDROID_EXTENSION_ID) ) fun canRiderExtensionBeInstalled(): Boolean = @@ -51,7 +52,7 @@ object PluginService : Logging { private fun canExtensionBeInstalled(pluginIdString: String) = ApplicationManager.getApplication().executeOnPooledThread( Callable { - val pluginId = PluginId.getId(pluginIdString) + val pluginId = PluginIds.getId(pluginIdString) runSafelyWithResult({ getLastCompatiblePluginUpdate( Collections.singleton(pluginId) diff --git a/src/main/kotlin/io/unthrottled/amii/services/GifService.kt b/src/main/kotlin/io/unthrottled/amii/services/GifService.kt index 0a5db057..4286a9d0 100644 --- a/src/main/kotlin/io/unthrottled/amii/services/GifService.kt +++ b/src/main/kotlin/io/unthrottled/amii/services/GifService.kt @@ -14,6 +14,7 @@ import javax.imageio.metadata.IIOMetadataNode import javax.imageio.stream.ImageInputStream object GifService : Logging { + private const val MAX_CACHE_SIZE = 512 private val cache = ConcurrentHashMap() private val dimensionCache = ConcurrentHashMap() @@ -32,22 +33,26 @@ object GifService : Logging { filePath: URI, cacheGetter: (URI) -> R ): R { - if (cacheGuy.containsKey(filePath).not()) { - cacheGuy[filePath] = cacheGetter(filePath) + if (cacheGuy.size > MAX_CACHE_SIZE) { + cacheGuy.clear() } - return cacheGuy[filePath]!! + return cacheGuy.computeIfAbsent(filePath, cacheGetter) } private fun fetchGifDuration(filePath: URI) = runSafelyWithResult({ createImageStream(filePath) .use { imageInputStream -> val reader = getImageReader(imageInputStream) - val numImages = reader.getNumImages(true) - val gifCycleDuration = (0 until numImages) - .mapNotNull { reader.getImageMetadata(it) } - .sumOf { getFrameDelay(it) } - gifCycleDuration + try { + val numImages = reader.getNumImages(true) + val gifCycleDuration = (0 until numImages) + .mapNotNull { reader.getImageMetadata(it) } + .sumOf { getFrameDelay(it) } + gifCycleDuration + } finally { + reader.dispose() + } } }) { logger().warn("Unable to read image count", it) @@ -59,10 +64,14 @@ object GifService : Logging { createImageStream(filePath) .use { imageInputStream -> val reader = getImageReader(imageInputStream) - Dimension( - reader.getWidth(0), - reader.getHeight(0) - ) + try { + Dimension( + reader.getWidth(0), + reader.getHeight(0) + ) + } finally { + reader.dispose() + } } }) { logger().warn("Unable to read image dimensions", it) diff --git a/src/main/kotlin/io/unthrottled/amii/services/WelcomeService.kt b/src/main/kotlin/io/unthrottled/amii/services/WelcomeService.kt index 817692ed..bcca01f1 100644 --- a/src/main/kotlin/io/unthrottled/amii/services/WelcomeService.kt +++ b/src/main/kotlin/io/unthrottled/amii/services/WelcomeService.kt @@ -1,28 +1,73 @@ package io.unthrottled.amii.services +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project import com.intellij.openapi.startup.StartupManager +import com.intellij.util.concurrency.AppExecutorUtil +import io.unthrottled.amii.assets.AnimeContentManager +import io.unthrottled.amii.assets.AudibleContentManager +import io.unthrottled.amii.assets.CharacterContentManager +import io.unthrottled.amii.assets.RemoteVisualContentManager +import io.unthrottled.amii.assets.Status import io.unthrottled.amii.events.EVENT_TOPIC import io.unthrottled.amii.events.UserEvent import io.unthrottled.amii.events.UserEventCategory import io.unthrottled.amii.events.UserEvents import io.unthrottled.amii.tools.PluginMessageBundle +import java.util.concurrent.TimeUnit object WelcomeService { + private const val MAX_READINESS_ATTEMPTS = 5 + private const val READINESS_RETRY_DELAY_MILLIS = 500L fun greetUser(project: Project) { StartupManager.getInstance(project) .runWhenProjectIsInitialized { - project.messageBus - .syncPublisher(EVENT_TOPIC) - .onDispatch( - UserEvent( - UserEvents.STARTUP, - UserEventCategory.POSITIVE, - PluginMessageBundle.message("user.event.startup.name"), - project - ) - ) + dispatchGreetingWhenReady(project, 0) } } + + private fun dispatchGreetingWhenReady(project: Project, attempt: Int) { + if (project.isDisposed) return + + if (assetMetadataResolved() || attempt >= MAX_READINESS_ATTEMPTS) { + dispatchGreeting(project) + return + } + + AppExecutorUtil.getAppScheduledExecutorService().schedule( + { + dispatchGreetingWhenReady(project, attempt + 1) + }, + READINESS_RETRY_DELAY_MILLIS, + TimeUnit.MILLISECONDS + ) + } + + private fun assetMetadataResolved(): Boolean = + listOf( + AudibleContentManager.status, + RemoteVisualContentManager.status, + AnimeContentManager.status, + CharacterContentManager.status + ).none { it == Status.UNKNOWN } + + private fun dispatchGreeting(project: Project) { + if (project.isDisposed) return + + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + + project.messageBus + .syncPublisher(EVENT_TOPIC) + .onDispatch( + UserEvent( + UserEvents.STARTUP, + UserEventCategory.POSITIVE, + PluginMessageBundle.message("user.event.startup.name"), + project + ) + ) + } + } } diff --git a/src/main/kotlin/io/unthrottled/amii/tools/AssetTools.kt b/src/main/kotlin/io/unthrottled/amii/tools/AssetTools.kt index 6a00d04b..47eb50ee 100644 --- a/src/main/kotlin/io/unthrottled/amii/tools/AssetTools.kt +++ b/src/main/kotlin/io/unthrottled/amii/tools/AssetTools.kt @@ -10,6 +10,7 @@ import java.awt.Dimension import java.net.URI import java.nio.file.Files import java.nio.file.Path +import java.security.DigestInputStream import java.security.MessageDigest import java.util.Optional @@ -19,7 +20,16 @@ object AssetTools { @JvmStatic fun calculateMD5Hash(path: Path): String { - return computeCheckSum(Files.readAllBytes(path)) + val messageDigest = MessageDigest.getInstance("MD5") + Files.newInputStream(path).use { inputStream -> + DigestInputStream(inputStream, messageDigest).use { digestInputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (digestInputStream.read(buffer) != -1) { + // DigestInputStream updates the digest as bytes are read. + } + } + } + return StringUtil.toHexString(messageDigest.digest()) } @JvmStatic @@ -31,12 +41,6 @@ object AssetTools { return "width='" + usableDimension.width + "' height='" + usableDimension.height + "'" } - private fun computeCheckSum(byteArray: ByteArray): String { - val messageDigest = MessageDigest.getInstance("MD5") - messageDigest.update(byteArray) - return StringUtil.toHexString(messageDigest.digest()) - } - fun resolveAssetFromCategories( vararg categories: MemeAssetCategory ): Optional { diff --git a/src/main/resources/messages/AMII.properties b/src/main/resources/messages/AMII.properties index f925745d..8cfaadd2 100644 --- a/src/main/resources/messages/AMII.properties +++ b/src/main/resources/messages/AMII.properties @@ -19,7 +19,7 @@ user.event.log-watch.name=Log Watching Events user.event.relax.name=Calming user.event.startup.name=Greeting notifications.bad.state.title=Unable to initialize -notifications.bad.state.body=I need internet first before I can work offline. Please re-establish connection and restart. +notifications.bad.state.body=I need internet before I can work offline. Please re-establish connection and run Synchronize Assets. actions.sync.title=Assets Synchronized actions.sync.message=Your local lists of assets should now be up to date with the remote repository. actions.minimal.enabled.title=Mimimal mode enabled @@ -62,7 +62,7 @@ settings.personality.general.show-mood=Show Mood in Status Bar settings.general.misc=Miscellaneous actions.sync.start.title=Starting Asset Sync actions.sync.start.message=Fetching list of assets from the remote repository. -miku.startup.error.body=For full functionality, please try restarting your IDE. Please submit an issue if it persists. +miku.startup.error.body=Some AMII listeners could not be registered. Please run Synchronize Assets and submit an issue if it persists. miku.startup.error.title=Unable to fully initialize! user.event.silence.name=Silence Breaking Events settings.events.silence.name=Break the silence diff --git a/src/main/resources/messages/AMII_zh.properties b/src/main/resources/messages/AMII_zh.properties index 5974fdbe..4a8edb8f 100644 --- a/src/main/resources/messages/AMII_zh.properties +++ b/src/main/resources/messages/AMII_zh.properties @@ -18,7 +18,7 @@ user.event.log-watch.name=日志查看事件 user.event.relax.name=平静 user.event.startup.name=问候 notifications.bad.state.title=无法初始化 -notifications.bad.state.body=在可以脱机工作前,我需要连接网络。请重新建立网络连接并重启。 +notifications.bad.state.body=在可以脱机工作前,我需要连接网络。请重新建立网络连接并运行同步资源。 actions.sync.title=资源已同步 actions.sync.message=你的本地资源列表现在应该与远程仓库同步了。 actions.minimal.enabled.title=Minimal模式启用 @@ -61,7 +61,7 @@ settings.personality.general.show-mood=在状态栏显示心情 settings.general.misc=杂项 actions.sync.start.title=开始同步资源 actions.sync.start.message=正在从远程仓库获取资源列表... -miku.startup.error.body=为获得完整功能,请重启你的IDE。如果问题依然存在,请提交 Issue 。 +miku.startup.error.body=部分 AMII 监听器无法注册。请运行同步资源;如果问题依然存在,请提交 Issue 。 miku.startup.error.title=无法完全初始化! user.event.silence.name=打破寂静事件 settings.events.silence.name=打破寂静 diff --git a/src/test/kotlin/io/unthrottled/amii/ProjectLifecycleRegistryTest.kt b/src/test/kotlin/io/unthrottled/amii/ProjectLifecycleRegistryTest.kt new file mode 100644 index 00000000..635847cf --- /dev/null +++ b/src/test/kotlin/io/unthrottled/amii/ProjectLifecycleRegistryTest.kt @@ -0,0 +1,27 @@ +package io.unthrottled.amii + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class ProjectLifecycleRegistryTest { + + @Test + fun `same project can only be opened once until closed`() { + val registry = ProjectLifecycleRegistry() + + assertThat(registry.markProjectOpened("project-one", isDisposed = false)).isTrue + assertThat(registry.markProjectOpened("project-one", isDisposed = false)).isFalse + + registry.markProjectClosed("project-one") + + assertThat(registry.markProjectOpened("project-one", isDisposed = false)).isTrue + } + + @Test + fun `disposed projects are never registered`() { + val registry = ProjectLifecycleRegistry() + + assertThat(registry.markProjectOpened("project-one", isDisposed = true)).isFalse + assertThat(registry.markProjectOpened("project-one", isDisposed = false)).isTrue + } +} diff --git a/src/test/kotlin/io/unthrottled/amii/assets/ContentAssetManagerRegressionTest.kt b/src/test/kotlin/io/unthrottled/amii/assets/ContentAssetManagerRegressionTest.kt new file mode 100644 index 00000000..6c48a97f --- /dev/null +++ b/src/test/kotlin/io/unthrottled/amii/assets/ContentAssetManagerRegressionTest.kt @@ -0,0 +1,68 @@ +package io.unthrottled.amii.assets + +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.unthrottled.amii.integrations.RestTools +import io.unthrottled.amii.tools.TestTools +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.net.URI +import java.nio.file.Files +import java.util.Comparator +import java.util.Optional +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class ContentAssetManagerRegressionTest { + + private val testDirectory = TestTools.getTestAssetPath("content-assets-regression") + + @Before + fun setUp() { + mockkObject(LocalStorageService) + mockkObject(RestTools) + every { LocalStorageService.getContentDirectory() } returns testDirectory.toString() + every { LocalStorageService.createDirectories(any()) } answers { + Files.createDirectories(firstArg().parent) + } + ContentAssetManager.isDispatchThread = { true } + ContentAssetManager.executeInBackground = { runnable -> + Thread(runnable, "asset-resolution-test").start() + } + } + + @After + fun tearDown() { + ContentAssetManager.isDispatchThread = { + com.intellij.openapi.application.ApplicationManager.getApplication()?.isDispatchThread == true + } + ContentAssetManager.executeInBackground = { runnable -> + com.intellij.openapi.application.ApplicationManager.getApplication()?.executeOnPooledThread(runnable) ?: runnable() + } + unmockkObject(LocalStorageService) + unmockkObject(RestTools) + if (Files.exists(testDirectory)) { + Files.walk(testDirectory) + .sorted(Comparator.reverseOrder()) + .forEach { Files.deleteIfExists(it) } + } + } + + @Test + fun `resolveAssetUrl does not perform network synchronously on EDT`() { + val requestStarted = CountDownLatch(1) + every { RestTools.performRequest(any(), any()) } answers { + requestStarted.countDown() + Optional.empty() + } + + val result = ContentAssetManager.resolveAssetUrl(AssetCategory.PROMOTION, "missing/logo.png") + + assertThat(result).isNotNull + assertThat(result).isEmpty + assertThat(requestStarted.await(5, TimeUnit.SECONDS)).isTrue + } +} diff --git a/src/test/kotlin/io/unthrottled/amii/assets/LocalVisualContentManagerRegressionTest.kt b/src/test/kotlin/io/unthrottled/amii/assets/LocalVisualContentManagerRegressionTest.kt new file mode 100644 index 00000000..7045a141 --- /dev/null +++ b/src/test/kotlin/io/unthrottled/amii/assets/LocalVisualContentManagerRegressionTest.kt @@ -0,0 +1,57 @@ +package io.unthrottled.amii.assets + +import io.unthrottled.amii.tools.TestTools +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Test +import java.util.Comparator +import java.nio.file.Files +import kotlin.io.path.isRegularFile + +class LocalVisualContentManagerRegressionTest { + + private val testDirectory = TestTools.getTestAssetPath("custom-assets-regression") + + @After + fun cleanUp() { + if (Files.exists(testDirectory)) { + Files.walk(testDirectory) + .sorted(Comparator.reverseOrder()) + .forEach { Files.deleteIfExists(it) } + } + } + + @Test + fun `walkDirectoryForAssets returns no items for empty directory`() { + val assets = LocalVisualAssetScanner.withAssetsInDirectory(testDirectory.toString()) { + it.toList() + } + + assertThat(assets).isEmpty() + } + + @Test + fun `walkDirectoryForAssets returns only gif files`() { + Files.writeString(testDirectory.resolve("one.gif"), "not really a gif") + Files.writeString(testDirectory.resolve("two.txt"), "not a gif") + + val assets = LocalVisualAssetScanner.withAssetsInDirectory(testDirectory.toString()) { + it.map { path -> path.fileName.toString() }.toList() + } + + assertThat(assets).containsExactly("one.gif") + } + + @Test + fun `walkDirectoryForAssets handles many gif files without creating UI panels`() { + repeat(600) { idx -> + Files.writeString(testDirectory.resolve("asset-$idx.gif"), "not really a gif") + } + + val assetCount = LocalVisualAssetScanner.withAssetsInDirectory(testDirectory.toString()) { + it.filter { path -> path.isRegularFile() }.count() + } + + assertThat(assetCount).isEqualTo(600) + } +} diff --git a/src/test/kotlin/io/unthrottled/amii/config/ui/PluginSettingsUIRegressionTest.kt b/src/test/kotlin/io/unthrottled/amii/config/ui/PluginSettingsUIRegressionTest.kt new file mode 100644 index 00000000..1a07956e --- /dev/null +++ b/src/test/kotlin/io/unthrottled/amii/config/ui/PluginSettingsUIRegressionTest.kt @@ -0,0 +1,51 @@ +package io.unthrottled.amii.config.ui + +import io.unthrottled.amii.events.UserEvents +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class PluginSettingsUIRegressionTest { + + @Test + fun `parse exit codes ignores invalid and empty values`() { + val exitCodes = PluginSettingsUI.parseExitCodes("0,,abc, 1 ,2147483648,-9") + + assertThat(exitCodes).containsExactly(-9, 0, 1) + } + + @Test + fun `parse exit codes removes duplicates and sorts`() { + val exitCodes = PluginSettingsUI.parseExitCodes("2,1,2,0,-1") + + assertThat(exitCodes).containsExactly(-1, 0, 1, 2) + } + + @Test + fun `bitmask update clears only the requested bit`() { + val initialValue = UserEvents.STARTUP.value or UserEvents.TEST.value or UserEvents.TASK.value + + val result = PluginSettingsUI.updateBitmask(initialValue, UserEvents.TEST.value, false) + + assertThat(result and UserEvents.STARTUP.value).isEqualTo(UserEvents.STARTUP.value) + assertThat(result and UserEvents.TEST.value).isZero() + assertThat(result and UserEvents.TASK.value).isEqualTo(UserEvents.TASK.value) + } + + @Test + fun `bitmask update setting an already set bit is stable`() { + val initialValue = UserEvents.STARTUP.value or UserEvents.TEST.value + + val result = PluginSettingsUI.updateBitmask(initialValue, UserEvents.TEST.value, true) + + assertThat(result).isEqualTo(initialValue) + } + + @Test + fun `bitmask update clearing an already clear bit is stable`() { + val initialValue = UserEvents.STARTUP.value + + val result = PluginSettingsUI.updateBitmask(initialValue, UserEvents.TEST.value, false) + + assertThat(result).isEqualTo(initialValue) + } +}