Subject: [PATCH] ImportExportPlugins
---
Index: src/org/openstreetmap/josm/actions/SessionLoadAction.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/actions/SessionLoadAction.java b/src/org/openstreetmap/josm/actions/SessionLoadAction.java
--- a/src/org/openstreetmap/josm/actions/SessionLoadAction.java	(revision 18807)
+++ b/src/org/openstreetmap/josm/actions/SessionLoadAction.java	(date 1692108399212)
@@ -12,6 +12,7 @@
 import java.nio.file.Files;
 import java.nio.file.StandardCopyOption;
 import java.util.Arrays;
+import java.util.EnumSet;
 import java.util.List;
 
 import javax.swing.JFileChooser;
@@ -33,6 +34,7 @@
 import org.openstreetmap.josm.io.session.SessionReader;
 import org.openstreetmap.josm.io.session.SessionReader.SessionProjectionChoiceData;
 import org.openstreetmap.josm.io.session.SessionReader.SessionViewportData;
+import org.openstreetmap.josm.io.session.SessionWriter;
 import org.openstreetmap.josm.tools.CheckParameterUtil;
 import org.openstreetmap.josm.tools.JosmRuntimeException;
 import org.openstreetmap.josm.tools.Logging;
@@ -205,7 +207,14 @@
                     postLoadTasks = reader.getPostLoadTasks();
                     viewport = reader.getViewport();
                     projectionChoice = reader.getProjectionChoice();
