Index: scripts/SyncEditorLayerIndex.java
===================================================================
--- scripts/SyncEditorLayerIndex.java	(revision 16248)
+++ scripts/SyncEditorLayerIndex.java	(working copy)
@@ -1367,7 +1367,7 @@
     }
 
     static String getType(Object e) {
-        if (e instanceof ImageryInfo) return ((ImageryInfo) e).getImageryType().getTypeString();
+        if (e instanceof ImageryInfo) return ((ImageryInfo) e).getSourceType().getTypeString();
         return ((Map<String, JsonObject>) e).get("properties").getString("type");
     }
 
@@ -1445,7 +1445,7 @@
 
     static String getCategory(Object e) {
         if (e instanceof ImageryInfo) {
-            return ((ImageryInfo) e).getImageryCategoryOriginalString();
+            return ((ImageryInfo) e).getSourceCategoryOriginalString();
         }
         return ((Map<String, JsonObject>) e).get("properties").getString("category", null);
     }
Index: src/org/openstreetmap/josm/actions/AddImageryLayerAction.java
===================================================================
--- src/org/openstreetmap/josm/actions/AddImageryLayerAction.java	(revision 16248)
+++ src/org/openstreetmap/josm/actions/AddImageryLayerAction.java	(working copy)
@@ -109,7 +109,7 @@
                 info.setDate(userDate);
                 // TODO persist new {time} value (via ImageryLayerInfo.save?)
             }