-                    SessionSaveAction.setCurrentSession(file, zip, reader.getLayers());
+                    final EnumSet<SessionWriter.SessionWriterFlags> flagSet = EnumSet.noneOf(SessionWriter.SessionWriterFlags.class);
+                    if (zip) {
+                        flagSet.add(SessionWriter.SessionWriterFlags.IS_ZIP);
+                    }
+                    if (reader.loadedPluginData()) {
+                        flagSet.add(SessionWriter.SessionWriterFlags.SAVE_PLUGIN_INFORMATION);
+                    }
+                    SessionSaveAction.setCurrentSession(file, reader.getLayers(), flagSet);
                 } finally {
                     if (tempFile) {
                         Utils.deleteFile(file);
Index: src/org/openstreetmap/josm/actions/SessionSaveAction.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/actions/SessionSaveAction.java b/src/org/openstreetmap/josm/actions/SessionSaveAction.java
--- a/src/org/openstreetmap/josm/actions/SessionSaveAction.java	(revision 18807)
+++ b/src/org/openstreetmap/josm/actions/SessionSaveAction.java	(date 1692108329721)
@@ -17,6 +17,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -56,8 +57,10 @@
 import org.openstreetmap.josm.gui.util.GuiHelper;
 import org.openstreetmap.josm.gui.util.WindowGeometry;
 import org.openstreetmap.josm.gui.widgets.AbstractFileChooser;
+import org.openstreetmap.josm.io.session.PluginSessionExporter;
 import org.openstreetmap.josm.io.session.SessionLayerExporter;
 import org.openstreetmap.josm.io.session.SessionWriter;
+import org.openstreetmap.josm.plugins.PluginHandler;
 import org.openstreetmap.josm.spi.preferences.Config;
 import org.openstreetmap.josm.tools.GBC;
 import org.openstreetmap.josm.tools.JosmRuntimeException;
@@ -78,6 +81,7 @@
     private transient MultiMap<Layer, Layer> dependencies;
 
     private static final BooleanProperty SAVE_LOCAL_FILES_PROPERTY = new BooleanProperty("session.savelocal", true);
+    private static final BooleanProperty SAVE_PLUGIN_INFORMATION_PROPERTY = new BooleanProperty("session.saveplugins", false);
     private static final String TOOLTIP_DEFAULT = tr("Save the current session.");
 
     protected transient FileFilter joz = new ExtensionFileFilter("joz", "joz", tr("Session file (archive) (*.joz)"));
@@ -88,6 +92,7 @@
     private static String tooltip = TOOLTIP_DEFAULT;
     static File sessionFile;
     static boolean isZipSessionFile;
+    private static boolean pluginData;
     static List<WeakReference<Layer>> layersInSessionFile;
 
     private static final SessionSaveAction instance = new SessionSaveAction();
@@ -170,7 +175,7 @@
                 .collect(Collectors.toList());
 
         boolean zipRequired = layersOut.stream().map(l -> exporters.get(l))
-                .anyMatch(ex -> ex != null && ex.requiresZip());
+                .anyMatch(ex -> ex != null && ex.requiresZip()) || pluginsWantToSave();
 
         saveAs = !doGetFile(saveAs, zipRequired);
 
@@ -240,7 +245,14 @@
             active = layersOut.indexOf(activeLayer);
         }
 
-        SessionWriter sw = new SessionWriter(layersOut, active, exporters, dependencies, isZipSessionFile);
+        final EnumSet<SessionWriter.SessionWriterFlags> flags = EnumSet.noneOf(SessionWriter.SessionWriterFlags.class);
+        if (pluginData || Boolean.TRUE.equals(SAVE_PLUGIN_INFORMATION_PROPERTY.get()) && pluginsWantToSave()) {
+            flags.add(SessionWriter.SessionWriterFlags.SAVE_PLUGIN_INFORMATION);
+        }
+        if (isZipSessionFile) {
+            flags.add(SessionWriter.SessionWriterFlags.IS_ZIP);
+        }
+        SessionWriter sw = new SessionWriter(layersOut, active, exporters, dependencies, flags.toArray(new SessionWriter.SessionWriterFlags[0]));
         try {
             Notification savingNotification = showSavingNotification(sessionFile.getName());
             sw.write(sessionFile);
@@ -434,7 +446,13 @@
             op.add(tabs, GBC.eol().fill());
             JCheckBox chkSaveLocal = new JCheckBox(tr("Save all local files to disk"), SAVE_LOCAL_FILES_PROPERTY.get());
             chkSaveLocal.addChangeListener(l -> SAVE_LOCAL_FILES_PROPERTY.put(chkSaveLocal.isSelected()));
-            op.add(chkSaveLocal);
+            op.add(chkSaveLocal, GBC.eol());
+            if (pluginsWantToSave()) {
+                JCheckBox chkSavePlugins = new JCheckBox(tr("Save plugin information to disk"), SAVE_PLUGIN_INFORMATION_PROPERTY.get());
+                chkSavePlugins.addChangeListener(l -> SAVE_PLUGIN_INFORMATION_PROPERTY.put(chkSavePlugins.isSelected()));
+                chkSavePlugins.setToolTipText(tr("Plugins may have additional information that can be saved"));
+                op.add(chkSavePlugins, GBC.eol());
+            }
             return op;
         }
 
@@ -509,10 +527,41 @@
      * @param file file
      * @param zip if it is a zip session file
      * @param layers layers that are currently represented in the session file
+     * @deprecated since xxx, use {@link #setCurrentSession(File, List, SessionWriter.SessionWriterFlags...)} instead
      */
+    @Deprecated
     public static void setCurrentSession(File file, boolean zip, List<Layer> layers) {
+        if (zip) {
+            setCurrentSession(file, layers, SessionWriter.SessionWriterFlags.IS_ZIP);
+        } else {
+            setCurrentSession(file, layers);
+        }
+    }
+
+    /**
+     * Sets the current session file and the layers included in that file
+     * @param file file
+     * @param layers layers that are currently represented in the session file
+     * @param flags The flags for the current session
+     * @since xxx
+     */
+    public static void setCurrentSession(File file, List<Layer> layers, SessionWriter.SessionWriterFlags... flags) {
+        final EnumSet<SessionWriter.SessionWriterFlags> flagSet = EnumSet.noneOf(SessionWriter.SessionWriterFlags.class);
+        flagSet.addAll(Arrays.asList(flags));
+        setCurrentSession(file, layers, flagSet);
+    }
+
+    /**
+     * Sets the current session file and the layers included in that file
+     * @param file file
+     * @param layers layers that are currently represented in the session file
+     * @param flags The flags for the current session
+     * @since xxx
+     */
+    public static void setCurrentSession(File file, List<Layer> layers, Set<SessionWriter.SessionWriterFlags> flags) {
         setCurrentLayers(layers);
-        setCurrentSession(file, zip);
+        setCurrentSession(file, flags.contains(SessionWriter.SessionWriterFlags.IS_ZIP));
+        pluginData = flags.contains(SessionWriter.SessionWriterFlags.SAVE_PLUGIN_INFORMATION);
     }
 
     /**
@@ -550,4 +599,17 @@
         return tooltip;
     }
 
+    /**
+     * Check to see if any plugins want to save their state
+     * @return {@code true} if the plugin wants to save their state
+     */
+    private static boolean pluginsWantToSave() {
+        for (PluginSessionExporter exporter : PluginHandler.load(PluginSessionExporter.class)) {
+            if (exporter.requiresSaving()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
 }
Index: src/org/openstreetmap/josm/io/session/GenericSessionExporter.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/io/session/GenericSessionExporter.java b/src/org/openstreetmap/josm/io/session/GenericSessionExporter.java
--- a/src/org/openstreetmap/josm/io/session/GenericSessionExporter.java	(revision 18807)
+++ b/src/org/openstreetmap/josm/io/session/GenericSessionExporter.java	(date 1692042246262)
@@ -31,6 +31,7 @@
 import org.openstreetmap.josm.gui.util.GuiHelper;
 import org.openstreetmap.josm.gui.widgets.JosmTextField;
 import org.openstreetmap.josm.io.session.SessionWriter.ExportSupport;
+import org.openstreetmap.josm.plugins.PluginHandler;
 import org.openstreetmap.josm.tools.GBC;
 import org.openstreetmap.josm.tools.ImageProvider;
 import org.w3c.dom.Element;
@@ -210,7 +211,15 @@
 
     @Override
     public boolean requiresZip() {
-        return include.isSelected();
+        if (include.isSelected()) {
+            return true;
+        }
+        for (PluginSessionExporter exporter : PluginHandler.load(PluginSessionExporter.class)) {
+            if (exporter.requiresSaving()) {
+                return true;
+            }
+        }
+        return false;
     }
 
     protected abstract void addDataFile(OutputStream out) throws IOException;
Index: src/org/openstreetmap/josm/io/session/PluginSessionExporter.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/io/session/PluginSessionExporter.java b/src/org/openstreetmap/josm/io/session/PluginSessionExporter.java
new file mode 100644
--- /dev/null	(date 1692106166477)
+++ b/src/org/openstreetmap/josm/io/session/PluginSessionExporter.java	(date 1692106166477)
@@ -0,0 +1,47 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.io.session;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipException;
+import java.util.zip.ZipOutputStream;
+
+/**
+ * Export arbitrary data from a plugin.
+ * @since xxx
+ */
+public interface PluginSessionExporter {
+    /**
+     * Get the filename to store the data in the archive
+     * @return The filename
+     * @see PluginSessionImporter#getFileName()
+     */
+    String getFileName();
+
+    /**
+     * Check to see if the specified exporter needs to save anything
+     * @return {@code true} if the exporter needs to save something
+     */
+    boolean requiresSaving();
+
+    /**
+     * Write data to a zip file
+     * @param zipOut The zip output stream
+     * @throws IOException see {@link ZipOutputStream#putNextEntry(ZipEntry)}
+     * @throws ZipException see {@link ZipOutputStream#putNextEntry(ZipEntry)}
+     */
+    default void writeZipEntries(ZipOutputStream zipOut) throws IOException {
+        if (requiresSaving()) {
+            final ZipEntry zipEntry = new ZipEntry(this.getFileName());
+            zipOut.putNextEntry(zipEntry);
+            this.write(zipOut);
+        }
+    }
+
+    /**
+     * Write the plugin data to a stream
+     * @param outputStream The stream to write to
+     */
+    void write(OutputStream outputStream);
+}
Index: src/org/openstreetmap/josm/io/session/PluginSessionImporter.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/io/session/PluginSessionImporter.java b/src/org/openstreetmap/josm/io/session/PluginSessionImporter.java
new file mode 100644
--- /dev/null	(date 1692107921803)
+++ b/src/org/openstreetmap/josm/io/session/PluginSessionImporter.java	(date 1692107921803)
@@ -0,0 +1,43 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.io.session;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+/**
+ * Import arbitrary data for a plugin.
+ * @since xxx
+ */
+public interface PluginSessionImporter {
+    /**
+     * Get the filename that was used to store data in the archive.
+     * @return The filename
+     * @see PluginSessionExporter#getFileName()
+     */
+    String getFileName();
+
+    /**
+     * Read data from a file stream
+     * @param inputStream The stream to read
+     * @return {@code true} if the importer loaded data
+     */
+    boolean read(InputStream inputStream);
+
+    /**
+     * Read the data from a zip file
+     * @param zipFile The zipfile to read
+     * @return {@code true} if the importer loaded data
+     * @throws IOException if there was an issue reading the zip file. See {@link ZipFile#getInputStream(ZipEntry)}.
+     */
+    default boolean readZipFile(ZipFile zipFile) throws IOException {
+        final ZipEntry entry = zipFile.getEntry(this.getFileName());
+        if (entry != null) {
+            try (InputStream inputStream = zipFile.getInputStream(entry)) {
+                return this.read(inputStream);
+            }
+        }
+        return false;
+    }
+}
Index: src/org/openstreetmap/josm/io/session/SessionReader.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/io/session/SessionReader.java b/src/org/openstreetmap/josm/io/session/SessionReader.java
--- a/src/org/openstreetmap/josm/io/session/SessionReader.java	(revision 18807)
+++ b/src/org/openstreetmap/josm/io/session/SessionReader.java	(date 1692109489728)
@@ -39,13 +39,16 @@
 import org.openstreetmap.josm.data.coor.ILatLon;
 import org.openstreetmap.josm.data.coor.LatLon;
 import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.gui.ExceptionDialogUtil;
 import org.openstreetmap.josm.gui.ExtendedDialog;
 import org.openstreetmap.josm.gui.MainApplication;
 import org.openstreetmap.josm.gui.layer.Layer;
 import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
 import org.openstreetmap.josm.gui.progress.ProgressMonitor;
+import org.openstreetmap.josm.gui.util.GuiHelper;
 import org.openstreetmap.josm.io.Compression;
 import org.openstreetmap.josm.io.IllegalDataException;
+import org.openstreetmap.josm.plugins.PluginHandler;
 import org.openstreetmap.josm.tools.CheckParameterUtil;
 import org.openstreetmap.josm.tools.JosmRuntimeException;
 import org.openstreetmap.josm.tools.Logging;
@@ -156,6 +159,7 @@
 
     private URI sessionFileURI;
     private boolean zip; // true, if session file is a .joz file; false if it is a .jos file
+    private boolean pluginData; // true, if a plugin restored state from a .joz file. False otherwise.
     private ZipFile zipFile;
     private List<Layer> layers = new ArrayList<>();
     private int active = -1;
@@ -192,7 +196,7 @@
         Class<? extends SessionLayerImporter> importerClass = sessionLayerImporters.get(layerType);
         if (importerClass == null)
             return null;
-        SessionLayerImporter importer = null;
+        SessionLayerImporter importer;
         try {
             importer = importerClass.getConstructor().newInstance();
         } catch (ReflectiveOperationException e) {
@@ -243,6 +247,15 @@
         return projectionChoice;
     }
 
+    /**
+     * Returns whether plugins loaded additonal data
+     * @return {@code true} if at least one plugin loaded additional data
+     * @since xxx
+     */
+    public boolean loadedPluginData() {
+        return this.pluginData;
+    }
+
     /**
      * A class that provides some context for the individual {@link SessionLayerImporter}
      * when doing the import.
@@ -308,9 +321,9 @@
 
         /**
          * Return an InputStream for a URI from a .jos/.joz file.
-         *
+         * <p>
          * The following forms are supported:
-         *
+         * <p>
          * - absolute file (both .jos and .joz):
          *         "file:///home/user/data.osm"
          *         "file:/home/user/data.osm"
@@ -351,7 +364,7 @@
 
         /**
          * Return a File for a URI from a .jos/.joz file.
-         *
+         * <p>
          * Returns null if the URI points to a file inside the zip archive.
          * In this case, inZipPath will be set to the corresponding path.
          * @param uriStr the URI as string
@@ -712,7 +725,7 @@
     /**
      * Show Dialog when there is an error for one layer.
      * Ask the user whether to cancel the complete session loading or just to skip this layer.
-     *
+     * <p>
      * This is expected to run in a worker thread (PleaseWaitRunnable), so invokeAndWait is
      * needed to block the current thread and wait for the result of the modal dialog from EDT.
      */
@@ -742,6 +755,19 @@
         }
     }
 
+    private void loadPluginData() {
+        if (!zip) {
+            return;
+        }
+        for (PluginSessionImporter importer : PluginHandler.load(PluginSessionImporter.class)) {
+            try {
+                this.pluginData |= importer.readZipFile(zipFile);
+            } catch (IOException ioException) {
+                GuiHelper.runInEDT(() -> ExceptionDialogUtil.explainException(ioException));
+            }
+        }
+    }
+
     /**
      * Loads session from the given file.
      * @param sessionFile session file to load
@@ -753,6 +779,7 @@
     public void loadSession(File sessionFile, boolean zip, ProgressMonitor progressMonitor) throws IllegalDataException, IOException {
         try (InputStream josIS = createInputStream(sessionFile, zip)) {
             loadSession(josIS, sessionFile.toURI(), zip, progressMonitor);
+            this.postLoadTasks.add(this::loadPluginData);
         }
     }
 
Index: src/org/openstreetmap/josm/io/session/SessionWriter.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/io/session/SessionWriter.java b/src/org/openstreetmap/josm/io/session/SessionWriter.java
--- a/src/org/openstreetmap/josm/io/session/SessionWriter.java	(revision 18807)
+++ b/src/org/openstreetmap/josm/io/session/SessionWriter.java	(date 1692106178190)
@@ -9,6 +9,7 @@
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
@@ -43,6 +44,7 @@
 import org.openstreetmap.josm.gui.layer.geoimage.GeoImageLayer;
 import org.openstreetmap.josm.gui.layer.markerlayer.MarkerLayer;
 import org.openstreetmap.josm.gui.preferences.projection.ProjectionPreference;
+import org.openstreetmap.josm.plugins.PluginHandler;
 import org.openstreetmap.josm.tools.JosmRuntimeException;
 import org.openstreetmap.josm.tools.Logging;
 import org.openstreetmap.josm.tools.MultiMap;
@@ -58,6 +60,21 @@
  */
 public class SessionWriter {
 
+    /**
+     * {@link SessionWriter} options
+     * @since xxx
+     */
+    public enum SessionWriterFlags {
+        /**
+         * Use if the file to be written needs to be a zip file
+         */
+        IS_ZIP,
+        /**
+         * Use if there are plugins that want to save information
+         */
+        SAVE_PLUGIN_INFORMATION
+    }
+
     private static final Map<Class<? extends Layer>, Class<? extends SessionLayerExporter>> sessionLayerExporters = new HashMap<>();
 
     private final List<Layer> layers;
@@ -65,6 +82,7 @@
     private final Map<Layer, SessionLayerExporter> exporters;
     private final MultiMap<Layer, Layer> dependencies;
     private final boolean zip;
+    private final boolean plugins;
 
     private ZipOutputStream zipOut;
 
@@ -82,7 +100,7 @@
 
     /**
      * Register a session layer exporter.
-     *
+     * <p>
      * The exporter class must have a one-argument constructor with layerClass as formal parameter type.
      * @param layerClass layer class
      * @param exporter exporter for this layer class
@@ -120,11 +138,29 @@
      */
     public SessionWriter(List<Layer> layers, int active, Map<Layer, SessionLayerExporter> exporters,
                 MultiMap<Layer, Layer> dependencies, boolean zip) {
+        this(layers, active, exporters, dependencies,
+                zip ? new SessionWriterFlags[] {SessionWriterFlags.IS_ZIP} : new SessionWriterFlags[0]);
+    }
+
+    /**
+     * Constructs a new {@code SessionWriter}.
+     * @param layers The ordered list of layers to save
+     * @param active The index of active layer in {@code layers} (starts at 0). Ignored if set to -1
+     * @param exporters The exporters to use to save layers
+     * @param dependencies layer dependencies
+     * @param flags The flags to use when writing data
+     * @since xxx
+     */
+    public SessionWriter(List<Layer> layers, int active, Map<Layer, SessionLayerExporter> exporters,
+                         MultiMap<Layer, Layer> dependencies, SessionWriterFlags... flags) {
         this.layers = layers;
         this.active = active;
         this.exporters = exporters;
         this.dependencies = dependencies;
-        this.zip = zip;
+        final EnumSet<SessionWriterFlags> flagSet = flags.length == 0 ? EnumSet.noneOf(SessionWriterFlags.class) :
+                EnumSet.of(flags[0], flags);
+        this.zip = flagSet.contains(SessionWriterFlags.IS_ZIP);
+        this.plugins = flagSet.contains(SessionWriterFlags.SAVE_PLUGIN_INFORMATION);
     }
 
     /**
@@ -218,7 +254,7 @@
      * @throws IOException if any I/O error occurs
      */
     public Document createJosDocument() throws IOException {
-        DocumentBuilder builder = null;
+        DocumentBuilder builder;
         try {
             builder = XmlUtils.newSafeDOMBuilder();
         } catch (ParserConfigurationException e) {
@@ -361,6 +397,11 @@
             ZipEntry entry = new ZipEntry("session.jos");
             zipOut.putNextEntry(entry);
             writeJos(doc, zipOut);
+            if (this.plugins) {
+                for (PluginSessionExporter exporter : PluginHandler.load(PluginSessionExporter.class)) {
+                    exporter.writeZipEntries(zipOut);
+                }
+            }
             Utils.close(zipOut);
         } else {
             writeJos(doc, new BufferedOutputStream(out));
Index: src/org/openstreetmap/josm/plugins/PluginHandler.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/plugins/PluginHandler.java b/src/org/openstreetmap/josm/plugins/PluginHandler.java
--- a/src/org/openstreetmap/josm/plugins/PluginHandler.java	(revision 18807)
+++ b/src/org/openstreetmap/josm/plugins/PluginHandler.java	(date 1692103515015)
@@ -32,6 +32,7 @@
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Objects;
+import java.util.ServiceLoader;
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.TreeSet;
@@ -357,6 +358,18 @@
         return Collections.unmodifiableCollection(classLoaders.values());
     }
 
+    /**
+     * Get a {@link ServiceLoader} for the specified service. This uses {@link #getJoinedPluginResourceCL()} as the
+     * class loader, so that we don't have to iterate through the {@link ClassLoader}s from {@link #getPluginClassLoaders()}.
+     * @param <S> The service type
+     * @param service The service class to look for
+     * @return The service loader
+     * @since xxx
+     */
+    public static <S> ServiceLoader<S> load(Class<S> service) {
+        return ServiceLoader.load(service, getJoinedPluginResourceCL());
+    }
+
     /**
      * Removes deprecated plugins from a collection of plugins. Modifies the
      * collection <code>plugins</code>.