-            switch(info.getImageryType()) {
+            switch(info.getSourceType()) {
             case WMS_ENDPOINT:
                 // convert to WMS type
                 if (info.getDefaultLayers() == null || info.getDefaultLayers().isEmpty()) {
@@ -263,7 +263,7 @@
      */
     public static ImageryInfo getWMSLayerInfo(ImageryInfo info, Function<WMSImagery, LayerSelection> choice)
             throws IOException, WMSGetCapabilitiesException {
-        CheckParameterUtil.ensureThat(ImageryType.WMS_ENDPOINT == info.getImageryType(), "wms_endpoint imagery type expected");
+        CheckParameterUtil.ensureThat(ImageryType.WMS_ENDPOINT == info.getSourceType(), "wms_endpoint imagery type expected");
         final WMSImagery wms = new WMSImagery(info.getUrl(), info.getCustomHttpHeaders());
         LayerSelection selection = choice.apply(wms);
         if (selection == null) {
@@ -283,7 +283,7 @@
         // Use full copy of original Imagery info to copy all attributes. Only overwrite what's different
         ImageryInfo ret = new ImageryInfo(info);
         ret.setUrl(url);
-        ret.setImageryType(ImageryType.WMS);
+        ret.setSourceType(ImageryType.WMS);
         ret.setName(info.getName() + " - " + selectedLayers);
         ret.setServerProjections(wms.getServerProjections(selection.layers));
         return ret;
Index: src/org/openstreetmap/josm/actions/MapRectifierWMSmenuAction.java
===================================================================
--- src/org/openstreetmap/josm/actions/MapRectifierWMSmenuAction.java	(revision 16248)
+++ src/org/openstreetmap/josm/actions/MapRectifierWMSmenuAction.java	(working copy)
@@ -238,7 +238,7 @@
      */
     private static void addWMSLayer(String title, String url) {
         ImageryInfo info = new ImageryInfo(title, url);
-        if (info.getImageryType() == ImageryType.WMS_ENDPOINT) {
+        if (info.getSourceType() == ImageryType.WMS_ENDPOINT) {
             try {
                 info = AddImageryLayerAction.getWMSLayerInfo(info);
             } catch (IOException | WMSGetCapabilitiesException e) {
Index: src/org/openstreetmap/josm/data/StructUtils.java
===================================================================
--- src/org/openstreetmap/josm/data/StructUtils.java	(revision 16248)
+++ src/org/openstreetmap/josm/data/StructUtils.java	(working copy)
@@ -156,7 +156,7 @@
         }
 
         HashMap<String, String> hash = new LinkedHashMap<>();
-        for (Field f : klass.getDeclaredFields()) {
+        for (Field f : getDeclaredFieldsInClassOrSuperTypes(klass)) {
             if (f.getAnnotation(StructEntry.class) == null) {
                 continue;
             }
@@ -205,16 +205,11 @@
         }
         for (Map.Entry<String, String> keyValue : hash.entrySet()) {
             Object value;
-            Field f;
-            try {
-                f = klass.getDeclaredField(keyValue.getKey().replace('-', '_'));
-            } catch (NoSuchFieldException ex) {
-                Logging.trace(ex);
+            Field f = getDeclaredFieldInClassOrSuperTypes(klass, keyValue.getKey().replace('-', '_'));
+
+            if (f == null || f.getAnnotation(StructEntry.class) == null) {
                 continue;
             }
-            if (f.getAnnotation(StructEntry.class) == null) {
-                continue;
-            }
             ReflectionUtils.setObjectsAccessible(f);
             if (f.getType() == Boolean.class || f.getType() == boolean.class) {
                 value = Boolean.valueOf(keyValue.getValue());
@@ -250,6 +245,30 @@
         return struct;
     }
 
+    private static <T> Field getDeclaredFieldInClassOrSuperTypes(Class<T> clazz, String fieldName) {
+        Class<?> tClass = clazz;
+        do {
+            try {
+                Field f = tClass.getDeclaredField(fieldName);
+                return f;
+            } catch (NoSuchFieldException ex) {
+                Logging.trace(ex);
+            }
+            tClass = tClass.getSuperclass();
+        } while (tClass != null);
+        return null;
+    }
+
+    private static <T> Field[] getDeclaredFieldsInClassOrSuperTypes(Class<T> clazz) {
+        List<Field> fields = new ArrayList<>();
+        Class<?> tclass = clazz;
+        do {
+            Collections.addAll(fields, tclass.getDeclaredFields());
+            tclass = tclass.getSuperclass();
+        } while (tclass != null);
+        return fields.toArray(new Field[] {});
+    }
+
     @SuppressWarnings("rawtypes")
     private static String mapToJson(Map map) {
         StringWriter stringWriter = new StringWriter();
Index: src/org/openstreetmap/josm/data/imagery/ImageryInfo.java
===================================================================
--- src/org/openstreetmap/josm/data/imagery/ImageryInfo.java	(revision 16248)
+++ src/org/openstreetmap/josm/data/imagery/ImageryInfo.java	(working copy)
@@ -3,7 +3,6 @@
 
 import static org.openstreetmap.josm.tools.I18n.tr;
 
-import java.awt.Image;
 import java.io.StringReader;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -10,14 +9,11 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumMap;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.Set;
-import java.util.TreeSet;
 import java.util.concurrent.TimeUnit;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -26,25 +22,17 @@
 import javax.json.Json;
 import javax.json.JsonObject;
 import javax.json.JsonReader;
-import javax.json.stream.JsonCollectors;
 import javax.swing.ImageIcon;
 
-import org.openstreetmap.gui.jmapviewer.interfaces.Attributed;
-import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
-import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTileSource;
-import org.openstreetmap.gui.jmapviewer.tilesources.OsmTileSource.Mapnik;
-import org.openstreetmap.gui.jmapviewer.tilesources.TileSourceInfo;
-import org.openstreetmap.josm.data.Bounds;
-import org.openstreetmap.josm.data.StructUtils;
 import org.openstreetmap.josm.data.StructUtils.StructEntry;
-import org.openstreetmap.josm.io.Capabilities;
-import org.openstreetmap.josm.io.OsmApi;
-import org.openstreetmap.josm.spi.preferences.Config;
-import org.openstreetmap.josm.spi.preferences.IPreferences;
+import org.openstreetmap.josm.data.sources.ISourceCategory;
+import org.openstreetmap.josm.data.sources.ISourceType;
+import org.openstreetmap.josm.data.sources.SourceBounds;
+import org.openstreetmap.josm.data.sources.SourceInfo;
+import org.openstreetmap.josm.data.sources.SourcePreferenceEntry;
 import org.openstreetmap.josm.tools.CheckParameterUtil;
 import org.openstreetmap.josm.tools.ImageProvider;
 import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
-import org.openstreetmap.josm.tools.LanguageInfo;
 import org.openstreetmap.josm.tools.Logging;
 import org.openstreetmap.josm.tools.MultiMap;
 import org.openstreetmap.josm.tools.Utils;
@@ -54,12 +42,12 @@
  *
  * @author Frederik Ramm
  */
-public class ImageryInfo extends TileSourceInfo implements Comparable<ImageryInfo>, Attributed {
+public class ImageryInfo extends SourceInfo<ImageryInfo.ImageryCategory, ImageryInfo.ImageryType, ImageryInfo.ImageryBounds, ImageryInfo.ImageryPreferenceEntry> {
 
     /**
      * Type of imagery entry.
      */
-    public enum ImageryType {
+    public enum ImageryType implements ISourceType<ImageryType> {
         /** A WMS (Web Map Service) entry. **/
         WMS("wms"),
         /** A TMS (Tile Map Service) entry. **/
@@ -84,6 +72,7 @@
          * @return the unique string identifying this type
          * @since 6690
          */
+        @Override
         public final String getTypeString() {
             return typeString;
         }
@@ -93,7 +82,7 @@
          * @param s The type string
          * @return the imagery type matching the given type string
          */
-        public static ImageryType fromString(String s) {
+        public static ImageryType getFromString(String s) {
             for (ImageryType type : ImageryType.values()) {
                 if (type.getTypeString().equals(s)) {
                     return type;
@@ -101,6 +90,16 @@
             }
             return null;
         }
+
+        @Override
+        public ImageryType fromString(String s) {
+            return getFromString(s);
+        }
+
+        @Override
+        public ImageryType getDefault() {
+            return WMS;
+        }
     }
 
     /**
@@ -107,7 +106,7 @@
      * Category of imagery entry.
      * @since 13792
      */
-    public enum ImageryCategory {
+    public enum ImageryCategory implements ISourceCategory<ImageryCategory> {
         /** A aerial or satellite photo. **/
         PHOTO(/* ICON(data/imagery/) */ "photo", tr("Aerial or satellite photo")),
         /** A map of digital terrain model, digital surface model or contour lines. **/
@@ -139,6 +138,7 @@
          * Returns the unique string identifying this category.
          * @return the unique string identifying this category
          */
+        @Override
         public final String getCategoryString() {
             return category;
         }
@@ -147,6 +147,7 @@
          * Returns the description of this category.
          * @return the description of this category
          */
+        @Override
         public final String getDescription() {
             return description;
         }
@@ -157,6 +158,7 @@
          * @return the category icon at the given size
          * @since 15049
          */
+        @Override
         public final ImageIcon getIcon(ImageSizes size) {
             return iconCache
                     .computeIfAbsent(size, x -> Collections.synchronizedMap(new EnumMap<>(ImageryCategory.class)))
@@ -168,7 +170,7 @@
          * @param s The category string
          * @return the imagery category matching the given category string
          */
-        public static ImageryCategory fromString(String s) {
+        public static ImageryCategory getFromString(String s) {
             for (ImageryCategory category : ImageryCategory.values()) {
                 if (category.getCategoryString().equals(s)) {
                     return category;
@@ -176,6 +178,16 @@
             }
             return null;
         }
+
+        @Override
+        public ImageryCategory getDefault() {
+            return OTHER;
+        }
+
+        @Override
+        public ImageryCategory fromString(String s) {
+            return getFromString(s);
+        }
     }
 
     /**
@@ -182,7 +194,7 @@
      * Multi-polygon bounds for imagery backgrounds.
      * Used to display imagery coverage in preferences and to determine relevant imagery entries based on edit location.
      */
-    public static class ImageryBounds extends Bounds {
+    public static class ImageryBounds extends SourceBounds {
 
         /**
          * Constructs a new {@code ImageryBounds} from string.
@@ -192,98 +204,16 @@
         public ImageryBounds(String asString, String separator) {
             super(asString, separator);
         }
-
-        private List<Shape> shapes = new ArrayList<>();
-
-        /**
-         * Adds a new shape to this bounds.
-         * @param shape The shape to add
-         */
-        public final void addShape(Shape shape) {
-            this.shapes.add(shape);
-        }
-
-        /**
-         * Sets the list of shapes defining this bounds.
-         * @param shapes The list of shapes defining this bounds.
-         */
-        public final void setShapes(List<Shape> shapes) {
-            this.shapes = shapes;
-        }
-
-        /**
-         * Returns the list of shapes defining this bounds.
-         * @return The list of shapes defining this bounds
-         */
-        public final List<Shape> getShapes() {
-            return shapes;
-        }
-
-        @Override
-        public int hashCode() {
-            return Objects.hash(super.hashCode(), shapes);
-        }
-
-        @Override
-        public boolean equals(Object o) {
-            if (this == o) return true;
-            if (o == null || getClass() != o.getClass()) return false;
-            if (!super.equals(o)) return false;
-            ImageryBounds that = (ImageryBounds) o;
-            return Objects.equals(shapes, that.shapes);
-        }
     }
 
-    /** original name of the imagery entry in case of translation call, for multiple languages English when possible */
-    private String origName;
-    /** (original) language of the translated name entry */
-    private String langName;
-    /** whether this is a entry activated by default or not */
-    private boolean defaultEntry;
-    /** Whether this service requires a explicit EULA acceptance before it can be activated */
-    private String eulaAcceptanceRequired;
-    /** type of the imagery servics - WMS, TMS, ... */
-    private ImageryType imageryType = ImageryType.WMS;
     private double pixelPerDegree;
     /** maximum zoom level for TMS imagery */
     private int defaultMaxZoom;
     /** minimum zoom level for TMS imagery */
     private int defaultMinZoom;
-    /** display bounds of imagery, displayed in prefs and used for automatic imagery selection */
-    private ImageryBounds bounds;
     /** projections supported by WMS servers */
     private List<String> serverProjections = Collections.emptyList();
-    /** description of the imagery entry, should contain notes what type of data it is */
-    private String description;
-    /** language of the description entry */
-    private String langDescription;
-    /** Text of a text attribution displayed when using the imagery */
-    private String attributionText;
-    /** Link to the privacy policy of the operator */
-    private String privacyPolicyURL;
-    /** Link to a reference stating the permission for OSM usage */
-    private String permissionReferenceURL;
-    /** Link behind the text attribution displayed when using the imagery */
-    private String attributionLinkURL;
-    /** Image of a graphical attribution displayed when using the imagery */
-    private String attributionImage;
-    /** Link behind the graphical attribution displayed when using the imagery */
-    private String attributionImageURL;
-    /** Text with usage terms displayed when using the imagery */
-    private String termsOfUseText;
-    /** Link behind the text with usage terms displayed when using the imagery */
-    private String termsOfUseURL;
-    /** country code of the imagery (for country specific imagery) */
-    private String countryCode = "";
     /**
-      * creation date of the imagery (in the form YYYY-MM-DD;YYYY-MM-DD, where
-      * DD and MM as well as a second date are optional).
-      *
-      * Also used as time filter for WMS time={time} parameter (such as Sentinel-2)
-      * @since 11570
-      */
-    private String date;
-    /**
       * marked as best in other editors
       * @since 11575
       */
@@ -293,64 +223,29 @@
       * @since 13536
       */
     private boolean overlay;
+
+    /** mirrors of different type for this entry */
+    protected List<ImageryInfo> mirrors;
     /**
-      * list of old IDs, only for loading, not handled anywhere else
-      * @since 13536
-      */
-    private Collection<String> oldIds;
-    /** mirrors of different type for this entry */
-    private List<ImageryInfo> mirrors;
-    /** icon used in menu */
-    private String icon;
+     * Auxiliary class to save an {@link ImageryInfo} object in the preferences.
+     */
     /** is the geo reference correct - don't offer offset handling */
     private boolean isGeoreferenceValid;
-    /** which layers should be activated by default on layer addition. **/
-    private List<DefaultLayer> defaultLayers = Collections.emptyList();
-    /** HTTP headers **/
-    private Map<String, String> customHttpHeaders = Collections.emptyMap();
     /** Should this map be transparent **/
     private boolean transparent = true;
     private int minimumTileExpire = (int) TimeUnit.MILLISECONDS.toSeconds(TMSCachedTileLoaderJob.MINIMUM_EXPIRES.get());
-    /** category of the imagery */
-    private ImageryCategory category;
-    /** category of the imagery (input string, not saved, copied or used otherwise except for error checks) */
-    private String categoryOriginalString;
-    /** when adding a field, also adapt the:
-     * {@link #ImageryPreferenceEntry ImageryPreferenceEntry object}
-     * {@link #ImageryPreferenceEntry#ImageryPreferenceEntry(ImageryInfo) ImageryPreferenceEntry constructor}
-     * {@link #ImageryInfo(ImageryPreferenceEntry) ImageryInfo constructor}
-     * {@link #ImageryInfo(ImageryInfo) ImageryInfo constructor}
-     * {@link #equalsPref(ImageryPreferenceEntry) equalsPref method}
-     **/
 
     /**
-     * Auxiliary class to save an {@link ImageryInfo} object in the preferences.
+     * The ImageryPreferenceEntry class for storing data in JOSM preferences.
+     *
+     * @author Frederik Ramm, modified by Taylor Smock
      */
-    public static class ImageryPreferenceEntry {
-        @StructEntry String name;
+    public static class ImageryPreferenceEntry extends SourcePreferenceEntry<ImageryInfo> {
         @StructEntry String d;
-        @StructEntry String id;
-        @StructEntry String type;
-        @StructEntry String url;
         @StructEntry double pixel_per_eastnorth;
-        @StructEntry String eula;
-        @StructEntry String attribution_text;
-        @StructEntry String attribution_url;
-        @StructEntry String permission_reference_url;
-        @StructEntry String logo_image;
-        @StructEntry String logo_url;
-        @StructEntry String terms_of_use_text;
-        @StructEntry String terms_of_use_url;
-        @StructEntry String country_code = "";
-        @StructEntry String date;
         @StructEntry int max_zoom;
         @StructEntry int min_zoom;
-        @StructEntry String cookies;
-        @StructEntry String bounds;
-        @StructEntry String shapes;
         @StructEntry String projections;
-        @StructEntry String icon;
-        @StructEntry String description;
         @StructEntry MultiMap<String, String> noTileHeaders;
         @StructEntry MultiMap<String, String> noTileChecksums;
         @StructEntry int tileSize = -1;
@@ -359,17 +254,14 @@
         @StructEntry boolean bestMarked;
         @StructEntry boolean modTileFeatures;
         @StructEntry boolean overlay;
-        @StructEntry String default_layers;
-        @StructEntry Map<String, String> customHttpHeaders;
         @StructEntry boolean transparent;
         @StructEntry int minimumTileExpire;
-        @StructEntry String category;
 
         /**
          * Constructs a new empty WMS {@code ImageryPreferenceEntry}.
          */
         public ImageryPreferenceEntry() {
-            // Do nothing
+            super();
         }
 
         /**
@@ -377,42 +269,12 @@
          * @param i The corresponding imagery info
          */
         public ImageryPreferenceEntry(ImageryInfo i) {
-            name = i.name;
-            id = i.id;
-            type = i.imageryType.getTypeString();
-            url = i.url;
+            super(i);
             pixel_per_eastnorth = i.pixelPerDegree;
-            eula = i.eulaAcceptanceRequired;
-            attribution_text = i.attributionText;
-            attribution_url = i.attributionLinkURL;
-            permission_reference_url = i.permissionReferenceURL;
-            date = i.date;
             bestMarked = i.bestMarked;
             overlay = i.overlay;
-            logo_image = i.attributionImage;
-            logo_url = i.attributionImageURL;
-            terms_of_use_text = i.termsOfUseText;
-            terms_of_use_url = i.termsOfUseURL;
-            country_code = i.countryCode;
             max_zoom = i.defaultMaxZoom;
             min_zoom = i.defaultMinZoom;
-            cookies = i.cookies;
-            icon = intern(i.icon);
-            description = i.description;
-            category = i.category != null ? i.category.getCategoryString() : null;
-            if (i.bounds != null) {
-                bounds = i.bounds.encodeAsString(",");
-                StringBuilder shapesString = new StringBuilder();
-                for (Shape s : i.bounds.getShapes()) {
-                    if (shapesString.length() > 0) {
-                        shapesString.append(';');
-                    }
-                    shapesString.append(s.encodeAsString(","));
-                }
-                if (shapesString.length() > 0) {
-                    shapes = shapesString.toString();
-                }
-            }
             if (!i.serverProjections.isEmpty()) {
                 projections = String.join(",", i.serverProjections);
             }
@@ -432,10 +294,6 @@
 
             valid_georeference = i.isGeoreferenceValid();
             modTileFeatures = i.isModTileFeatures();
-            if (!i.defaultLayers.isEmpty()) {
-                default_layers = i.defaultLayers.stream().map(DefaultLayer::toJson).collect(JsonCollectors.toJsonArray()).toString();
-            }
-            customHttpHeaders = i.customHttpHeaders;
             transparent = i.isTransparent();
             minimumTileExpire = i.minimumTileExpire;
         }
@@ -500,11 +358,11 @@
     public ImageryInfo(String name, String url, String type, String eulaAcceptanceRequired, String cookies) {
         this(name);
         setExtendedUrl(url);
-        ImageryType t = ImageryType.fromString(type);
+        ImageryType t = ImageryType.getFromString(type);
         this.cookies = cookies;
         this.eulaAcceptanceRequired = eulaAcceptanceRequired;
         if (t != null) {
-            this.imageryType = t;
+            this.sourceType = t;
         } else if (type != null && !type.isEmpty()) {
             throw new IllegalArgumentException("unknown type: "+type);
         }
@@ -536,8 +394,8 @@
         description = e.description;
         cookies = e.cookies;
         eulaAcceptanceRequired = e.eula;
-        imageryType = ImageryType.fromString(e.type);
-        if (imageryType == null) throw new IllegalArgumentException("unknown type");
+        sourceType = ImageryType.getFromString(e.type);
+        if (sourceType == null) throw new IllegalArgumentException("unknown type");
         pixelPerDegree = e.pixel_per_eastnorth;
         defaultMaxZoom = e.max_zoom;
         defaultMinZoom = e.min_zoom;
@@ -557,7 +415,7 @@
             // split generates null element on empty string which gives one element Array[null]
             setServerProjections(Arrays.asList(e.projections.split(",")));
         }
-        attributionText = intern(e.attribution_text);
+        attributionText = Utils.intern(e.attribution_text);
         attributionLinkURL = e.attribution_url;
         permissionReferenceURL = e.permission_reference_url;
         attributionImage = e.logo_image;
@@ -567,8 +425,8 @@
         overlay = e.overlay;
         termsOfUseText = e.terms_of_use_text;
         termsOfUseURL = e.terms_of_use_url;
-        countryCode = intern(e.country_code);
-        icon = intern(e.icon);
+        countryCode = Utils.intern(e.country_code);
+        icon = Utils.intern(e.icon);
         if (e.noTileHeaders != null) {
             noTileHeaders = e.noTileHeaders.toMap();
         }
@@ -584,7 +442,7 @@
                 defaultLayers = jsonReader.
                         readArray().
                         stream().
-                        map(x -> DefaultLayer.fromJson((JsonObject) x, imageryType)).
+                        map(x -> DefaultLayer.fromJson((JsonObject) x, sourceType)).
                         collect(Collectors.toList());
             }
         }
@@ -591,7 +449,7 @@
         setCustomHttpHeaders(e.customHttpHeaders);
         transparent = e.transparent;
         minimumTileExpire = e.minimumTileExpire;
-        category = ImageryCategory.fromString(e.category);
+        category = ImageryCategory.getFromString(e.category);
     }
 
     /**
@@ -613,7 +471,7 @@
         this.langName = i.langName;
         this.defaultEntry = i.defaultEntry;
         this.eulaAcceptanceRequired = null;
-        this.imageryType = i.imageryType;
+        this.sourceType = i.sourceType;
         this.pixelPerDegree = i.pixelPerDegree;
         this.defaultMaxZoom = i.defaultMaxZoom;
         this.defaultMinZoom = i.defaultMinZoom;
@@ -634,22 +492,79 @@
         this.bestMarked = i.bestMarked;
         this.overlay = i.overlay;
         // do not copy field {@code mirrors}
-        this.icon = intern(i.icon);
+        this.icon = Utils.intern(i.icon);
         this.isGeoreferenceValid = i.isGeoreferenceValid;
         setDefaultLayers(i.defaultLayers);
         setCustomHttpHeaders(i.customHttpHeaders);
         this.transparent = i.transparent;
         this.minimumTileExpire = i.minimumTileExpire;
-        this.categoryOriginalString = intern(i.categoryOriginalString);
+        this.categoryOriginalString = Utils.intern(i.categoryOriginalString);
         this.category = i.category;
     }
 
-    @Override
-    public int hashCode() {
-        return Objects.hash(url, imageryType);
+    /**
+     * Adds a mirror entry. Mirror entries are completed with the data from the master entry
+     * and only describe another method to access identical data.
+     *
+     * @param entry the mirror to be added
+     * @since 9658
+     */
+    public void addMirror(ImageryInfo entry) {
+       if (mirrors == null) {
+           mirrors = new ArrayList<>();
+       }
+       mirrors.add(entry);
     }
 
     /**
+     * Returns the mirror entries. Entries are completed with master entry data.
+     *
+     * @return the list of mirrors
+     * @since 9658
+     */
+    public List<ImageryInfo> getMirrors() {
+       List<ImageryInfo> l = new ArrayList<>();
+       if (mirrors != null) {
+           int num = 1;
+           for (ImageryInfo i : mirrors) {
+               ImageryInfo n = new ImageryInfo(this);
+               if (i.defaultMaxZoom != 0) {
+                   n.defaultMaxZoom = i.defaultMaxZoom;
+               }
+               if (i.defaultMinZoom != 0) {
+                   n.defaultMinZoom = i.defaultMinZoom;
+               }
+               n.setServerProjections(i.getServerProjections());
+               n.url = i.url;
+               n.sourceType = i.sourceType;
+               if (i.getTileSize() != 0) {
+                   n.setTileSize(i.getTileSize());
+               }
+               if (i.getPrivacyPolicyURL() != null) {
+                   n.setPrivacyPolicyURL(i.getPrivacyPolicyURL());
+               }
+               if (n.id != null) {
+                   n.id = n.id + "_mirror"+num;
+               }
+               if (num > 1) {
+                   n.name = tr("{0} mirror server {1}", n.name, num);
+                   if (n.origName != null) {
+                       n.origName += " mirror server " + num;
+                   }
+               } else {
+                   n.name = tr("{0} mirror server", n.name);
+                   if (n.origName != null) {
+                       n.origName += " mirror server";
+                   }
+               }
+               l.add(n);
+               ++num;
+           }
+       }
+       return l;
+    }
+
+    /**
      * Check if this object equals another ImageryInfo with respect to the properties
      * that get written to the preference file.
      *
@@ -658,112 +573,39 @@
      * @param other the ImageryInfo object to compare to
      * @return true if they are equal
      */
-    public boolean equalsPref(ImageryInfo other) {
-        if (other == null) {
+    @Override
+    public boolean equalsPref(SourceInfo<ImageryInfo.ImageryCategory,ImageryInfo.ImageryType,ImageryInfo.ImageryBounds,ImageryInfo.ImageryPreferenceEntry> other) {
+        if (!(other instanceof ImageryInfo)) {
             return false;
         }
+        ImageryInfo realOther = (ImageryInfo) other;
 
         // CHECKSTYLE.OFF: BooleanExpressionComplexity
-        return
-                Objects.equals(this.name, other.name) &&
-                Objects.equals(this.id, other.id) &&
-                Objects.equals(this.url, other.url) &&
-                Objects.equals(this.modTileFeatures, other.modTileFeatures) &&
-                Objects.equals(this.bestMarked, other.bestMarked) &&
-                Objects.equals(this.overlay, other.overlay) &&
-                Objects.equals(this.isGeoreferenceValid, other.isGeoreferenceValid) &&
-                Objects.equals(this.cookies, other.cookies) &&
-                Objects.equals(this.eulaAcceptanceRequired, other.eulaAcceptanceRequired) &&
-                Objects.equals(this.imageryType, other.imageryType) &&
-                Objects.equals(this.defaultMaxZoom, other.defaultMaxZoom) &&
-                Objects.equals(this.defaultMinZoom, other.defaultMinZoom) &&
-                Objects.equals(this.bounds, other.bounds) &&
-                Objects.equals(this.serverProjections, other.serverProjections) &&
-                Objects.equals(this.attributionText, other.attributionText) &&
-                Objects.equals(this.attributionLinkURL, other.attributionLinkURL) &&
-                Objects.equals(this.permissionReferenceURL, other.permissionReferenceURL) &&
-                Objects.equals(this.attributionImageURL, other.attributionImageURL) &&
-                Objects.equals(this.attributionImage, other.attributionImage) &&
-                Objects.equals(this.termsOfUseText, other.termsOfUseText) &&
-                Objects.equals(this.termsOfUseURL, other.termsOfUseURL) &&
-                Objects.equals(this.countryCode, other.countryCode) &&
-                Objects.equals(this.date, other.date) &&
-                Objects.equals(this.icon, other.icon) &&
-                Objects.equals(this.description, other.description) &&
-                Objects.equals(this.noTileHeaders, other.noTileHeaders) &&
-                Objects.equals(this.noTileChecksums, other.noTileChecksums) &&
-                Objects.equals(this.metadataHeaders, other.metadataHeaders) &&
-                Objects.equals(this.defaultLayers, other.defaultLayers) &&
-                Objects.equals(this.customHttpHeaders, other.customHttpHeaders) &&
-                Objects.equals(this.transparent, other.transparent) &&
-                Objects.equals(this.minimumTileExpire, other.minimumTileExpire) &&
-                Objects.equals(this.category, other.category);
+        return super.equalsPref(realOther) &&
+                Objects.equals(this.bestMarked, realOther.bestMarked) &&
+                Objects.equals(this.overlay, realOther.overlay) &&
+                Objects.equals(this.isGeoreferenceValid, realOther.isGeoreferenceValid) &&
+                Objects.equals(this.defaultMaxZoom, realOther.defaultMaxZoom) &&
+                Objects.equals(this.defaultMinZoom, realOther.defaultMinZoom) &&
+                Objects.equals(this.serverProjections, realOther.serverProjections) &&
+                Objects.equals(this.transparent, realOther.transparent) &&
+                Objects.equals(this.minimumTileExpire, realOther.minimumTileExpire);
         // CHECKSTYLE.ON: BooleanExpressionComplexity
     }
 
     @Override
-    public boolean equals(Object o) {
-        if (this == o) return true;
-        if (o == null || getClass() != o.getClass()) return false;
-        ImageryInfo that = (ImageryInfo) o;
-        return imageryType == that.imageryType && Objects.equals(url, that.url);
-    }
-
-    private static final Map<String, String> localizedCountriesCache = new HashMap<>();
-    static {
-        localizedCountriesCache.put("", tr("Worldwide"));
-    }
-
-    /**
-     * Returns a localized name for the given country code, or "Worldwide" if empty.
-     * This function falls back on the English name, and uses the ISO code as a last-resortvalue.
-     *
-     * @param countryCode An ISO 3166 alpha-2 country code or a UN M.49 numeric-3 area code
-     * @return The name of the country appropriate to the current locale.
-     * @see Locale#getDisplayCountry
-     * @since 15158
-     */
-    public static String getLocalizedCountry(String countryCode) {
-        return localizedCountriesCache.computeIfAbsent(countryCode, code -> new Locale("en", code).getDisplayCountry());
-    }
-
-    @Override
-    public String toString() {
-        // Used in imagery preferences filtering, so must be efficient
-        return new StringBuilder(name)
-                .append('[').append(countryCode)
-                // appending the localized country in toString() allows us to filter imagery preferences table with it!
-                .append("] ('").append(getLocalizedCountry(countryCode)).append(')')
-                .append(" - ").append(url)
-                .append(" - ").append(imageryType)
-                .toString();
-    }
-
-    @Override
-    public int compareTo(ImageryInfo in) {
-        int i = countryCode.compareTo(in.countryCode);
-        if (i == 0) {
-            i = name.toLowerCase(Locale.ENGLISH).compareTo(in.name.toLowerCase(Locale.ENGLISH));
+    public int compareTo(SourceInfo<ImageryInfo.ImageryCategory,ImageryInfo.ImageryType,ImageryInfo.ImageryBounds,ImageryInfo.ImageryPreferenceEntry> other) {
+        int i = super.compareTo(other);
+        if (other instanceof ImageryInfo) {
+            ImageryInfo in = (ImageryInfo) other;
+            if (i == 0) {
+                i = Double.compare(pixelPerDegree, in.pixelPerDegree);
+            }
         }
-        if (i == 0) {
-            i = url.compareTo(in.url);
-        }
-        if (i == 0) {
-            i = Double.compare(pixelPerDegree, in.pixelPerDegree);
-        }
         return i;
     }
 
     /**
-     * Determines if URL is equal to given imagery info.
-     * @param in imagery info
-     * @return {@code true} if URL is equal to given imagery info
-     */
-    public boolean equalsBaseValues(ImageryInfo in) {
-        return url.equals(in.url);
-    }
-
-    /**
      * Sets the pixel per degree value.
      * @param ppd The ppd value
      * @see #getPixelPerDegree()
@@ -789,165 +631,6 @@
     }
 
     /**
-     * Sets the imagery polygonial bounds.
-     * @param b The imagery bounds (non-rectangular)
-     */
-    public void setBounds(ImageryBounds b) {
-        this.bounds = b;
-    }
-
-    /**
-     * Returns the imagery polygonial bounds.
-     * @return The imagery bounds (non-rectangular)
-     */
-    public ImageryBounds getBounds() {
-        return bounds;
-    }
-
-    @Override
-    public boolean requiresAttribution() {
-        return attributionText != null || attributionLinkURL != null || attributionImage != null
-                || termsOfUseText != null || termsOfUseURL != null;
-    }
-
-    @Override
-    public String getAttributionText(int zoom, ICoordinate topLeft, ICoordinate botRight) {
-        return attributionText;
-    }
-
-    @Override
-    public String getAttributionLinkURL() {
-        return attributionLinkURL;
-    }
-
-    /**
-     * Return the permission reference URL.
-     * @return The url
-     * @see #setPermissionReferenceURL
-     * @since 11975
-     */
-    public String getPermissionReferenceURL() {
-        return permissionReferenceURL;
-    }
-
-    /**
-     * Return the privacy policy URL.
-     * @return The url
-     * @see #setPrivacyPolicyURL
-     * @since 16127
-     */
-    public String getPrivacyPolicyURL() {
-        return privacyPolicyURL;
-    }
-
-    @Override
-    public Image getAttributionImage() {
-        ImageIcon i = ImageProvider.getIfAvailable(attributionImage);
-        if (i != null) {
-            return i.getImage();
-        }
-        return null;
-    }
-
-    /**
-     * Return the raw attribution logo information (an URL to the image).
-     * @return The url text
-     * @since 12257
-     */
-    public String getAttributionImageRaw() {
-        return attributionImage;
-    }
-
-    @Override
-    public String getAttributionImageURL() {
-        return attributionImageURL;
-    }
-
-    @Override
-    public String getTermsOfUseText() {
-        return termsOfUseText;
-    }
-
-    @Override
-    public String getTermsOfUseURL() {
-        return termsOfUseURL;
-    }
-
-    /**
-     * Set the attribution text
-     * @param text The text
-     * @see #getAttributionText(int, ICoordinate, ICoordinate)
-     */
-    public void setAttributionText(String text) {
-        attributionText = intern(text);
-    }
-
-    /**
-     * Set the attribution image
-     * @param url The url of the image.
-     * @see #getAttributionImageURL()
-     */
-    public void setAttributionImageURL(String url) {
-        attributionImageURL = url;
-    }
-
-    /**
-     * Set the image for the attribution
-     * @param res The image resource
-     * @see #getAttributionImage()
-     */
-    public void setAttributionImage(String res) {
-        attributionImage = res;
-    }
-
-    /**
-     * Sets the URL the attribution should link to.
-     * @param url The url.
-     * @see #getAttributionLinkURL()
-     */
-    public void setAttributionLinkURL(String url) {
-        attributionLinkURL = url;
-    }
-
-    /**
-     * Sets the permission reference URL.
-     * @param url The url.
-     * @see #getPermissionReferenceURL()
-     * @since 11975
-     */
-    public void setPermissionReferenceURL(String url) {
-        permissionReferenceURL = url;
-    }
-
-    /**
-     * Sets the privacy policy URL.
-     * @param url The url.
-     * @see #getPrivacyPolicyURL()
-     * @since 16127
-     */
-    public void setPrivacyPolicyURL(String url) {
-        privacyPolicyURL = url;
-    }
-
-    /**
-     * Sets the text to display to the user as terms of use.
-     * @param text The text
-     * @see #getTermsOfUseText()
-     */
-    public void setTermsOfUseText(String text) {
-        termsOfUseText = text;
-    }
-
-    /**
-     * Sets a url that links to the terms of use text.
-     * @param text The url.
-     * @see #getTermsOfUseURL()
-     */
-    public void setTermsOfUseURL(String text) {
-        termsOfUseURL = text;
-    }
-
-    /**
      * Sets the extended URL of this entry.
      * @param url Entry extended URL containing in addition of service URL, its type and min/max zoom info
      */
@@ -956,7 +639,7 @@
 
         // Default imagery type is WMS
         this.url = url;
-        this.imageryType = ImageryType.WMS;
+        this.sourceType = ImageryType.WMS;
 
         defaultMaxZoom = 0;
         defaultMinZoom = 0;
@@ -964,7 +647,7 @@
             Matcher m = Pattern.compile(type.getTypeString()+"(?:\\[(?:(\\d+)[,-])?(\\d+)\\])?:(.*)").matcher(url);
             if (m.matches()) {
                 this.url = m.group(3);
-                this.imageryType = type;
+                this.sourceType = type;
                 if (m.group(2) != null) {
                     defaultMaxZoom = Integer.parseInt(m.group(2));
                 }
@@ -982,62 +665,7 @@
             }
         }
     }
-
     /**
-     * Returns the entry name.
-     * @return The entry name
-     * @since 6968
-     */
-    public String getOriginalName() {
-        return this.origName != null ? this.origName : this.name;
-    }
-
-    /**
-     * Sets the entry name and handle translation.
-     * @param language The used language
-     * @param name The entry name
-     * @since 8091
-     */
-    public void setName(String language, String name) {
-        boolean isdefault = LanguageInfo.getJOSMLocaleCode(null).equals(language);
-        if (LanguageInfo.isBetterLanguage(langName, language)) {
-            this.name = isdefault ? tr(name) : name;
-            this.langName = language;
-        }
-        if (origName == null || isdefault) {
-            this.origName = name;
-        }
-    }
-
-    /**
-     * Store the id of this info to the preferences and clear it afterwards.
-     */
-    public void clearId() {
-        if (this.id != null) {
-            Collection<String> newAddedIds = new TreeSet<>(Config.getPref().getList("imagery.layers.addedIds"));
-            newAddedIds.add(this.id);
-            Config.getPref().putList("imagery.layers.addedIds", new ArrayList<>(newAddedIds));
-        }
-        setId(null);
-    }
-
-    /**
-     * Determines if this entry is enabled by default.
-     * @return {@code true} if this entry is enabled by default, {@code false} otherwise
-     */
-    public boolean isDefaultEntry() {
-        return defaultEntry;
-    }
-
-    /**
-     * Sets the default state of this entry.
-     * @param defaultEntry {@code true} if this entry has to be enabled by default, {@code false} otherwise
-     */
-    public void setDefaultEntry(boolean defaultEntry) {
-        this.defaultEntry = defaultEntry;
-    }
-
-    /**
      * Gets the pixel per degree value
      * @return The ppd value.
      */
@@ -1064,55 +692,11 @@
     }
 
     /**
-     * Returns the description text when existing.
-     * @return The description
-     * @since 8065
-     */
-    public String getDescription() {
-        return this.description;
-    }
-
-    /**
-     * Sets the description text when existing.
-     * @param language The used language
-     * @param description the imagery description text
-     * @since 8091
-     */
-    public void setDescription(String language, String description) {
-        boolean isdefault = LanguageInfo.getJOSMLocaleCode(null).equals(language);
-        if (LanguageInfo.isBetterLanguage(langDescription, language)) {
-            this.description = isdefault ? tr(description) : description;
-            this.langDescription = intern(language);
-        }
-    }
-
-    /**
-     * Return the sorted list of activated Imagery IDs.
-     * @return sorted list of activated Imagery IDs
-     * @since 13536
-     */
-    public static Collection<String> getActiveIds() {
-        ArrayList<String> ids = new ArrayList<>();
-        IPreferences pref = Config.getPref();
-        if (pref != null) {
-            List<ImageryPreferenceEntry> entries = StructUtils.getListOfStructs(
-                pref, "imagery.entries", null, ImageryPreferenceEntry.class);
-            if (entries != null) {
-                for (ImageryPreferenceEntry prefEntry : entries) {
-                    if (prefEntry.id != null && !prefEntry.id.isEmpty())
-                        ids.add(prefEntry.id);
-                }
-                Collections.sort(ids);
-            }
-        }
-        return ids;
-    }
-
-    /**
      * Returns a tool tip text for display.
      * @return The text
      * @since 8065
      */
+    @Override
     public String getToolTipText() {
         StringBuilder res = new StringBuilder(getName());
         boolean html = false;
@@ -1143,75 +727,7 @@
         }
         return res.toString();
     }
-
     /**
-     * Returns the EULA acceptance URL, if any.
-     * @return The URL to an EULA text that has to be accepted before use, or {@code null}
-     */
-    public String getEulaAcceptanceRequired() {
-        return eulaAcceptanceRequired;
-    }
-
-    /**
-     * Sets the EULA acceptance URL.
-     * @param eulaAcceptanceRequired The URL to an EULA text that has to be accepted before use
-     */
-    public void setEulaAcceptanceRequired(String eulaAcceptanceRequired) {
-        this.eulaAcceptanceRequired = eulaAcceptanceRequired;
-    }
-
-    /**
-     * Returns the ISO 3166-1-alpha-2 country code.
-     * @return The country code (2 letters)
-     */
-    public String getCountryCode() {
-        return countryCode;
-    }
-
-    /**
-     * Sets the ISO 3166-1-alpha-2 country code.
-     * @param countryCode The country code (2 letters)
-     */
-    public void setCountryCode(String countryCode) {
-        this.countryCode = intern(countryCode);
-    }
-
-    /**
-     * Returns the date information.
-     * @return The date (in the form YYYY-MM-DD;YYYY-MM-DD, where
-     * DD and MM as well as a second date are optional)
-     * @since 11570
-     */
-    public String getDate() {
-        return date;
-    }
-
-    /**
-     * Sets the date information.
-     * @param date The date information
-     * @since 11570
-     */
-    public void setDate(String date) {
-        this.date = date;
-    }
-
-    /**
-     * Returns the entry icon.
-     * @return The entry icon
-     */
-    public String getIcon() {
-        return icon;
-    }
-
-    /**
-     * Sets the entry icon.
-     * @param icon The entry icon
-     */
-    public void setIcon(String icon) {
-        this.icon = intern(icon);
-    }
-
-    /**
      * Get the projections supported by the server. Only relevant for
      * WMS-type ImageryInfo at the moment.
      * @return null, if no projections have been specified; the list
@@ -1237,7 +753,7 @@
      * @return The extended URL
      */
     public String getExtendedUrl() {
-        return imageryType.getTypeString() + (defaultMaxZoom != 0
+        return sourceType.getTypeString() + (defaultMaxZoom != 0
             ? ('['+(defaultMinZoom != 0 ? (Integer.toString(defaultMinZoom) + ',') : "")+defaultMaxZoom+']') : "") + ':' + url;
     }
 
@@ -1265,185 +781,8 @@
         return res;
     }
 
-    /**
-     * Determines if this entry requires attribution.
-     * @return {@code true} if some attribution text has to be displayed, {@code false} otherwise
-     */
-    public boolean hasAttribution() {
-        return attributionText != null;
-    }
 
     /**
-     * Copies attribution from another {@code ImageryInfo}.
-     * @param i The other imagery info to get attribution from
-     */
-    public void copyAttribution(ImageryInfo i) {
-        this.attributionImage = i.attributionImage;
-        this.attributionImageURL = i.attributionImageURL;
-        this.attributionText = i.attributionText;
-        this.attributionLinkURL = i.attributionLinkURL;
-        this.termsOfUseText = i.termsOfUseText;
-        this.termsOfUseURL = i.termsOfUseURL;
-    }
-
-    /**
-     * Applies the attribution from this object to a tile source.
-     * @param s The tile source
-     */
-    public void setAttribution(AbstractTileSource s) {
-        if (attributionText != null) {
-            if ("osm".equals(attributionText)) {
-                s.setAttributionText(new Mapnik().getAttributionText(0, null, null));
-            } else {
-                s.setAttributionText(attributionText);
-            }
-        }
-        if (attributionLinkURL != null) {
-            if ("osm".equals(attributionLinkURL)) {
-                s.setAttributionLinkURL(new Mapnik().getAttributionLinkURL());
-            } else {
-                s.setAttributionLinkURL(attributionLinkURL);
-            }
-        }
-        if (attributionImage != null) {
-            ImageIcon i = ImageProvider.getIfAvailable(null, attributionImage);
-            if (i != null) {
-                s.setAttributionImage(i.getImage());
-            }
-        }
-        if (attributionImageURL != null) {
-            s.setAttributionImageURL(attributionImageURL);
-        }
-        if (termsOfUseText != null) {
-            s.setTermsOfUseText(termsOfUseText);
-        }
-        if (termsOfUseURL != null) {
-            if ("osm".equals(termsOfUseURL)) {
-                s.setTermsOfUseURL(new Mapnik().getTermsOfUseURL());
-            } else {
-                s.setTermsOfUseURL(termsOfUseURL);
-            }
-        }
-    }
-
-    /**
-     * Returns the imagery type.
-     * @return The imagery type
-     */
-    public ImageryType getImageryType() {
-        return imageryType;
-    }
-
-    /**
-     * Sets the imagery type.
-     * @param imageryType The imagery type
-     */
-    public void setImageryType(ImageryType imageryType) {
-        this.imageryType = imageryType;
-    }
-
-    /**
-     * Returns the imagery category.
-     * @return The imagery category
-     * @since 13792
-     */
-    public ImageryCategory getImageryCategory() {
-        return category;
-    }
-
-    /**
-     * Sets the imagery category.
-     * @param category The imagery category
-     * @since 13792
-     */
-    public void setImageryCategory(ImageryCategory category) {
-        this.category = category;
-    }
-
-    /**
-     * Returns the imagery category original string (don't use except for error checks).
-     * @return The imagery category original string
-     * @since 13792
-     */
-    public String getImageryCategoryOriginalString() {
-        return categoryOriginalString;
-    }
-
-    /**
-     * Sets the imagery category original string (don't use except for error checks).
-     * @param categoryOriginalString The imagery category original string
-     * @since 13792
-     */
-    public void setImageryCategoryOriginalString(String categoryOriginalString) {
-        this.categoryOriginalString = intern(categoryOriginalString);
-    }
-
-    /**
-     * Returns true if this layer's URL is matched by one of the regular
-     * expressions kept by the current OsmApi instance.
-     * @return {@code true} is this entry is blacklisted, {@code false} otherwise
-     */
-    public boolean isBlacklisted() {
-        Capabilities capabilities = OsmApi.getOsmApi().getCapabilities();
-        return capabilities != null && capabilities.isOnImageryBlacklist(this.url);
-    }
-
-    /**
-     * Sets the map of &lt;header name, header value&gt; that if any of this header
-     * will be returned, then this tile will be treated as "no tile at this zoom level"
-     *
-     * @param noTileHeaders Map of &lt;header name, header value&gt; which will be treated as "no tile at this zoom level"
-     * @since 9613
-     */
-    public void setNoTileHeaders(MultiMap<String, String> noTileHeaders) {
-       if (noTileHeaders == null || noTileHeaders.isEmpty()) {
-           this.noTileHeaders = null;
-       } else {
-            this.noTileHeaders = noTileHeaders.toMap();
-       }
-    }
-
-    @Override
-    public Map<String, Set<String>> getNoTileHeaders() {
-        return noTileHeaders;
-    }
-
-    /**
-     * Sets the map of &lt;checksum type, checksum value&gt; that if any tile with that checksum
-     * will be returned, then this tile will be treated as "no tile at this zoom level"
-     *
-     * @param noTileChecksums Map of &lt;checksum type, checksum value&gt; which will be treated as "no tile at this zoom level"
-     * @since 9613
-     */
-    public void setNoTileChecksums(MultiMap<String, String> noTileChecksums) {
-        if (noTileChecksums == null || noTileChecksums.isEmpty()) {
-            this.noTileChecksums = null;
-        } else {
-            this.noTileChecksums = noTileChecksums.toMap();
-        }
-    }
-
-    @Override
-    public Map<String, Set<String>> getNoTileChecksums() {
-        return noTileChecksums;
-    }
-
-    /**
-     * Returns the map of &lt;header name, metadata key&gt; indicating, which HTTP headers should
-     * be moved to metadata
-     *
-     * @param metadataHeaders map of &lt;header name, metadata key&gt; indicating, which HTTP headers should be moved to metadata
-     * @since 8418
-     */
-    public void setMetadataHeaders(Map<String, String> metadataHeaders) {
-        if (metadataHeaders == null || metadataHeaders.isEmpty()) {
-            this.metadataHeaders = null;
-        } else {
-            this.metadataHeaders = metadataHeaders;
-        }
-    }
-
-    /**
      * Gets the flag if the georeference is valid.
      * @return <code>true</code> if it is valid.
      */
@@ -1496,125 +835,6 @@
     }
 
     /**
-     * Adds an old Id.
-     *
-     * @param id the Id to be added
-     * @since 13536
-     */
-    public void addOldId(String id) {
-       if (oldIds == null) {
-           oldIds = new ArrayList<>();
-       }
-       oldIds.add(id);
-    }
-
-    /**
-     * Get old Ids.
-     *
-     * @return collection of ids
-     * @since 13536
-     */
-    public Collection<String> getOldIds() {
-        return oldIds;
-    }
-
-    /**
-     * Adds a mirror entry. Mirror entries are completed with the data from the master entry
-     * and only describe another method to access identical data.
-     *
-     * @param entry the mirror to be added
-     * @since 9658
-     */
-    public void addMirror(ImageryInfo entry) {
-       if (mirrors == null) {
-           mirrors = new ArrayList<>();
-       }
-       mirrors.add(entry);
-    }
-
-    /**
-     * Returns the mirror entries. Entries are completed with master entry data.
-     *
-     * @return the list of mirrors
-     * @since 9658
-     */
-    public List<ImageryInfo> getMirrors() {
-       List<ImageryInfo> l = new ArrayList<>();
-       if (mirrors != null) {
-           int num = 1;
-           for (ImageryInfo i : mirrors) {
-               ImageryInfo n = new ImageryInfo(this);
-               if (i.defaultMaxZoom != 0) {
-                   n.defaultMaxZoom = i.defaultMaxZoom;
-               }
-               if (i.defaultMinZoom != 0) {
-                   n.defaultMinZoom = i.defaultMinZoom;
-               }
-               n.setServerProjections(i.getServerProjections());
-               n.url = i.url;
-               n.imageryType = i.imageryType;
-               if (i.getTileSize() != 0) {
-                   n.setTileSize(i.getTileSize());
-               }
-               if (i.getPrivacyPolicyURL() != null) {
-                   n.setPrivacyPolicyURL(i.getPrivacyPolicyURL());
-               }
-               if (n.id != null) {
-                   n.id = n.id + "_mirror"+num;
-               }
-               if (num > 1) {
-                   n.name = tr("{0} mirror server {1}", n.name, num);
-                   if (n.origName != null) {
-                       n.origName += " mirror server " + num;
-                   }
-               } else {
-                   n.name = tr("{0} mirror server", n.name);
-                   if (n.origName != null) {
-                       n.origName += " mirror server";
-                   }
-               }
-               l.add(n);
-               ++num;
-           }
-       }
-       return l;
-    }
-
-    /**
-     * Returns default layers that should be shown for this Imagery (if at all supported by imagery provider)
-     * If no layer is set to default and there is more than one imagery available, then user will be asked to choose the layer
-     * to work on
-     * @return Collection of the layer names
-     */
-    public List<DefaultLayer> getDefaultLayers() {
-        return defaultLayers;
-    }
-
-    /**
-     * Sets the default layers that user will work with
-     * @param layers set the list of default layers
-     */
-    public void setDefaultLayers(List<DefaultLayer> layers) {
-        this.defaultLayers = Utils.toUnmodifiableList(layers);
-    }
-
-    /**
-     * Returns custom HTTP headers that should be sent with request towards imagery provider
-     * @return headers
-     */
-    public Map<String, String> getCustomHttpHeaders() {
-        return customHttpHeaders;
-    }
-
-    /**
-     * Sets custom HTTP headers that should be sent with request towards imagery provider
-     * @param customHttpHeaders http headers
-     */
-    public void setCustomHttpHeaders(Map<String, String> customHttpHeaders) {
-        this.customHttpHeaders = Utils.toUnmodifiableMap(customHttpHeaders);
-    }
-
-    /**
      * Determines if this imagery should be transparent.
      * @return should this imagery be transparent
      */
@@ -1652,7 +872,7 @@
      * @since 13890
      */
     public String getSourceName() {
-        if (ImageryType.BING == getImageryType()) {
+        if (ImageryType.BING == getSourceType()) {
             return "Bing";
         } else {
             if (id != null) {
@@ -1666,7 +886,12 @@
         }
     }
 
-    private static String intern(String string) {
-        return string == null ? null : string.intern();
+    /**
+     * Return the sorted list of activated source IDs.
+     * @return sorted list of activated source IDs
+     * @since 13536
+     */
+    public static Collection<String> getActiveIds() {
+        return getActiveIds(ImageryInfo.class);
     }
 }
Index: src/org/openstreetmap/josm/data/imagery/ImageryLayerInfo.java
===================================================================
--- src/org/openstreetmap/josm/data/imagery/ImageryLayerInfo.java	(revision 16248)
+++ src/org/openstreetmap/josm/data/imagery/ImageryLayerInfo.java	(working copy)
@@ -336,7 +336,7 @@
     }
 
     private static boolean isSimilar(ImageryInfo iiA, ImageryInfo iiB) {
-        if (iiA == null || iiA.getImageryType() != iiB.getImageryType())
+        if (iiA == null || iiA.getSourceType() != iiB.getSourceType())
             return false;
         if (iiA.getId() != null && iiB.getId() != null)
             return iiA.getId().equals(iiB.getId());
Index: src/org/openstreetmap/josm/data/imagery/WMSEndpointTileSource.java
===================================================================
--- src/org/openstreetmap/josm/data/imagery/WMSEndpointTileSource.java	(revision 16248)
+++ src/org/openstreetmap/josm/data/imagery/WMSEndpointTileSource.java	(working copy)
@@ -40,7 +40,7 @@
      */
     public WMSEndpointTileSource(ImageryInfo info, Projection tileProjection) {
         super(info, tileProjection);
-        CheckParameterUtil.ensureThat(info.getImageryType() == ImageryType.WMS_ENDPOINT, "imageryType");
+        CheckParameterUtil.ensureThat(info.getSourceType() == ImageryType.WMS_ENDPOINT, "imageryType");
         try {
             wmsi = new WMSImagery(info.getUrl(), info.getCustomHttpHeaders());
         } catch (IOException | WMSGetCapabilitiesException e) {
Index: src/org/openstreetmap/josm/data/imagery/WMTSTileSource.java
===================================================================
--- src/org/openstreetmap/josm/data/imagery/WMTSTileSource.java	(revision 16248)
+++ src/org/openstreetmap/josm/data/imagery/WMTSTileSource.java	(working copy)
@@ -392,7 +392,7 @@
                                         .orElse(ffirst));
                     }
                 }
-                this.defaultLayer = new DefaultLayer(info.getImageryType(), first.identifier, first.style, first.tileMatrixSet.identifier);
+                this.defaultLayer = new DefaultLayer(info.getSourceType(), first.identifier, first.style, first.tileMatrixSet.identifier);
             } else {
                 this.defaultLayer = null;
             }
Index: src/org/openstreetmap/josm/data/sources/ICommonSource.java
===================================================================
--- src/org/openstreetmap/josm/data/sources/ICommonSource.java	(nonexistent)
+++ src/org/openstreetmap/josm/data/sources/ICommonSource.java	(working copy)
@@ -0,0 +1,26 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.sources;
+
+/**
+ * This interface is used to ensure that a class can get a enum from a string.
+ * For various reasons, the fromString method cannot be implemented statically.
+ *
+ * @author Taylor Smock
+ * @since xxx
+ *
+ * @param <T> The enum type
+ */
+public interface ICommonSource<T extends Enum<T>> {
+    /**
+     * Get the default value for the Enum
+     * @return The default value
+     */
+    public T getDefault();
+
+    /**
+     * Returns the source category from the given category string.
+     * @param s The category string
+     * @return the source category matching the given category string
+     */
+    public T fromString(String s);
+}
Index: src/org/openstreetmap/josm/data/sources/ISourceCategory.java
===================================================================
--- src/org/openstreetmap/josm/data/sources/ISourceCategory.java	(nonexistent)
+++ src/org/openstreetmap/josm/data/sources/ISourceCategory.java	(working copy)
@@ -0,0 +1,34 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.sources;
+
+import javax.swing.ImageIcon;
+
+import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
+
+/**
+ * @author Taylor Smock
+ *
+ * @param <T> The enum that is extending this interface
+ * @since xxx
+ */
+public interface ISourceCategory<T extends Enum<T>> extends ICommonSource<T> {
+
+    /**
+     * Returns the unique string identifying this category.
+     * @return the unique string identifying this category
+     */
+    public String getCategoryString();
+
+    /**
+     * Returns the description of this category.
+     * @return the description of this category
+     */
+    public String getDescription();
+
+    /**
+     * Returns the category icon at the given size.
+     * @param size icon wanted size
+     * @return the category icon at the given size
+     */
+    public ImageIcon getIcon(ImageSizes size);
+}
\ No newline at end of file
Index: src/org/openstreetmap/josm/data/sources/ISourceType.java
===================================================================
--- src/org/openstreetmap/josm/data/sources/ISourceType.java	(nonexistent)
+++ src/org/openstreetmap/josm/data/sources/ISourceType.java	(working copy)
@@ -0,0 +1,18 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.sources;
+
+/**
+ * This interface should only be used for Enums
+ * @author Taylor Smock
+ * @since xxx
+ *
+ * @param <T> The source type (e.g., Imagery or otherwise -- should be the name of the class)
+ */
+public interface ISourceType<T extends Enum<T>> extends ICommonSource<T> {
+
+    /**
+     * Returns the unique string identifying this type.
+     * @return the unique string identifying this type
+     */
+    public String getTypeString();
+}
Index: src/org/openstreetmap/josm/data/sources/SourceBounds.java
===================================================================
--- src/org/openstreetmap/josm/data/sources/SourceBounds.java	(nonexistent)
+++ src/org/openstreetmap/josm/data/sources/SourceBounds.java	(working copy)
@@ -0,0 +1,69 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.sources;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.imagery.Shape;
+
+/**
+ *
+ * Multi-polygon bounds for source backgrounds.
+ * Used to display source coverage in preferences and to determine relevant source entries based on edit location.
+ *
+ * @author Frederik Ramm, extracted by Taylor Smock
+ * @since xxx (extracted from {@link org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryBounds})
+ */
+public class SourceBounds extends Bounds {
+
+    /**
+     * Constructs a new {@code SourceBounds} from string.
+     * @param asString The string containing the list of shapes defining this bounds
+     * @param separator The shape separator in the given string, usually a comma
+     */
+    public SourceBounds(String asString, String separator) {
+        super(asString, separator);
+    }
+
+    private List<Shape> shapes = new ArrayList<>();
+
+    /**
+     * Adds a new shape to this bounds.
+     * @param shape The shape to add
+     */
+    public final void addShape(Shape shape) {
+        this.shapes.add(shape);
+    }
+
+    /**
+     * Sets the list of shapes defining this bounds.
+     * @param shapes The list of shapes defining this bounds.
+     */
+    public final void setShapes(List<Shape> shapes) {
+        this.shapes = shapes;
+    }
+
+    /**
+     * Returns the list of shapes defining this bounds.
+     * @return The list of shapes defining this bounds
+     */
+    public final List<Shape> getShapes() {
+        return shapes;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(super.hashCode(), shapes);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        if (!super.equals(o)) return false;
+        SourceBounds that = (SourceBounds) o;
+        return Objects.equals(shapes, that.shapes);
+    }
+}
Index: src/org/openstreetmap/josm/data/sources/SourceInfo.java
===================================================================
--- src/org/openstreetmap/josm/data/sources/SourceInfo.java	(nonexistent)
+++ src/org/openstreetmap/josm/data/sources/SourceInfo.java	(working copy)
@@ -0,0 +1,833 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.sources;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Image;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+
+import javax.swing.ImageIcon;
+
+import org.openstreetmap.gui.jmapviewer.interfaces.Attributed;
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTileSource;
+import org.openstreetmap.gui.jmapviewer.tilesources.OsmTileSource.Mapnik;
+import org.openstreetmap.gui.jmapviewer.tilesources.TileSourceInfo;
+import org.openstreetmap.josm.data.StructUtils;
+import org.openstreetmap.josm.data.imagery.DefaultLayer;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.io.Capabilities;
+import org.openstreetmap.josm.io.OsmApi;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.spi.preferences.IPreferences;
+import org.openstreetmap.josm.tools.ImageProvider;
+import org.openstreetmap.josm.tools.LanguageInfo;
+import org.openstreetmap.josm.tools.MultiMap;
+import org.openstreetmap.josm.tools.Utils;
+
+/**
+ * @author Taylor Smock
+ * @param <T> The SourceCategory The categories enum for the source
+ * @param <U> The SourceType The type enum of the source
+ * @param <V> The SourceBounds The bound type for the entry
+ * @param <W> The storage for the entry
+ *
+ * @since xxx
+ */
+public class SourceInfo<T extends ISourceCategory<?>, U extends ISourceType<?>, V extends SourceBounds, W extends SourcePreferenceEntry<?>> extends TileSourceInfo implements Comparable<SourceInfo<T, U, V, W>>, Attributed {
+    /** original name of the source entry in case of translation call, for multiple languages English when possible */
+    protected String origName;
+    /** (original) language of the translated name entry */
+    protected String langName;
+    /** whether this is a entry activated by default or not */
+    protected boolean defaultEntry;
+    /** Whether this service requires a explicit EULA acceptance before it can be activated */
+    protected String eulaAcceptanceRequired;
+    /** type of the services - WMS, TMS, ... */
+    protected U sourceType;
+    /** display bounds of imagery, displayed in prefs and used for automatic imagery selection */
+    protected V bounds;
+    /** description of the imagery entry, should contain notes what type of data it is */
+    protected String description;
+    /** language of the description entry */
+    protected String langDescription;
+    /** Text of a text attribution displayed when using the imagery */
+    protected String attributionText;
+    /** Link to the privacy policy of the operator */
+    protected String privacyPolicyURL;
+    /** Link to a reference stating the permission for OSM usage */
+    protected String permissionReferenceURL;
+    /** Link behind the text attribution displayed when using the imagery */
+    protected String attributionLinkURL;
+    /** Image of a graphical attribution displayed when using the imagery */
+    protected String attributionImage;
+    /** Link behind the graphical attribution displayed when using the imagery */
+    protected String attributionImageURL;
+    /** Text with usage terms displayed when using the imagery */
+    protected String termsOfUseText;
+    /** Link behind the text with usage terms displayed when using the imagery */
+    protected String termsOfUseURL;
+    /** country code of the imagery (for country specific imagery) */
+    protected String countryCode = "";
+    /**
+      * creation date of the source (in the form YYYY-MM-DD;YYYY-MM-DD, where
+      * DD and MM as well as a second date are optional).
+      *
+      * Also used as time filter for WMS time={time} parameter (such as Sentinel-2)
+      * @since 11570
+      */
+    protected String date;
+    /**
+      * list of old IDs, only for loading, not handled anywhere else
+      * @since 13536
+      */
+    protected Collection<String> oldIds;
+    /** icon used in menu */
+    protected String icon;
+    /** which layers should be activated by default on layer addition. **/
+    protected List<DefaultLayer> defaultLayers = Collections.emptyList();
+    /** HTTP headers **/
+    protected Map<String, String> customHttpHeaders = Collections.emptyMap();
+    /** category of the imagery */
+    protected T category;
+    /** category of the imagery (input string, not saved, copied or used otherwise except for error checks) */
+    protected String categoryOriginalString;
+    /** when adding a field, also adapt the:
+     * {@link #ImageryPreferenceEntry ImageryPreferenceEntry object}
+     * {@link #ImageryPreferenceEntry#ImageryPreferenceEntry(ImageryInfo) ImageryPreferenceEntry constructor}
+     * {@link #ImageryInfo(ImageryPreferenceEntry) ImageryInfo constructor}
+     * {@link #ImageryInfo(ImageryInfo) ImageryInfo constructor}
+     * {@link #equalsPref(ImageryPreferenceEntry) equalsPref method}
+     **/
+
+    /**
+     * Creates empty SourceInfo class
+     */
+    public SourceInfo() {
+        super();
+    }
+
+    /**
+     * Create a SourceInfo class
+     *
+     * @param name name
+     */
+    public SourceInfo(String name) {
+        super(name);
+    }
+
+    /**
+     * Create a SourceInfo class
+     *
+     * @param name name
+     * @param url base URL
+     * @param id unique id
+     */
+    public SourceInfo(String name, String url, String id) {
+        super(name, url, id);
+    }
+
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(url, sourceType);
+    }
+
+    /**
+     * Check if this object equals another SourceInfo with respect to the properties
+     * that get written to the preference file.
+     *
+     * This should be overridden and called in subclasses.
+     *
+     * @param other the SourceInfo object to compare to
+     * @return true if they are equal
+     */
+    public boolean equalsPref(SourceInfo<T, U, V, W> other) {
+        if (other == null) {
+            return false;
+        }
+
+        // CHECKSTYLE.OFF: BooleanExpressionComplexity
+        return
+                Objects.equals(this.name, other.name) &&
+                Objects.equals(this.id, other.id) &&
+                Objects.equals(this.url, other.url) &&
+                Objects.equals(this.modTileFeatures, other.modTileFeatures) &&
+                Objects.equals(this.cookies, other.cookies) &&
+                Objects.equals(this.eulaAcceptanceRequired, other.eulaAcceptanceRequired) &&
+                Objects.equals(this.sourceType, other.sourceType) &&
+                Objects.equals(this.bounds, other.bounds) &&
+                Objects.equals(this.attributionText, other.attributionText) &&
+                Objects.equals(this.attributionLinkURL, other.attributionLinkURL) &&
+                Objects.equals(this.permissionReferenceURL, other.permissionReferenceURL) &&
+                Objects.equals(this.attributionImageURL, other.attributionImageURL) &&
+                Objects.equals(this.attributionImage, other.attributionImage) &&
+                Objects.equals(this.termsOfUseText, other.termsOfUseText) &&
+                Objects.equals(this.termsOfUseURL, other.termsOfUseURL) &&
+                Objects.equals(this.countryCode, other.countryCode) &&
+                Objects.equals(this.date, other.date) &&
+                Objects.equals(this.icon, other.icon) &&
+                Objects.equals(this.description, other.description) &&
+                Objects.equals(this.noTileHeaders, other.noTileHeaders) &&
+                Objects.equals(this.noTileChecksums, other.noTileChecksums) &&
+                Objects.equals(this.metadataHeaders, other.metadataHeaders) &&
+                Objects.equals(this.defaultLayers, other.defaultLayers) &&
+                Objects.equals(this.customHttpHeaders, other.customHttpHeaders) &&
+                Objects.equals(this.category, other.category);
+        // CHECKSTYLE.ON: BooleanExpressionComplexity
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        SourceInfo<?, ?, ?, ?> that = (SourceInfo<?, ?, ?, ?>) o;
+        return sourceType == that.sourceType && Objects.equals(url, that.url);
+    }
+
+    private static final Map<String, String> localizedCountriesCache = new HashMap<>();
+    static {
+        localizedCountriesCache.put("", tr("Worldwide"));
+    }
+
+    /**
+     * Returns a localized name for the given country code, or "Worldwide" if empty.
+     * This function falls back on the English name, and uses the ISO code as a last-resortvalue.
+     *
+     * @param countryCode An ISO 3166 alpha-2 country code or a UN M.49 numeric-3 area code
+     * @return The name of the country appropriate to the current locale.
+     * @see Locale#getDisplayCountry
+     * @since 15158
+     */
+    public static String getLocalizedCountry(String countryCode) {
+        return localizedCountriesCache.computeIfAbsent(countryCode, code -> new Locale("en", code).getDisplayCountry());
+    }
+
+    @Override
+    public String toString() {
+        // Used in imagery preferences filtering, so must be efficient
+        return new StringBuilder(name)
+                .append('[').append(countryCode)
+                // appending the localized country in toString() allows us to filter imagery preferences table with it!
+                .append("] ('").append(getLocalizedCountry(countryCode)).append(')')
+                .append(" - ").append(url)
+                .append(" - ").append(sourceType)
+                .toString();
+    }
+
+    @Override
+    public int compareTo(SourceInfo<T, U, V, W> in) {
+        int i = countryCode.compareTo(in.countryCode);
+        if (i == 0) {
+            i = name.toLowerCase(Locale.ENGLISH).compareTo(in.name.toLowerCase(Locale.ENGLISH));
+        }
+        if (i == 0) {
+            i = url.compareTo(in.url);
+        }
+        return i;
+    }
+
+    /**
+     * Determines if URL is equal to given source info.
+     * @param in source info
+     * @return {@code true} if URL is equal to given source info
+     */
+    public boolean equalsBaseValues(SourceInfo<T, U, V, W> in) {
+        return url.equals(in.url);
+    }
+
+    /**
+     * Sets the source polygonial bounds.
+     * @param b The source bounds (non-rectangular)
+     */
+    public void setBounds(V b) {
+        this.bounds = b;
+    }
+
+    /**
+     * Returns the source polygonial bounds.
+     * @return The source bounds (non-rectangular)
+     */
+    public V getBounds() {
+        return bounds;
+    }
+
+    @Override
+    public boolean requiresAttribution() {
+        return attributionText != null || attributionLinkURL != null || attributionImage != null
+                || termsOfUseText != null || termsOfUseURL != null;
+    }
+
+    @Override
+    public String getAttributionText(int zoom, ICoordinate topLeft, ICoordinate botRight) {
+        return attributionText;
+    }
+
+    @Override
+    public String getAttributionLinkURL() {
+        return attributionLinkURL;
+    }
+
+    /**
+     * Return the permission reference URL.
+     * @return The url
+     * @see #setPermissionReferenceURL
+     * @since 11975
+     */
+    public String getPermissionReferenceURL() {
+        return permissionReferenceURL;
+    }
+
+    /**
+     * Return the privacy policy URL.
+     * @return The url
+     * @see #setPrivacyPolicyURL
+     * @since 16127
+     */
+    public String getPrivacyPolicyURL() {
+        return privacyPolicyURL;
+    }
+
+    @Override
+    public Image getAttributionImage() {
+        ImageIcon i = ImageProvider.getIfAvailable(attributionImage);
+        if (i != null) {
+            return i.getImage();
+        }
+        return null;
+    }
+
+    /**
+     * Return the raw attribution logo information (an URL to the image).
+     * @return The url text
+     * @since 12257
+     */
+    public String getAttributionImageRaw() {
+        return attributionImage;
+    }
+
+    @Override
+    public String getAttributionImageURL() {
+        return attributionImageURL;
+    }
+
+    @Override
+    public String getTermsOfUseText() {
+        return termsOfUseText;
+    }
+
+    @Override
+    public String getTermsOfUseURL() {
+        return termsOfUseURL;
+    }
+
+    /**
+     * Set the attribution text
+     * @param text The text
+     * @see #getAttributionText(int, ICoordinate, ICoordinate)
+     */
+    public void setAttributionText(String text) {
+        attributionText = Utils.intern(text);
+    }
+
+    /**
+     * Set the attribution image
+     * @param url The url of the image.
+     * @see #getAttributionImageURL()
+     */
+    public void setAttributionImageURL(String url) {
+        attributionImageURL = url;
+    }
+
+    /**
+     * Set the image for the attribution
+     * @param res The image resource
+     * @see #getAttributionImage()
+     */
+    public void setAttributionImage(String res) {
+        attributionImage = res;
+    }
+
+    /**
+     * Sets the URL the attribution should link to.
+     * @param url The url.
+     * @see #getAttributionLinkURL()
+     */
+    public void setAttributionLinkURL(String url) {
+        attributionLinkURL = url;
+    }
+
+    /**
+     * Sets the permission reference URL.
+     * @param url The url.
+     * @see #getPermissionReferenceURL()
+     * @since 11975
+     */
+    public void setPermissionReferenceURL(String url) {
+        permissionReferenceURL = url;
+    }
+
+    /**
+     * Sets the privacy policy URL.
+     * @param url The url.
+     * @see #getPrivacyPolicyURL()
+     * @since 16127
+     */
+    public void setPrivacyPolicyURL(String url) {
+        privacyPolicyURL = url;
+    }
+
+    /**
+     * Sets the text to display to the user as terms of use.
+     * @param text The text
+     * @see #getTermsOfUseText()
+     */
+    public void setTermsOfUseText(String text) {
+        termsOfUseText = text;
+    }
+
+    /**
+     * Sets a url that links to the terms of use text.
+     * @param text The url.
+     * @see #getTermsOfUseURL()
+     */
+    public void setTermsOfUseURL(String text) {
+        termsOfUseURL = text;
+    }
+
+    /**
+     * Returns the entry name.
+     * @return The entry name
+     * @since 6968
+     */
+    public String getOriginalName() {
+        return this.origName != null ? this.origName : this.name;
+    }
+
+    /**
+     * Sets the entry name and handle translation.
+     * @param language The used language
+     * @param name The entry name
+     * @since 8091
+     */
+    public void setName(String language, String name) {
+        boolean isdefault = LanguageInfo.getJOSMLocaleCode(null).equals(language);
+        if (LanguageInfo.isBetterLanguage(langName, language)) {
+            this.name = isdefault ? tr(name) : name;
+            this.langName = language;
+        }
+        if (origName == null || isdefault) {
+            this.origName = name;
+        }
+    }
+
+    /**
+     * Store the id of this info to the preferences and clear it afterwards.
+     */
+    public void clearId() {
+        if (this.id != null) {
+            Collection<String> newAddedIds = new TreeSet<>(Config.getPref().getList("imagery.layers.addedIds"));
+            newAddedIds.add(this.id);
+            Config.getPref().putList("imagery.layers.addedIds", new ArrayList<>(newAddedIds));
+        }
+        setId(null);
+    }
+
+    /**
+     * Determines if this entry is enabled by default.
+     * @return {@code true} if this entry is enabled by default, {@code false} otherwise
+     */
+    public boolean isDefaultEntry() {
+        return defaultEntry;
+    }
+
+    /**
+     * Sets the default state of this entry.
+     * @param defaultEntry {@code true} if this entry has to be enabled by default, {@code false} otherwise
+     */
+    public void setDefaultEntry(boolean defaultEntry) {
+        this.defaultEntry = defaultEntry;
+    }
+
+    /**
+     * Returns the description text when existing.
+     * @return The description
+     * @since 8065
+     */
+    public String getDescription() {
+        return this.description;
+    }
+
+    /**
+     * Sets the description text when existing.
+     * @param language The used language
+     * @param description the imagery description text
+     * @since 8091
+     */
+    public void setDescription(String language, String description) {
+        boolean isdefault = LanguageInfo.getJOSMLocaleCode(null).equals(language);
+        if (LanguageInfo.isBetterLanguage(langDescription, language)) {
+            this.description = isdefault ? tr(description) : description;
+            this.langDescription = Utils.intern(language);
+        }
+    }
+
+    /**
+     * Return the sorted list of activated source IDs.
+     * @param <W> The type of active id to get
+     * @param clazz The class of the type of id
+     * @return sorted list of activated source IDs
+     * @since 13536, xxx (extracted)
+     */
+    public static <W extends SourceInfo<?, ?, ?, ?>> Collection<String> getActiveIds(Class<W> clazz) {
+        ArrayList<String> ids = new ArrayList<>();
+        IPreferences pref = Config.getPref();
+        if (pref != null) {
+            List<W> entries = StructUtils.getListOfStructs(
+                pref, (ImageryInfo.class.equals(clazz) ? "imagery" : clazz.getSimpleName()) + ".entries", null, clazz);
+            if (entries != null) {
+                for (W prefEntry : entries) {
+                    if (prefEntry.id != null && !prefEntry.id.isEmpty())
+                        ids.add(prefEntry.id);
+                }
+                Collections.sort(ids);
+            }
+        }
+        return ids;
+    }
+
+    /**
+     * Returns a tool tip text for display.
+     * @return The text
+     * @since 8065
+     */
+    public String getToolTipText() {
+        StringBuilder res = new StringBuilder(getName());
+        boolean html = false;
+        String dateStr = getDate();
+        if (dateStr != null && !dateStr.isEmpty()) {
+            res.append("<br>").append(tr("Date of imagery: {0}", dateStr));
+            html = true;
+        }
+        if (category != null && category.getDescription() != null) {
+            res.append("<br>").append(tr("Imagery category: {0}", category.getDescription()));
+            html = true;
+        }
+        String desc = getDescription();
+        if (desc != null && !desc.isEmpty()) {
+            res.append("<br>").append(Utils.escapeReservedCharactersHTML(desc));
+            html = true;
+        }
+        if (html) {
+            res.insert(0, "<html>").append("</html>");
+        }
+        return res.toString();
+    }
+
+    /**
+     * Returns the EULA acceptance URL, if any.
+     * @return The URL to an EULA text that has to be accepted before use, or {@code null}
+     */
+    public String getEulaAcceptanceRequired() {
+        return eulaAcceptanceRequired;
+    }
+
+    /**
+     * Sets the EULA acceptance URL.
+     * @param eulaAcceptanceRequired The URL to an EULA text that has to be accepted before use
+     */
+    public void setEulaAcceptanceRequired(String eulaAcceptanceRequired) {
+        this.eulaAcceptanceRequired = eulaAcceptanceRequired;
+    }
+
+    /**
+     * Returns the ISO 3166-1-alpha-2 country code.
+     * @return The country code (2 letters)
+     */
+    public String getCountryCode() {
+        return countryCode;
+    }
+
+    /**
+     * Sets the ISO 3166-1-alpha-2 country code.
+     * @param countryCode The country code (2 letters)
+     */
+    public void setCountryCode(String countryCode) {
+        this.countryCode = Utils.intern(countryCode);
+    }
+
+    /**
+     * Returns the date information.
+     * @return The date (in the form YYYY-MM-DD;YYYY-MM-DD, where
+     * DD and MM as well as a second date are optional)
+     * @since 11570
+     */
+    public String getDate() {
+        return date;
+    }
+
+    /**
+     * Sets the date information.
+     * @param date The date information
+     * @since 11570
+     */
+    public void setDate(String date) {
+        this.date = date;
+    }
+
+    /**
+     * Returns the entry icon.
+     * @return The entry icon
+     */
+    public String getIcon() {
+        return icon;
+    }
+
+    /**
+     * Sets the entry icon.
+     * @param icon The entry icon
+     */
+    public void setIcon(String icon) {
+        this.icon = Utils.intern(icon);
+    }
+
+    /**
+     * Determines if this entry requires attribution.
+     * @return {@code true} if some attribution text has to be displayed, {@code false} otherwise
+     */
+    public boolean hasAttribution() {
+        return attributionText != null;
+    }
+
+    /**
+     * Copies attribution from another {@code SourceInfo}.
+     * @param i The other source info to get attribution from
+     */
+    public void copyAttribution(SourceInfo<T, U, V, W> i) {
+        this.attributionImage = i.attributionImage;
+        this.attributionImageURL = i.attributionImageURL;
+        this.attributionText = i.attributionText;
+        this.attributionLinkURL = i.attributionLinkURL;
+        this.termsOfUseText = i.termsOfUseText;
+        this.termsOfUseURL = i.termsOfUseURL;
+    }
+
+    /**
+     * Applies the attribution from this object to a tile source.
+     * @param s The tile source
+     */
+    public void setAttribution(AbstractTileSource s) {
+        if (attributionText != null) {
+            if ("osm".equals(attributionText)) {
+                s.setAttributionText(new Mapnik().getAttributionText(0, null, null));
+            } else {
+                s.setAttributionText(attributionText);
+            }
+        }
+        if (attributionLinkURL != null) {
+            if ("osm".equals(attributionLinkURL)) {
+                s.setAttributionLinkURL(new Mapnik().getAttributionLinkURL());
+            } else {
+                s.setAttributionLinkURL(attributionLinkURL);
+            }
+        }
+        if (attributionImage != null) {
+            ImageIcon i = ImageProvider.getIfAvailable(null, attributionImage);
+            if (i != null) {
+                s.setAttributionImage(i.getImage());
+            }
+        }
+        if (attributionImageURL != null) {
+            s.setAttributionImageURL(attributionImageURL);
+        }
+        if (termsOfUseText != null) {
+            s.setTermsOfUseText(termsOfUseText);
+        }
+        if (termsOfUseURL != null) {
+            if ("osm".equals(termsOfUseURL)) {
+                s.setTermsOfUseURL(new Mapnik().getTermsOfUseURL());
+            } else {
+                s.setTermsOfUseURL(termsOfUseURL);
+            }
+        }
+    }
+
+    /**
+     * Returns the source type.
+     * @return The source type
+     */
+    public U getSourceType() {
+        return sourceType;
+    }
+
+    /**
+     * Sets the source type.
+     * @param imageryType The source type
+     */
+    public void setSourceType(U imageryType) {
+        this.sourceType = imageryType;
+    }
+
+    /**
+     * Returns the source category.
+     * @return The source category
+     */
+    public T getSourceCategory() {
+        return category;
+    }
+
+    /**
+     * Sets the source category.
+     * @param category The source category
+     */
+    public void setSourceCategory(T category) {
+        this.category = category;
+    }
+
+    /**
+     * Returns the source category original string (don't use except for error checks).
+     * @return The source category original string
+     */
+    public String getSourceCategoryOriginalString() {
+        return categoryOriginalString;
+    }
+
+    /**
+     * Sets the source category original string (don't use except for error checks).
+     * @param categoryOriginalString The source category original string
+     */
+    public void setSourceCategoryOriginalString(String categoryOriginalString) {
+        this.categoryOriginalString = Utils.intern(categoryOriginalString);
+    }
+
+    /**
+     * Returns true if this layer's URL is matched by one of the regular
+     * expressions kept by the current OsmApi instance.
+     * @return {@code true} is this entry is blacklisted, {@code false} otherwise
+     */
+    public boolean isBlacklisted() {
+        Capabilities capabilities = OsmApi.getOsmApi().getCapabilities();
+        return capabilities != null && capabilities.isOnImageryBlacklist(this.url);
+    }
+
+    /**
+     * Sets the map of &lt;header name, header value&gt; that if any of this header
+     * will be returned, then this tile will be treated as "no tile at this zoom level"
+     *
+     * @param noTileHeaders Map of &lt;header name, header value&gt; which will be treated as "no tile at this zoom level"
+     * @since 9613
+     */
+    public void setNoTileHeaders(MultiMap<String, String> noTileHeaders) {
+       if (noTileHeaders == null || noTileHeaders.isEmpty()) {
+           this.noTileHeaders = null;
+       } else {
+            this.noTileHeaders = noTileHeaders.toMap();
+       }
+    }
+
+    @Override
+    public Map<String, Set<String>> getNoTileHeaders() {
+        return noTileHeaders;
+    }
+
+    /**
+     * Sets the map of &lt;checksum type, checksum value&gt; that if any tile with that checksum
+     * will be returned, then this tile will be treated as "no tile at this zoom level"
+     *
+     * @param noTileChecksums Map of &lt;checksum type, checksum value&gt; which will be treated as "no tile at this zoom level"
+     * @since 9613
+     */
+    public void setNoTileChecksums(MultiMap<String, String> noTileChecksums) {
+        if (noTileChecksums == null || noTileChecksums.isEmpty()) {
+            this.noTileChecksums = null;
+        } else {
+            this.noTileChecksums = noTileChecksums.toMap();
+        }
+    }
+
+    @Override
+    public Map<String, Set<String>> getNoTileChecksums() {
+        return noTileChecksums;
+    }
+
+    /**
+     * Returns the map of &lt;header name, metadata key&gt; indicating, which HTTP headers should
+     * be moved to metadata
+     *
+     * @param metadataHeaders map of &lt;header name, metadata key&gt; indicating, which HTTP headers should be moved to metadata
+     * @since 8418
+     */
+    public void setMetadataHeaders(Map<String, String> metadataHeaders) {
+        if (metadataHeaders == null || metadataHeaders.isEmpty()) {
+            this.metadataHeaders = null;
+        } else {
+            this.metadataHeaders = metadataHeaders;
+        }
+    }
+
+    /**
+     * Adds an old Id.
+     *
+     * @param id the Id to be added
+     * @since 13536
+     */
+    public void addOldId(String id) {
+       if (oldIds == null) {
+           oldIds = new ArrayList<>();
+       }
+       oldIds.add(id);
+    }
+
+    /**
+     * Get old Ids.
+     *
+     * @return collection of ids
+     * @since 13536
+     */
+    public Collection<String> getOldIds() {
+        return oldIds;
+    }
+
+    /**
+     * Returns default layers that should be shown for this Imagery (if at all supported by imagery provider)
+     * If no layer is set to default and there is more than one imagery available, then user will be asked to choose the layer
+     * to work on
+     * @return Collection of the layer names
+     */
+    public List<DefaultLayer> getDefaultLayers() {
+        return defaultLayers;
+    }
+
+    /**
+     * Sets the default layers that user will work with
+     * @param layers set the list of default layers
+     */
+    public void setDefaultLayers(List<DefaultLayer> layers) {
+        this.defaultLayers = Utils.toUnmodifiableList(layers);
+    }
+
+    /**
+     * Returns custom HTTP headers that should be sent with request towards imagery provider
+     * @return headers
+     */
+    public Map<String, String> getCustomHttpHeaders() {
+        return customHttpHeaders;
+    }
+
+    /**
+     * Sets custom HTTP headers that should be sent with request towards imagery provider
+     * @param customHttpHeaders http headers
+     */
+    public void setCustomHttpHeaders(Map<String, String> customHttpHeaders) {
+        this.customHttpHeaders = Utils.toUnmodifiableMap(customHttpHeaders);
+    }
+}
Index: src/org/openstreetmap/josm/data/sources/SourcePreferenceEntry.java
===================================================================
--- src/org/openstreetmap/josm/data/sources/SourcePreferenceEntry.java	(nonexistent)
+++ src/org/openstreetmap/josm/data/sources/SourcePreferenceEntry.java	(working copy)
@@ -0,0 +1,126 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.sources;
+
+import java.util.Map;
+
+import javax.json.stream.JsonCollectors;
+
+import org.openstreetmap.josm.data.StructUtils.StructEntry;
+import org.openstreetmap.josm.data.imagery.DefaultLayer;
+import org.openstreetmap.josm.data.imagery.Shape;
+import org.openstreetmap.josm.tools.Utils;
+
+/**
+ * A generic SourcePreferenceEntry that is used for storing data in JOSM preferences.
+ * This is intended to be removed, at some point. User beware.
+ *
+ * @author Taylor Smock
+ *
+ * @param <T> The type of SourceInfo
+ */
+public abstract class SourcePreferenceEntry<T extends SourceInfo<?, ?, ?, ?>> {
+    /** The name of the source */
+    @StructEntry public String name;
+    /** A *unique* id for the source */
+    @StructEntry public String id;
+    /** The type of the source (e.g., WMS, WMTS, etc.) */
+    @StructEntry public String type;
+    /** The URL for the source (base url) */
+    @StructEntry public String url;
+    /** The EULA for the source */
+    @StructEntry public String eula;
+    /** The attribution text for the source */
+    @StructEntry public String attribution_text;
+    /** The attribution URL for the source */
+    @StructEntry public String attribution_url;
+    /** The permission reference url (i.e., how do we know we have permission?) */
+    @StructEntry public String permission_reference_url;
+    /** The logo to be used for the source */
+    @StructEntry public String logo_image;
+    /** The logo url */
+    @StructEntry public String logo_url;
+    /** The TOU text */
+    @StructEntry public String terms_of_use_text;
+    /** The URL for the TOU */
+    @StructEntry public String terms_of_use_url;
+    /** The country code for the source (usually ISO 3166-1 alpha-2) */
+    @StructEntry public String country_code = "";
+    /** The date for the source */
+    @StructEntry public String date;
+    /** The cookies required to get the source */
+    @StructEntry public String cookies;
+    /** The bounds of the source */
+    @StructEntry public String bounds;
+    /** The shape of the source (mostly used for visual aid purposes) */
+    @StructEntry public String shapes;
+    /** The icon for the source (not necessarily the same as the logo) */
+    @StructEntry public String icon;
+    /** The description of the source */
+    @StructEntry public String description;
+    /** The default layers for the source, if any (mostly useful for imagery) */
+    @StructEntry public String default_layers;
+    /** Any custom HTTP headers */
+    @StructEntry public Map<String, String> customHttpHeaders;
+    /** The category string for the source */
+    @StructEntry public String category;
+
+    /**
+     * Constructs a new empty WMS {@code ImageryPreferenceEntry}.
+     */
+    public SourcePreferenceEntry() {
+        // Do nothing
+    }
+
+    /**
+     * Constructs a new {@code ImageryPreferenceEntry} from a given {@code ImageryInfo}.
+     * @param i The corresponding imagery info
+     */
+    public SourcePreferenceEntry(T i) {
+        name = i.getName();
+        id = i.getId();
+        type = i.sourceType.getTypeString();
+        url = i.getUrl();
+        eula = i.eulaAcceptanceRequired;
+        attribution_text = i.attributionText;
+        attribution_url = i.attributionLinkURL;
+        permission_reference_url = i.permissionReferenceURL;
+        date = i.date;
+        logo_image = i.attributionImage;
+        logo_url = i.attributionImageURL;
+        terms_of_use_text = i.termsOfUseText;
+        terms_of_use_url = i.termsOfUseURL;
+        country_code = i.countryCode;
+        cookies = i.getCookies();
+        icon = Utils.intern(i.icon);
+        description = i.description;
+        category = i.category != null ? i.category.getCategoryString() : null;
+        if (i.bounds != null) {
+            bounds = i.bounds.encodeAsString(",");
+            StringBuilder shapesString = new StringBuilder();
+            for (Shape s : i.bounds.getShapes()) {
+                if (shapesString.length() > 0) {
+                    shapesString.append(';');
+                }
+                shapesString.append(s.encodeAsString(","));
+            }
+            if (shapesString.length() > 0) {
+                shapes = shapesString.toString();
+            }
+        }
+
+        if (!i.defaultLayers.isEmpty()) {
+            default_layers = i.defaultLayers.stream().map(DefaultLayer::toJson).collect(JsonCollectors.toJsonArray()).toString();
+        }
+        customHttpHeaders = i.customHttpHeaders;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder s = new StringBuilder(getClass().getSimpleName()).append(" [name=").append(name);
+        if (id != null) {
+            s.append(" id=").append(id);
+        }
+        s.append("]");
+        return s.toString();
+    }
+}
Index: src/org/openstreetmap/josm/gui/ImageryMenu.java
===================================================================
--- src/org/openstreetmap/josm/gui/ImageryMenu.java	(revision 16248)
+++ src/org/openstreetmap/josm/gui/ImageryMenu.java	(working copy)
@@ -183,11 +183,11 @@
                     .sorted(alphabeticImageryComparator)
                     .collect(Collectors.toList());
             if (!inViewLayers.isEmpty()) {
-                if (inViewLayers.stream().anyMatch(i -> i.getImageryCategory() == ImageryCategory.PHOTO)) {
+                if (inViewLayers.stream().anyMatch(i -> i.getSourceCategory() == ImageryCategory.PHOTO)) {
                     addDynamicSeparator();
                 }
                 for (ImageryInfo i : inViewLayers) {
-                    addDynamic(trackJosmAction(new AddImageryLayerAction(i)), i.getImageryCategory());
+                    addDynamic(trackJosmAction(new AddImageryLayerAction(i)), i.getSourceCategory());
                 }
             }
             if (!dynamicNonPhotoItems.isEmpty()) {
Index: src/org/openstreetmap/josm/gui/layer/ImageryLayer.java
===================================================================
--- src/org/openstreetmap/josm/gui/layer/ImageryLayer.java	(revision 16248)
+++ src/org/openstreetmap/josm/gui/layer/ImageryLayer.java	(working copy)
@@ -121,7 +121,7 @@
         if (info != null) {
             List<List<String>> content = new ArrayList<>();
             content.add(Arrays.asList(tr("Name"), info.getName()));
-            content.add(Arrays.asList(tr("Type"), info.getImageryType().getTypeString().toUpperCase(Locale.ENGLISH)));
+            content.add(Arrays.asList(tr("Type"), info.getSourceType().getTypeString().toUpperCase(Locale.ENGLISH)));
             content.add(Arrays.asList(tr("URL"), info.getUrl()));
             content.add(Arrays.asList(tr("Id"), info.getId() == null ? "-" : info.getId()));
             if (info.getMinZoom() != 0) {
@@ -158,7 +158,7 @@
      * @return The created layer
      */
     public static ImageryLayer create(ImageryInfo info) {
-        switch(info.getImageryType()) {
+        switch(info.getSourceType()) {
         case WMS:
         case WMS_ENDPOINT:
             return new WMSLayer(info);
@@ -169,7 +169,7 @@
         case SCANEX:
             return new TMSLayer(info);
         default:
-            throw new AssertionError(tr("Unsupported imagery type: {0}", info.getImageryType()));
+            throw new AssertionError(tr("Unsupported imagery type: {0}", info.getSourceType()));
         }
     }
 
Index: src/org/openstreetmap/josm/gui/layer/TMSLayer.java
===================================================================
--- src/org/openstreetmap/josm/gui/layer/TMSLayer.java	(revision 16248)
+++ src/org/openstreetmap/josm/gui/layer/TMSLayer.java	(working copy)
@@ -114,14 +114,14 @@
      * @throws IllegalArgumentException if url from imagery info is null or invalid
      */
     public static TMSTileSource getTileSourceStatic(ImageryInfo info, Runnable attributionLoadedTask) {
-        if (info.getImageryType() == ImageryType.TMS) {
+        if (info.getSourceType() == ImageryType.TMS) {
             JosmTemplatedTMSTileSource.checkUrl(info.getUrl());
             TMSTileSource t = new JosmTemplatedTMSTileSource(info);
             info.setAttribution(t);
             return t;
-        } else if (info.getImageryType() == ImageryType.BING) {
+        } else if (info.getSourceType() == ImageryType.BING) {
             return new CachedAttributionBingAerialTileSource(info, attributionLoadedTask);
-        } else if (info.getImageryType() == ImageryType.SCANEX) {
+        } else if (info.getSourceType() == ImageryType.SCANEX) {
             return new ScanexTileSource(info);
         }
         return null;
Index: src/org/openstreetmap/josm/gui/layer/WMSLayer.java
===================================================================
--- src/org/openstreetmap/josm/gui/layer/WMSLayer.java	(revision 16248)
+++ src/org/openstreetmap/josm/gui/layer/WMSLayer.java	(working copy)
@@ -65,9 +65,9 @@
     public WMSLayer(ImageryInfo info) {
         super(info);
         CheckParameterUtil.ensureThat(
-                info.getImageryType() == ImageryType.WMS || info.getImageryType() == ImageryType.WMS_ENDPOINT, "ImageryType is WMS");
+                info.getSourceType() == ImageryType.WMS || info.getSourceType() == ImageryType.WMS_ENDPOINT, "ImageryType is WMS");
         CheckParameterUtil.ensureParameterNotNull(info.getUrl(), "info.url");
-        if (info.getImageryType() == ImageryType.WMS) {
+        if (info.getSourceType() == ImageryType.WMS) {
             TemplatedWMSTileSource.checkUrl(info.getUrl());
 
         }
@@ -93,7 +93,7 @@
     @Override
     protected AbstractWMSTileSource getTileSource() {
         AbstractWMSTileSource tileSource;
-        if (info.getImageryType() == ImageryType.WMS) {
+        if (info.getSourceType() == ImageryType.WMS) {
             tileSource = new TemplatedWMSTileSource(info, chooseProjection(ProjectionRegistry.getProjection()));
         } else {
             /*
Index: src/org/openstreetmap/josm/gui/layer/WMTSLayer.java
===================================================================
--- src/org/openstreetmap/josm/gui/layer/WMTSLayer.java	(revision 16248)
+++ src/org/openstreetmap/josm/gui/layer/WMTSLayer.java	(working copy)
@@ -57,7 +57,7 @@
     @Override
     protected WMTSTileSource getTileSource() {
         try {
-            if (info.getImageryType() == ImageryType.WMTS && info.getUrl() != null) {
+            if (info.getSourceType() == ImageryType.WMTS && info.getUrl() != null) {
                 WMTSTileSource.checkUrl(info.getUrl());
                 WMTSTileSource tileSource = new WMTSTileSource(info);
                 info.setAttribution(tileSource);
Index: src/org/openstreetmap/josm/gui/preferences/imagery/AddTMSLayerPanel.java
===================================================================
--- src/org/openstreetmap/josm/gui/preferences/imagery/AddTMSLayerPanel.java	(revision 16248)
+++ src/org/openstreetmap/josm/gui/preferences/imagery/AddTMSLayerPanel.java	(working copy)
@@ -79,7 +79,7 @@
     @Override
     public ImageryInfo getImageryInfo() {
         ImageryInfo ret = new ImageryInfo(getImageryName(), getTmsUrl());
-        ret.setImageryType(ImageryType.TMS);
+        ret.setSourceType(ImageryType.TMS);
         return ret;
 
     }
Index: src/org/openstreetmap/josm/gui/preferences/imagery/AddWMSLayerPanel.java
===================================================================
--- src/org/openstreetmap/josm/gui/preferences/imagery/AddWMSLayerPanel.java	(revision 16248)
+++ src/org/openstreetmap/josm/gui/preferences/imagery/AddWMSLayerPanel.java	(working copy)
@@ -170,7 +170,7 @@
         ImageryInfo info = null;
         if (endpoint.isSelected()) {
             info = new ImageryInfo(getImageryName(), getImageryRawUrl());
-            info.setImageryType(ImageryInfo.ImageryType.WMS_ENDPOINT);
+            info.setSourceType(ImageryInfo.ImageryType.WMS_ENDPOINT);
             if (setDefaultLayers.isSelected()) {
                 info.setDefaultLayers(tree.getSelectedLayers().stream()
                         .map(x -> new DefaultLayer(
@@ -189,7 +189,7 @@
             } else {
                 info = new ImageryInfo(getImageryName(), getWmsUrl());
             }
-            info.setImageryType(ImageryType.WMS);
+            info.setSourceType(ImageryType.WMS);
         }
         info.setGeoreferenceValid(getCommonIsValidGeoreference());
         info.setCustomHttpHeaders(getCommonHeaders());
Index: src/org/openstreetmap/josm/gui/preferences/imagery/AddWMTSLayerPanel.java
===================================================================
--- src/org/openstreetmap/josm/gui/preferences/imagery/AddWMTSLayerPanel.java	(revision 16248)
+++ src/org/openstreetmap/josm/gui/preferences/imagery/AddWMTSLayerPanel.java	(working copy)
@@ -105,7 +105,7 @@
         }
         ret.setCustomHttpHeaders(getCommonHeaders());
         ret.setGeoreferenceValid(getCommonIsValidGeoreference());
-        ret.setImageryType(ImageryType.WMTS);
+        ret.setSourceType(ImageryType.WMTS);
         try {
             new WMTSTileSource(ret); // check if constructor throws an error
         } catch (IOException | WMTSGetCapabilitiesException e) {
Index: src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java
===================================================================
--- src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java	(revision 16248)
+++ src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java	(working copy)
@@ -723,7 +723,7 @@
             ImageryInfo info = layerInfo.getAllDefaultLayers().get(row);
             switch (column) {
             case 0:
-                return Optional.ofNullable(info.getImageryCategory()).orElse(ImageryCategory.OTHER);
+                return Optional.ofNullable(info.getSourceCategory()).orElse(ImageryCategory.OTHER);
             case 1:
                 return info.getCountryCode();
             case 2:
Index: src/org/openstreetmap/josm/io/imagery/ImageryReader.java
===================================================================
--- src/org/openstreetmap/josm/io/imagery/ImageryReader.java	(revision 16248)
+++ src/org/openstreetmap/josm/io/imagery/ImageryReader.java	(working copy)
@@ -296,7 +296,7 @@
                 if ("layer".equals(qName)) {
                     newState = State.NOOP;
                     defaultLayers.add(new DefaultLayer(
-                            entry.getImageryType(),
+                            entry.getSourceType(),
                             atts.getValue("name"),
                             atts.getValue("style"),
                             atts.getValue("tile-matrix-set")
@@ -362,7 +362,7 @@
                         boolean found = false;
                         for (ImageryType type : ImageryType.values()) {
                             if (Objects.equals(accumulator.toString(), type.getTypeString())) {
-                                mirrorEntry.setImageryType(type);
+                                mirrorEntry.setSourceType(type);
                                 found = true;
                                 break;
                             }
@@ -433,9 +433,9 @@
                     entry.addOldId(accumulator.toString());
                     break;
                 case "type":
-                    ImageryType type = ImageryType.fromString(accumulator.toString());
+                    ImageryType type = ImageryType.getFromString(accumulator.toString());
                     if (type != null)
-                        entry.setImageryType(type);
+                        entry.setSourceType(type);
                     else
                         skipEntry = true;
                     break;
@@ -532,10 +532,10 @@
                     break;
                 case "category":
                     String cat = accumulator.toString();
-                    ImageryCategory category = ImageryCategory.fromString(cat);
+                    ImageryCategory category = ImageryCategory.getFromString(cat);
                     if (category != null)
-                        entry.setImageryCategory(category);
-                    entry.setImageryCategoryOriginalString(cat);
+                        entry.setSourceCategory(category);
+                    entry.setSourceCategoryOriginalString(cat);
                     break;
                 default: // Do nothing
                 }
Index: src/org/openstreetmap/josm/io/remotecontrol/handler/ImageryHandler.java
===================================================================
--- src/org/openstreetmap/josm/io/remotecontrol/handler/ImageryHandler.java	(revision 16248)
+++ src/org/openstreetmap/josm/io/remotecontrol/handler/ImageryHandler.java	(working copy)
@@ -49,7 +49,7 @@
 
     protected static ImageryInfo findBingEntry() {
         for (ImageryInfo i : ImageryLayerInfo.instance.getDefaultLayers()) {
-            if (ImageryType.BING == i.getImageryType()) {
+            if (ImageryType.BING == i.getSourceType()) {
                 return i;
             }
         }
Index: src/org/openstreetmap/josm/tools/Utils.java
===================================================================
--- src/org/openstreetmap/josm/tools/Utils.java	(revision 16248)
+++ src/org/openstreetmap/josm/tools/Utils.java	(working copy)
@@ -69,9 +69,10 @@
 import javax.script.ScriptEngine;
 import javax.script.ScriptEngineManager;
 
-import com.kitfox.svg.xml.XMLParseUtil;
 import org.openstreetmap.josm.spi.preferences.Config;
 
+import com.kitfox.svg.xml.XMLParseUtil;
+
 /**
  * Basic utils, that can be useful in different parts of the program.
  */
@@ -2012,4 +2013,14 @@
         // remove extra whitespaces
         return rawString.trim();
     }
+
+    /**
+     * Intern a string
+     * @param string The string to intern
+     * @return The interned string
+     * @since xxx
+     */
+    public static String intern(String string) {
+        return string == null ? null : string.intern();
+    }
 }
Index: test/unit/org/openstreetmap/josm/data/imagery/ImageryInfoTest.java
===================================================================
--- test/unit/org/openstreetmap/josm/data/imagery/ImageryInfoTest.java	(revision 16248)
+++ test/unit/org/openstreetmap/josm/data/imagery/ImageryInfoTest.java	(working copy)
@@ -48,7 +48,7 @@
     @Test
     public void testConstruct13264() {
         final ImageryInfo info = new ImageryInfo("test imagery", "tms[16-23]:http://localhost");
-        assertEquals(ImageryInfo.ImageryType.TMS, info.getImageryType());
+        assertEquals(ImageryInfo.ImageryType.TMS, info.getSourceType());
         assertEquals(16, info.getMinZoom());
         assertEquals(23, info.getMaxZoom());
         assertEquals("http://localhost", info.getUrl());
Index: test/unit/org/openstreetmap/josm/data/imagery/WMTSTileSourceTest.java
===================================================================
--- test/unit/org/openstreetmap/josm/data/imagery/WMTSTileSourceTest.java	(revision 16248)
+++ test/unit/org/openstreetmap/josm/data/imagery/WMTSTileSourceTest.java	(working copy)
@@ -74,7 +74,7 @@
                     "test",
                     new File(path).toURI().toURL().toString()
                     );
-            ret.setImageryType(ImageryType.WMTS);
+            ret.setSourceType(ImageryType.WMTS);
             return ret;
         } catch (MalformedURLException e) {
             e.printStackTrace();
Index: test/unit/org/openstreetmap/josm/gui/layer/TMSLayerTest.java
===================================================================
--- test/unit/org/openstreetmap/josm/gui/layer/TMSLayerTest.java	(revision 16248)
+++ test/unit/org/openstreetmap/josm/gui/layer/TMSLayerTest.java	(working copy)
@@ -51,7 +51,7 @@
     private static void test(ImageryType expected, TMSLayer layer) {
         try {
             MainApplication.getLayerManager().addLayer(layer);
-            assertEquals(expected, layer.getInfo().getImageryType());
+            assertEquals(expected, layer.getInfo().getSourceType());
         } finally {
             // Ensure we clean the place before leaving, even if test fails.
             MainApplication.getLayerManager().removeLayer(layer);
Index: test/unit/org/openstreetmap/josm/gui/layer/WMSLayerTest.java
===================================================================
--- test/unit/org/openstreetmap/josm/gui/layer/WMSLayerTest.java	(revision 16248)
+++ test/unit/org/openstreetmap/josm/gui/layer/WMSLayerTest.java	(working copy)
@@ -32,7 +32,7 @@
         WMSLayer wms = new WMSLayer(new ImageryInfo("test wms", "http://localhost"));
         MainApplication.getLayerManager().addLayer(wms);
         try {
-            assertEquals(ImageryType.WMS, wms.getInfo().getImageryType());
+            assertEquals(ImageryType.WMS, wms.getInfo().getSourceType());
         } finally {
             // Ensure we clean the place before leaving, even if test fails.
             MainApplication.getLayerManager().removeLayer(wms);
Index: test/unit/org/openstreetmap/josm/gui/layer/WMTSLayerTest.java
===================================================================
--- test/unit/org/openstreetmap/josm/gui/layer/WMTSLayerTest.java	(revision 16248)
+++ test/unit/org/openstreetmap/josm/gui/layer/WMTSLayerTest.java	(working copy)
@@ -29,6 +29,6 @@
     @Test
     public void testWMTSLayer() {
         WMTSLayer wmts = new WMTSLayer(new ImageryInfo("test wmts", "http://localhost", "wmts", null, null));
-        assertEquals(ImageryType.WMTS, wmts.getInfo().getImageryType());
+        assertEquals(ImageryType.WMTS, wmts.getInfo().getSourceType());
     }
 }
Index: test/unit/org/openstreetmap/josm/gui/preferences/imagery/ImageryPreferenceTestIT.java
===================================================================
--- test/unit/org/openstreetmap/josm/gui/preferences/imagery/ImageryPreferenceTestIT.java	(revision 16248)
+++ test/unit/org/openstreetmap/josm/gui/preferences/imagery/ImageryPreferenceTestIT.java	(working copy)
@@ -334,7 +334,7 @@
                 }
             }
             // checking max zoom for real is complex, see https://josm.openstreetmap.de/ticket/16073#comment:27
-            if (info.getMaxZoom() > 0 && info.getImageryType() != ImageryType.SCANEX) {
+            if (info.getMaxZoom() > 0 && info.getSourceType() != ImageryType.SCANEX) {
                 checkTileUrl(info, tileSource, center, Utils.clamp(DEFAULT_ZOOM, info.getMinZoom() + 1, info.getMaxZoom()));
             }
         } catch (IOException | RuntimeException | WMSGetCapabilitiesException | WMTSGetCapabilitiesException e) {
@@ -366,7 +366,7 @@
     @SuppressWarnings("fallthrough")
     private static AbstractTileSource getTileSource(ImageryInfo info)
             throws IOException, WMTSGetCapabilitiesException, WMSGetCapabilitiesException {
-        switch (info.getImageryType()) {
+        switch (info.getSourceType()) {
             case BING:
                 return new BingAerialTileSource(info);
             case SCANEX:
