From 8abac2cf71fab94781f6d041a6f7d06af0d82026 Mon Sep 17 00:00:00 2001 From: "Jan N. Klug" Date: Sat, 11 Dec 2021 20:00:05 +0100 Subject: [PATCH 1/6] [addonservices] allow uninstalling of removed addons Signed-off-by: Jan N. Klug --- .../pom.xml | 6 + .../AbstractRemoteAddonService.java | 205 +++++++++++++++++ .../CommunityMarketplaceAddonService.java | 211 +++++------------- .../model/DiscourseCategoryResponseDTO.java | 40 ++-- .../model/DiscourseTopicResponseDTO.java | 61 +++-- .../internal/json/JsonAddonService.java | 170 +++----------- .../internal/json/model/AddonEntryDTO.java | 2 + .../test/AbstractRemoteAddonServiceTest.java | 103 +++++++++ .../core/addon/test/TestAddonService.java | 96 ++++++++ .../core/addon/test/VirtualAddonHandler.java | 63 ++++++ 10 files changed, 621 insertions(+), 336 deletions(-) create mode 100644 bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java create mode 100644 bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java create mode 100644 bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonService.java create mode 100644 bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/VirtualAddonHandler.java diff --git a/bundles/org.openhab.core.addon.marketplace/pom.xml b/bundles/org.openhab.core.addon.marketplace/pom.xml index 396bf0fbb61..1c6ddc6c2f4 100644 --- a/bundles/org.openhab.core.addon.marketplace/pom.xml +++ b/bundles/org.openhab.core.addon.marketplace/pom.xml @@ -38,6 +38,12 @@ ${project.version} compile + + org.openhab.core.bundles + org.openhab.core.test + ${project.version} + test + diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java new file mode 100644 index 00000000000..22d0191c571 --- /dev/null +++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java @@ -0,0 +1,205 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.addon.marketplace; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Dictionary; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.addon.Addon; +import org.openhab.core.addon.AddonEventFactory; +import org.openhab.core.addon.AddonService; +import org.openhab.core.addon.AddonType; +import org.openhab.core.cache.ExpiringCache; +import org.openhab.core.events.Event; +import org.openhab.core.events.EventPublisher; +import org.openhab.core.storage.Storage; +import org.openhab.core.storage.StorageService; +import org.osgi.service.cm.Configuration; +import org.osgi.service.cm.ConfigurationAdmin; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +/** + * The {@link AbstractRemoteAddonService} implements basic functionality of a remote add-on-service + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractRemoteAddonService implements AddonService { + protected static final Map TAG_ADDON_TYPE_MAP = Map.of( // + "automation", new AddonType("automation", "Automation"), // + "binding", new AddonType("binding", "Bindings"), // + "misc", new AddonType("misc", "Misc"), // + "persistence", new AddonType("persistence", "Persistence"), // + "transformation", new AddonType("transformation", "Transformations"), // + "ui", new AddonType("ui", "User Interfaces"), // + "voice", new AddonType("voice", "Voice")); + + protected final Gson gson = new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").create(); + protected final Set addonHandlers = new HashSet<>(); + protected final Storage installedAddonStorage; + protected final EventPublisher eventPublisher; + protected final ConfigurationAdmin configurationAdmin; + protected final ExpiringCache> cachedRemoteAddons = new ExpiringCache<>(Duration.ofMinutes(15), + this::getRemoteAddons); + protected List cachedAddons = List.of(); + protected List installedAddons = List.of(); + + public AbstractRemoteAddonService(EventPublisher eventPublisher, ConfigurationAdmin configurationAdmin, + StorageService storageService, String servicePid) { + this.eventPublisher = eventPublisher; + this.configurationAdmin = configurationAdmin; + this.installedAddonStorage = storageService.getStorage(servicePid); + } + + @Override + public void refreshSource() { + List addons = new ArrayList<>(); + installedAddonStorage.stream().map(e -> Objects.requireNonNull(gson.fromJson(e.getValue(), Addon.class))) + .forEach(addons::add); + addons.forEach(a -> a.setInstalled(true)); + + // create lookup list to make sure installed addons take precedence + List installedAddons = addons.stream().map(Addon::getId).collect(Collectors.toList()); + + if (remoteEnabled()) { + List remoteAddons = Objects.requireNonNullElse(cachedRemoteAddons.getValue(), List.of()); + remoteAddons.stream().filter(a -> !installedAddons.contains(a.getId())).forEach(addons::add); + } + + cachedAddons = addons; + this.installedAddons = installedAddons; + } + + /** + * get all addons from remote + * + * @return a list of {@link Addon} that are available on the remote side + */ + protected abstract List getRemoteAddons(); + + @Override + public List getAddons(@Nullable Locale locale) { + refreshSource(); + return cachedAddons; + } + + @Override + public abstract @Nullable Addon getAddon(String id, @Nullable Locale locale); + + @Override + public List getTypes(@Nullable Locale locale) { + return new ArrayList<>(TAG_ADDON_TYPE_MAP.values()); + } + + @Override + public void install(String id) { + Addon addon = getAddon(id, null); + if (addon != null) { + for (MarketplaceAddonHandler handler : addonHandlers) { + if (handler.supports(addon.getType(), addon.getContentType())) { + if (!handler.isInstalled(addon.getId())) { + try { + handler.install(addon); + installedAddonStorage.put(id, gson.toJson(addon)); + cachedRemoteAddons.invalidateValue(); + postInstalledEvent(addon.getId()); + } catch (MarketplaceHandlerException e) { + postFailureEvent(addon.getId(), e.getMessage()); + } + } else { + postFailureEvent(addon.getId(), "Add-on is already installed."); + } + return; + } + } + } + postFailureEvent(id, "Add-on not known."); + } + + @Override + public void uninstall(String id) { + Addon addon = getAddon(id, null); + if (addon != null) { + for (MarketplaceAddonHandler handler : addonHandlers) { + if (handler.supports(addon.getType(), addon.getContentType())) { + if (handler.isInstalled(addon.getId())) { + try { + handler.uninstall(addon); + installedAddonStorage.remove(id); + cachedRemoteAddons.invalidateValue(); + postUninstalledEvent(addon.getId()); + } catch (MarketplaceHandlerException e) { + postFailureEvent(addon.getId(), e.getMessage()); + } + } else { + installedAddonStorage.remove(id); + postFailureEvent(addon.getId(), "Add-on is not installed."); + } + return; + } + } + } + postFailureEvent(id, "Add-on not known."); + } + + @Override + public abstract @Nullable String getAddonId(URI addonURI); + + /** + * check if remote services are enabled + * + * @return true if network access is allowed + */ + protected boolean remoteEnabled() { + try { + Configuration configuration = configurationAdmin.getConfiguration("org.openhab.addons", null); + Dictionary properties = configuration.getProperties(); + if (properties == null) { + // if we can't determine a set property, we use true (default is remote enabled) + return true; + } + return (boolean) Objects.requireNonNullElse(properties.get("remote"), true); + } catch (IOException e) { + return true; + } + } + + private void postInstalledEvent(String extensionId) { + Event event = AddonEventFactory.createAddonInstalledEvent(extensionId); + eventPublisher.post(event); + } + + private void postUninstalledEvent(String extensionId) { + Event event = AddonEventFactory.createAddonUninstalledEvent(extensionId); + eventPublisher.post(event); + } + + private void postFailureEvent(String extensionId, @Nullable String msg) { + Event event = AddonEventFactory.createAddonFailureEvent(extensionId, msg); + eventPublisher.post(event); + } +} diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java index f7a40eca155..7021a48dc07 100644 --- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java +++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java @@ -14,7 +14,6 @@ import static org.openhab.core.addon.Addon.CODE_MATURITY_LEVELS; -import java.io.IOException; import java.io.InputStreamReader; import java.io.Reader; import java.net.URI; @@ -25,23 +24,20 @@ import java.util.Date; import java.util.Dictionary; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.addon.Addon; -import org.openhab.core.addon.AddonEventFactory; import org.openhab.core.addon.AddonService; import org.openhab.core.addon.AddonType; +import org.openhab.core.addon.marketplace.AbstractRemoteAddonService; import org.openhab.core.addon.marketplace.MarketplaceAddonHandler; -import org.openhab.core.addon.marketplace.MarketplaceHandlerException; import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO; import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO.DiscoursePosterInfo; import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO.DiscourseTopicItem; @@ -50,10 +46,9 @@ import org.openhab.core.addon.marketplace.internal.community.model.DiscourseTopicResponseDTO.DiscoursePostLink; import org.openhab.core.config.core.ConfigParser; import org.openhab.core.config.core.ConfigurableService; -import org.openhab.core.events.Event; import org.openhab.core.events.EventPublisher; +import org.openhab.core.storage.StorageService; import org.osgi.framework.Constants; -import org.osgi.service.cm.Configuration; import org.osgi.service.cm.ConfigurationAdmin; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; @@ -64,19 +59,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; - /** - * This class is a {@link AddonService} retrieving posts on community.openhab.org (Discourse). + * This class is an {@link org.openhab.core.addon.AddonService} retrieving posts on community.openhab.org (Discourse). * * @author Yannick Schaus - Initial contribution */ -@Component(immediate = true, configurationPid = "org.openhab.marketplace", // - property = Constants.SERVICE_PID + "=org.openhab.marketplace") -@ConfigurableService(category = "system", label = "Community Marketplace", description_uri = CommunityMarketplaceAddonService.CONFIG_URI) +@Component(immediate = true, configurationPid = CommunityMarketplaceAddonService.SERVICE_PID, // + property = Constants.SERVICE_PID + "=" + + CommunityMarketplaceAddonService.SERVICE_PID, service = AddonService.class) +@ConfigurableService(category = "system", label = CommunityMarketplaceAddonService.SERVICE_NAME, description_uri = CommunityMarketplaceAddonService.CONFIG_URI) @NonNullByDefault -public class CommunityMarketplaceAddonService implements AddonService { +public class CommunityMarketplaceAddonService extends AbstractRemoteAddonService { public static final String JAR_CONTENT_TYPE = "application/vnd.openhab.bundle"; public static final String KAR_CONTENT_TYPE = "application/vnd.openhab.feature;type=karfile"; public static final String RULETEMPLATES_CONTENT_TYPE = "application/vnd.openhab.ruletemplate"; @@ -84,16 +77,18 @@ public class CommunityMarketplaceAddonService implements AddonService { public static final String BLOCKLIBRARIES_CONTENT_TYPE = "application/vnd.openhab.uicomponent;type=blocks"; // constants for the configuration properties + static final String SERVICE_NAME = "Community Marketplace"; + static final String SERVICE_PID = "org.openhab.marketplace"; static final String CONFIG_URI = "system:marketplace"; static final String CONFIG_API_KEY = "apiKey"; static final String CONFIG_SHOW_UNPUBLISHED_ENTRIES_KEY = "showUnpublished"; - private static final String CFG_REMOTE = "remote"; private static final String COMMUNITY_BASE_URL = "https://community.openhab.org"; private static final String COMMUNITY_MARKETPLACE_URL = COMMUNITY_BASE_URL + "/c/marketplace/69/l/latest"; private static final String COMMUNITY_TOPIC_URL = COMMUNITY_BASE_URL + "/t/"; - private static final String ADDON_ID_PREFIX = "marketplace:"; + private static final String SERVICE_ID = "marketplace"; + private static final String ADDON_ID_PREFIX = SERVICE_ID + ":"; private static final String JSON_CODE_MARKUP_START = "
";
     private static final String YAML_CODE_MARKUP_START = "
";
@@ -106,44 +101,28 @@ public class CommunityMarketplaceAddonService implements AddonService {
 
     private static final String PUBLISHED_TAG = "published";
 
-    private static final Map TAG_ADDON_TYPE_MAP = Map.of( //
-            "automation", new AddonType("automation", "Automation"), //
-            "binding", new AddonType("binding", "Bindings"), //
-            "misc", new AddonType("misc", "Misc"), //
-            "persistence", new AddonType("persistence", "Persistence"), //
-            "transformation", new AddonType("transformation", "Transformations"), //
-            "ui", new AddonType("ui", "User Interfaces"), //
-            "voice", new AddonType("voice", "Voice"));
-
     private final Logger logger = LoggerFactory.getLogger(CommunityMarketplaceAddonService.class);
-    private final Gson gson = new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").create();
-    private final Set addonHandlers = new HashSet<>();
-
-    private final EventPublisher eventPublisher;
-    private final ConfigurationAdmin configurationAdmin;
 
     private @Nullable String apiKey = null;
     private boolean showUnpublished = false;
 
     @Activate
     public CommunityMarketplaceAddonService(final @Reference EventPublisher eventPublisher,
-            @Reference ConfigurationAdmin configurationAdmin) {
-        this.eventPublisher = eventPublisher;
-        this.configurationAdmin = configurationAdmin;
-    }
-
-    @Activate
-    protected void activate(Map config) {
+            @Reference ConfigurationAdmin configurationAdmin, @Reference StorageService storageService,
+            Map config) {
+        super(eventPublisher, configurationAdmin, storageService, SERVICE_PID);
         modified(config);
     }
 
     @Modified
-    void modified(@Nullable Map config) {
+    public void modified(@Nullable Map config) {
         if (config != null) {
             this.apiKey = (String) config.get(CONFIG_API_KEY);
             Object showUnpublishedConfigValue = config.get(CONFIG_SHOW_UNPUBLISHED_ENTRIES_KEY);
             this.showUnpublished = showUnpublishedConfigValue != null
                     && "true".equals(showUnpublishedConfigValue.toString());
+            cachedRemoteAddons.invalidateValue();
+            refreshSource();
         }
     }
 
@@ -158,24 +137,17 @@ protected void removeAddonHandler(MarketplaceAddonHandler handler) {
 
     @Override
     public String getId() {
-        return "marketplace";
+        return SERVICE_ID;
     }
 
     @Override
     public String getName() {
-        return "Community Marketplace";
+        return SERVICE_NAME;
     }
 
     @Override
-    public void refreshSource() {
-    }
-
-    @Override
-    public List getAddons(@Nullable Locale locale) {
-        if (!remoteEnabled()) {
-            return List.of();
-        }
-
+    protected List getRemoteAddons() {
+        List addons = new ArrayList<>();
         try {
             List pages = new ArrayList<>();
 
@@ -190,11 +162,11 @@ public List getAddons(@Nullable Locale locale) {
 
                 try (Reader reader = new InputStreamReader(connection.getInputStream())) {
                     DiscourseCategoryResponseDTO parsed = gson.fromJson(reader, DiscourseCategoryResponseDTO.class);
-                    if (parsed.topic_list.topics.length != 0) {
+                    if (parsed.topicList.topics.length != 0) {
                         pages.add(parsed);
                     }
 
-                    if (parsed.topic_list.more_topics_url != null) {
+                    if (parsed.topicList.moreTopicsUrl != null) {
                         // Discourse URL for next page is wrong
                         url = new URL(COMMUNITY_MARKETPLACE_URL + "?page=" + pageNb++);
                     } else {
@@ -204,24 +176,31 @@ public List getAddons(@Nullable Locale locale) {
             }
 
             List users = pages.stream().flatMap(p -> Stream.of(p.users)).collect(Collectors.toList());
-            return pages.stream().flatMap(p -> Stream.of(p.topic_list.topics))
+            pages.stream().flatMap(p -> Stream.of(p.topicList.topics))
                     .filter(t -> showUnpublished || Arrays.asList(t.tags).contains(PUBLISHED_TAG))
-                    .map(t -> convertTopicItemToAddon(t, users)).collect(Collectors.toList());
+                    .map(t -> convertTopicItemToAddon(t, users)).forEach(addons::add);
         } catch (Exception e) {
             logger.error("Unable to retrieve marketplace add-ons", e);
-            return List.of();
         }
+        return addons;
     }
 
     @Override
     public @Nullable Addon getAddon(String id, @Nullable Locale locale) {
+        String fullId = ADDON_ID_PREFIX + id;
+        // check if it is an installed add-on (cachedAddons also contains possibly incomplete results from the remote
+        // side, we need to retrieve them from Discourse)
+        if (installedAddons.contains(id)) {
+            return cachedAddons.stream().filter(e -> fullId.equals(e.getId())).findAny().orElse(null);
+        }
+
         if (!remoteEnabled()) {
             return null;
         }
 
-        URL url;
+        // retrieve from remote
         try {
-            url = new URL(String.format("%s%s", COMMUNITY_TOPIC_URL, id.replace(ADDON_ID_PREFIX, "")));
+            URL url = new URL(String.format("%s%s", COMMUNITY_TOPIC_URL, id.replace(ADDON_ID_PREFIX, "")));
             URLConnection connection = url.openConnection();
             connection.addRequestProperty("Accept", "application/json");
             if (this.apiKey != null) {
@@ -237,57 +216,6 @@ public List getAddons(@Nullable Locale locale) {
         }
     }
 
-    @Override
-    public List getTypes(@Nullable Locale locale) {
-        return new ArrayList<>(TAG_ADDON_TYPE_MAP.values());
-    }
-
-    @Override
-    public void install(String id) {
-        Addon addon = getAddon(id, null);
-        if (addon != null) {
-            for (MarketplaceAddonHandler handler : addonHandlers) {
-                if (handler.supports(addon.getType(), addon.getContentType())) {
-                    if (!handler.isInstalled(addon.getId())) {
-                        try {
-                            handler.install(addon);
-                            postInstalledEvent(id);
-                        } catch (MarketplaceHandlerException e) {
-                            postFailureEvent(id, e.getMessage());
-                        }
-                    } else {
-                        postFailureEvent(id, "Add-on is already installed.");
-                    }
-                    return;
-                }
-            }
-        }
-        postFailureEvent(id, "Add-on not known.");
-    }
-
-    @Override
-    public void uninstall(String id) {
-        Addon addon = getAddon(id, null);
-        if (addon != null) {
-            for (MarketplaceAddonHandler handler : addonHandlers) {
-                if (handler.supports(addon.getType(), addon.getContentType())) {
-                    if (handler.isInstalled(addon.getId())) {
-                        try {
-                            handler.uninstall(addon);
-                            postUninstalledEvent(id);
-                        } catch (MarketplaceHandlerException e) {
-                            postFailureEvent(id, e.getMessage());
-                        }
-                    } else {
-                        postFailureEvent(id, "Add-on is not installed.");
-                    }
-                    return;
-                }
-            }
-        }
-        postFailureEvent(id, "Add-on not known.");
-    }
-
     @Override
     public @Nullable String getAddonId(URI addonURI) {
         if (addonURI.toString().startsWith(COMMUNITY_TOPIC_URL)) {
@@ -344,20 +272,20 @@ private Addon convertTopicItemToAddon(DiscourseTopicItem topic, List tags = Arrays.asList(Objects.requireNonNullElse(topic.tags, new String[0]));
 
         String id = ADDON_ID_PREFIX + topic.id.toString();
-        AddonType addonType = getAddonType(topic.category_id, tags);
+        AddonType addonType = getAddonType(topic.categoryId, tags);
         String type = (addonType != null) ? addonType.getId() : "";
-        String contentType = getContentType(topic.category_id, tags);
+        String contentType = getContentType(topic.categoryId, tags);
 
         String title = topic.title;
         String link = COMMUNITY_TOPIC_URL + topic.id.toString();
-        int likeCount = topic.like_count;
+        int likeCount = topic.likeCount;
         int views = topic.views;
-        int postsCount = topic.posts_count;
-        Date createdDate = topic.created_at;
+        int postsCount = topic.postsCount;
+        Date createdDate = topic.createdAt;
         String author = "";
         for (DiscoursePosterInfo posterInfo : topic.posters) {
             if (posterInfo.description.contains("Original Poster")) {
-                author = users.stream().filter(u -> u.id.equals(posterInfo.user_id)).findFirst().get().name;
+                author = users.stream().filter(u -> u.id.equals(posterInfo.userId)).findFirst().get().name;
             }
         }
 
@@ -373,7 +301,7 @@ private Addon convertTopicItemToAddon(DiscourseTopicItem topic, List handler.supports(type, contentType) && handler.isInstalled(id));
 
-        return Addon.create(id).withType(type).withContentType(contentType).withImageLink(topic.image_url)
+        return Addon.create(id).withType(type).withContentType(contentType).withImageLink(topic.imageUrl)
                 .withAuthor(author).withProperties(properties).withLabel(title).withInstalled(installed)
                 .withMaturity(maturity).withLink(link).build();
     }
@@ -399,16 +327,16 @@ private Addon convertTopicToAddon(DiscourseTopicResponseDTO topic) {
         String id = ADDON_ID_PREFIX + topic.id.toString();
         List tags = Arrays.asList(Objects.requireNonNullElse(topic.tags, new String[0]));
 
-        AddonType addonType = getAddonType(topic.category_id, tags);
+        AddonType addonType = getAddonType(topic.categoryId, tags);
         String type = (addonType != null) ? addonType.getId() : "";
-        String contentType = getContentType(topic.category_id, tags);
+        String contentType = getContentType(topic.categoryId, tags);
 
-        int likeCount = topic.like_count;
+        int likeCount = topic.likeCount;
         int views = topic.views;
-        int postsCount = topic.posts_count;
-        Date createdDate = topic.post_stream.posts[0].created_at;
-        Date updatedDate = topic.post_stream.posts[0].updated_at;
-        Date lastPostedDate = topic.last_posted;
+        int postsCount = topic.postsCount;
+        Date createdDate = topic.postStream.posts[0].createdAt;
+        Date updatedDate = topic.postStream.posts[0].updatedAt;
+        Date lastPostedDate = topic.lastPosted;
 
         String maturity = tags.stream().filter(CODE_MATURITY_LEVELS::contains).findAny().orElse(null);
 
@@ -421,11 +349,11 @@ private Addon convertTopicToAddon(DiscourseTopicResponseDTO topic) {
         properties.put("posts_count", postsCount);
         properties.put("tags", tags.toArray(String[]::new));
 
-        String detailedDescription = topic.post_stream.posts[0].cooked;
+        String detailedDescription = topic.postStream.posts[0].cooked;
 
         // try to extract contents or links
-        if (topic.post_stream.posts[0].link_counts != null) {
-            for (DiscoursePostLink postLink : topic.post_stream.posts[0].link_counts) {
+        if (topic.postStream.posts[0].linkCounts != null) {
+            for (DiscoursePostLink postLink : topic.postStream.posts[0].linkCounts) {
                 if (postLink.url.endsWith(".jar")) {
                     properties.put("jar_download_url", postLink.url);
                 }
@@ -458,38 +386,9 @@ private Addon convertTopicToAddon(DiscourseTopicResponseDTO topic) {
                 .anyMatch(handler -> handler.supports(type, contentType) && handler.isInstalled(id));
 
         return Addon.create(id).withType(type).withContentType(contentType).withLabel(topic.title)
-                .withImageLink(topic.image_url).withLink(COMMUNITY_TOPIC_URL + topic.id.toString())
-                .withAuthor(topic.post_stream.posts[0].display_username).withMaturity(maturity)
+                .withImageLink(topic.imageUrl).withLink(COMMUNITY_TOPIC_URL + topic.id.toString())
+                .withAuthor(topic.postStream.posts[0].displayUsername).withMaturity(maturity)
                 .withDetailedDescription(detailedDescription).withInstalled(installed).withProperties(properties)
                 .build();
     }
-
-    private void postInstalledEvent(String extensionId) {
-        Event event = AddonEventFactory.createAddonInstalledEvent(extensionId);
-        eventPublisher.post(event);
-    }
-
-    private void postUninstalledEvent(String extensionId) {
-        Event event = AddonEventFactory.createAddonUninstalledEvent(extensionId);
-        eventPublisher.post(event);
-    }
-
-    private void postFailureEvent(String extensionId, @Nullable String msg) {
-        Event event = AddonEventFactory.createAddonFailureEvent(extensionId, msg);
-        eventPublisher.post(event);
-    }
-
-    private boolean remoteEnabled() {
-        try {
-            Configuration configuration = configurationAdmin.getConfiguration("org.openhab.addons", null);
-            Dictionary properties = configuration.getProperties();
-            if (properties == null) {
-                // if we can't determine a set property, we use true (default is remote enabled)
-                return true;
-            }
-            return ConfigParser.valueAsOrElse(properties.get(CFG_REMOTE), Boolean.class, true);
-        } catch (IOException e) {
-            return true;
-        }
-    }
 }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseCategoryResponseDTO.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseCategoryResponseDTO.java
index 286956f4f86..7d227c80cac 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseCategoryResponseDTO.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseCategoryResponseDTO.java
@@ -14,6 +14,8 @@
 
 import java.util.Date;
 
+import com.google.gson.annotations.SerializedName;
+
 /**
  * A DTO class mapped to the Discourse category topic list API.
  *
@@ -21,38 +23,48 @@
  */
 public class DiscourseCategoryResponseDTO {
     public DiscourseUser[] users;
-    public DiscourseTopicList topic_list;
+    @SerializedName("topic_list")
+    public DiscourseTopicList topicList;
 
-    public class DiscourseUser {
+    public static class DiscourseUser {
         public Integer id;
         public String username;
         public String name;
-        public String avatar_template;
+        @SerializedName("avatar_template")
+        public String avatarTemplate;
     }
 
-    public class DiscourseTopicList {
-        public String more_topics_url;
-        public Integer per_page;
+    public static class DiscourseTopicList {
+        @SerializedName("more_topics_url")
+        public String moreTopicsUrl;
+        @SerializedName("per_page")
+        public Integer perPage;
         public DiscourseTopicItem[] topics;
     }
 
-    public class DiscoursePosterInfo {
+    public static class DiscoursePosterInfo {
         public String extras;
         public String description;
-        public Integer user_id;
+        @SerializedName("user_id")
+        public Integer userId;
     }
 
-    public class DiscourseTopicItem {
+    public static class DiscourseTopicItem {
         public Integer id;
         public String title;
         public String slug;
         public String[] tags;
-        public Integer posts_count;
-        public String image_url;
-        public Date created_at;
-        public Integer like_count;
+        @SerializedName("posts_count")
+        public Integer postsCount;
+        @SerializedName("image_url")
+        public String imageUrl;
+        @SerializedName("created_at")
+        public Date createdAt;
+        @SerializedName("like_count")
+        public Integer likeCount;
         public Integer views;
-        public Integer category_id;
+        @SerializedName("category_id")
+        public Integer categoryId;
         public DiscoursePosterInfo[] posters;
     }
 }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseTopicResponseDTO.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseTopicResponseDTO.java
index 90ed5c510e9..22b0894f743 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseTopicResponseDTO.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseTopicResponseDTO.java
@@ -14,6 +14,8 @@
 
 import java.util.Date;
 
+import com.google.gson.annotations.SerializedName;
+
 /**
  * A DTO class mapped to the Discourse topic API.
  *
@@ -22,57 +24,72 @@
 public class DiscourseTopicResponseDTO {
     public Integer id;
 
-    public DiscoursePostStream post_stream;
+    @SerializedName("post_stream")
+    public DiscoursePostStream postStream;
 
     public String title;
-    public Integer posts_count;
-    public String image_url;
-
-    public Date created_at;
-    public Date updated_at;
-    public Date last_posted;
-
-    public Integer like_count;
+    @SerializedName("posts_count")
+    public Integer postsCount;
+    @SerializedName("image_url")
+    public String imageUrl;
+
+    @SerializedName("created_at")
+    public Date createdAt;
+    @SerializedName("updated_at")
+    public Date updatedAt;
+    @SerializedName("last_posted")
+    public Date lastPosted;
+
+    @SerializedName("like_count")
+    public Integer likeCount;
     public Integer views;
 
     public String[] tags;
-    public Integer category_id;
+    @SerializedName("category_id")
+    public Integer categoryId;
 
     public DiscourseTopicDetails details;
 
-    public class DiscoursePostAuthor {
+    public static class DiscoursePostAuthor {
         public Integer id;
         public String username;
-        public String avatar_template;
+        @SerializedName("avatar_template")
+        public String avatarTemplate;
     }
 
-    public class DiscoursePostLink {
+    public static class DiscoursePostLink {
         public String url;
         public Boolean internal;
         public Integer clicks;
     }
 
-    public class DiscoursePostStream {
+    public static class DiscoursePostStream {
         public DiscoursePost[] posts;
     }
 
-    public class DiscoursePost {
+    public static class DiscoursePost {
         public Integer id;
 
         public String username;
-        public String display_username;
+        @SerializedName("display_username")
+        public String displayUsername;
 
-        public Date created_at;
-        public Date updated_at;
+        @SerializedName("created_at")
+        public Date createdAt;
+        @SerializedName("updated_at")
+        public Date updatedAt;
 
         public String cooked;
 
-        public DiscoursePostLink[] link_counts;
+        @SerializedName("link_counts")
+        public DiscoursePostLink[] linkCounts;
     }
 
-    public class DiscourseTopicDetails {
-        public DiscoursePostAuthor created_by;
-        public DiscoursePostAuthor last_poster;
+    public static class DiscourseTopicDetails {
+        @SerializedName("created_by")
+        public DiscoursePostAuthor createdBy;
+        @SerializedName("last_poster")
+        public DiscoursePostAuthor lastPoster;
 
         public DiscoursePostLink[] links;
     }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java
index facbc698791..c35184249c6 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java
@@ -19,33 +19,27 @@
 import java.net.URI;
 import java.net.URL;
 import java.net.URLConnection;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Dictionary;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
-import java.util.Set;
 import java.util.stream.Collectors;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.openhab.core.addon.Addon;
-import org.openhab.core.addon.AddonEventFactory;
 import org.openhab.core.addon.AddonService;
-import org.openhab.core.addon.AddonType;
+import org.openhab.core.addon.marketplace.AbstractRemoteAddonService;
 import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
-import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
 import org.openhab.core.addon.marketplace.internal.json.model.AddonEntryDTO;
 import org.openhab.core.config.core.ConfigParser;
 import org.openhab.core.config.core.ConfigurableService;
-import org.openhab.core.events.Event;
 import org.openhab.core.events.EventPublisher;
+import org.openhab.core.storage.StorageService;
 import org.osgi.framework.Constants;
-import org.osgi.service.cm.Configuration;
 import org.osgi.service.cm.ConfigurationAdmin;
 import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
@@ -53,70 +47,49 @@
 import org.osgi.service.component.annotations.Reference;
 import org.osgi.service.component.annotations.ReferenceCardinality;
 import org.osgi.service.component.annotations.ReferencePolicy;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
 import com.google.gson.reflect.TypeToken;
 
 /**
- * This class is a {@link AddonService} retrieving JSON marketplace information.
+ * This class implements an {@link org.openhab.core.addon.AddonService} retrieving JSON marketplace information.
  *
  * @author Yannick Schaus - Initial contribution
  * @author Jan N. Klug - Refactored for JSON marketplaces
  */
-@Component(immediate = true, configurationPid = { "org.openhab.jsonaddonservice" }, //
-        property = Constants.SERVICE_PID + "=org.openhab.jsonaddonservice")
+@Component(immediate = true, configurationPid = JsonAddonService.SERVICE_PID, //
+        property = Constants.SERVICE_PID + "=" + JsonAddonService.SERVICE_PID, service = AddonService.class)
 @ConfigurableService(category = "system", label = JsonAddonService.SERVICE_NAME, description_uri = JsonAddonService.CONFIG_URI)
 @NonNullByDefault
-public class JsonAddonService implements AddonService {
-    private final Logger logger = LoggerFactory.getLogger(JsonAddonService.class);
-
+public class JsonAddonService extends AbstractRemoteAddonService {
     static final String SERVICE_NAME = "Json 3rd Party Add-on Service";
     static final String CONFIG_URI = "system:jsonaddonservice";
+    static final String SERVICE_PID = "org.openhab.jsonaddonservice";
 
     private static final String SERVICE_ID = "json";
     private static final String ADDON_ID_PREFIX = SERVICE_ID + ":";
 
     private static final String CONFIG_URLS = "urls";
     private static final String CONFIG_SHOW_UNSTABLE = "showUnstable";
-    private static final String CFG_REMOTE = "remote";
-
-    private static final Map TAG_ADDON_TYPE_MAP = Map.of( //
-            "automation", new AddonType("automation", "Automation"), //
-            "binding", new AddonType("binding", "Bindings"), //
-            "misc", new AddonType("misc", "Misc"), //
-            "persistence", new AddonType("persistence", "Persistence"), //
-            "transformation", new AddonType("transformation", "Transformations"), //
-            "ui", new AddonType("ui", "User Interfaces"), //
-            "voice", new AddonType("voice", "Voice"));
-
-    private final Gson gson = new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").create();
-    private final Set addonHandlers = new HashSet<>();
-
-    private List addonserviceUrls = List.of();
-    private List cachedAddons = List.of();
 
+    private List addonServiceUrls = List.of();
     private boolean showUnstable = false;
 
-    private final EventPublisher eventPublisher;
-    private final ConfigurationAdmin configurationAdmin;
-
     @Activate
-    public JsonAddonService(@Reference EventPublisher eventPublisher, @Reference ConfigurationAdmin configurationAdmin,
-            Map config) {
-        this.eventPublisher = eventPublisher;
-        this.configurationAdmin = configurationAdmin;
+    public JsonAddonService(@Reference EventPublisher eventPublisher, @Reference StorageService storageService,
+            @Reference ConfigurationAdmin configurationAdmin, Map config) {
+        super(eventPublisher, configurationAdmin, storageService, SERVICE_PID);
         modified(config);
     }
 
     @Modified
-    public void modified(Map config) {
-        String urls = Objects.requireNonNullElse((String) config.get(CONFIG_URLS), "");
-        addonserviceUrls = Arrays.asList(urls.split("\\|"));
-        showUnstable = (Boolean) config.getOrDefault(CONFIG_SHOW_UNSTABLE, false);
-        refreshSource();
+    public void modified(@Nullable Map config) {
+        if (config != null) {
+            String urls = Objects.requireNonNullElse((String) config.get(CONFIG_URLS), "");
+            addonServiceUrls = Arrays.asList(urls.split("\\|"));
+            showUnstable = (Boolean) config.getOrDefault(CONFIG_SHOW_UNSTABLE, false);
+            cachedRemoteAddons.invalidateValue();
+            refreshSource();
+        }
     }
 
     @Reference(cardinality = ReferenceCardinality.AT_LEAST_ONE, policy = ReferencePolicy.DYNAMIC)
@@ -140,13 +113,8 @@ public String getName() {
 
     @Override
     @SuppressWarnings("unchecked")
-    public void refreshSource() {
-        if (!remoteEnabled()) {
-            cachedAddons = List.of();
-            return;
-        }
-
-        cachedAddons = (List) addonserviceUrls.stream().map(urlString -> {
+    protected List getRemoteAddons() {
+        return addonServiceUrls.stream().map(urlString -> {
             try {
                 URL url = new URL(urlString);
                 URLConnection connection = url.openConnection();
@@ -158,72 +126,15 @@ public void refreshSource() {
             } catch (IOException e) {
                 return List.of();
             }
-        }).flatMap(List::stream).filter(e -> showUnstable || "stable".equals(((AddonEntryDTO) e).maturity))
+        }).flatMap(List::stream).filter(Objects::nonNull).map(e -> (AddonEntryDTO) e)
+                .filter(e -> showUnstable || "stable".equals(e.maturity)).map(this::fromAddonEntry)
                 .collect(Collectors.toList());
     }
 
-    @Override
-    public List getAddons(@Nullable Locale locale) {
-        refreshSource();
-        return cachedAddons.stream().map(this::fromAddonEntry).collect(Collectors.toList());
-    }
-
     @Override
     public @Nullable Addon getAddon(String id, @Nullable Locale locale) {
-        String remoteId = id.replace(ADDON_ID_PREFIX, "");
-        return cachedAddons.stream().filter(e -> remoteId.equals(e.id)).map(this::fromAddonEntry).findAny()
-                .orElse(null);
-    }
-
-    @Override
-    public List getTypes(@Nullable Locale locale) {
-        return new ArrayList<>(TAG_ADDON_TYPE_MAP.values());
-    }
-
-    @Override
-    public void install(String id) {
-        Addon addon = getAddon(id, null);
-        if (addon != null) {
-            for (MarketplaceAddonHandler handler : addonHandlers) {
-                if (handler.supports(addon.getType(), addon.getContentType())) {
-                    if (!handler.isInstalled(addon.getId())) {
-                        try {
-                            handler.install(addon);
-                            postInstalledEvent(addon.getId());
-                        } catch (MarketplaceHandlerException e) {
-                            postFailureEvent(addon.getId(), e.getMessage());
-                        }
-                    } else {
-                        postFailureEvent(addon.getId(), "Add-on is already installed.");
-                    }
-                    return;
-                }
-            }
-        }
-        postFailureEvent(id, "Add-on not known.");
-    }
-
-    @Override
-    public void uninstall(String id) {
-        Addon addon = getAddon(id, null);
-        if (addon != null) {
-            for (MarketplaceAddonHandler handler : addonHandlers) {
-                if (handler.supports(addon.getType(), addon.getContentType())) {
-                    if (handler.isInstalled(addon.getId())) {
-                        try {
-                            handler.uninstall(addon);
-                            postUninstalledEvent(addon.getId());
-                        } catch (MarketplaceHandlerException e) {
-                            postFailureEvent(addon.getId(), e.getMessage());
-                        }
-                    } else {
-                        postFailureEvent(addon.getId(), "Add-on is not installed.");
-                    }
-                    return;
-                }
-            }
-        }
-        postFailureEvent(id, "Add-on not known.");
+        String fullId = ADDON_ID_PREFIX + id;
+        return cachedAddons.stream().filter(e -> fullId.equals(e.getId())).findAny().orElse(null);
     }
 
     @Override
@@ -251,35 +162,6 @@ private Addon fromAddonEntry(AddonEntryDTO addonEntry) {
                 .withDetailedDescription(addonEntry.description).withContentType(addonEntry.contentType)
                 .withAuthor(addonEntry.author).withVersion(addonEntry.version).withLabel(addonEntry.title)
                 .withMaturity(addonEntry.maturity).withProperties(properties).withLink(addonEntry.link)
-                .withConfigDescriptionURI(addonEntry.configDescriptionURI).build();
-    }
-
-    private void postInstalledEvent(String extensionId) {
-        Event event = AddonEventFactory.createAddonInstalledEvent(extensionId);
-        eventPublisher.post(event);
-    }
-
-    private void postUninstalledEvent(String extensionId) {
-        Event event = AddonEventFactory.createAddonUninstalledEvent(extensionId);
-        eventPublisher.post(event);
-    }
-
-    private void postFailureEvent(String extensionId, @Nullable String msg) {
-        Event event = AddonEventFactory.createAddonFailureEvent(extensionId, msg);
-        eventPublisher.post(event);
-    }
-
-    private boolean remoteEnabled() {
-        try {
-            Configuration configuration = configurationAdmin.getConfiguration("org.openhab.addons", null);
-            Dictionary properties = configuration.getProperties();
-            if (properties == null) {
-                // if we can't determine a set property, we use true (default is remote enabled)
-                return true;
-            }
-            return ConfigParser.valueAsOrElse(properties.get(CFG_REMOTE), Boolean.class, true);
-        } catch (IOException e) {
-            return true;
-        }
+                .withImageLink(addonEntry.imageUrl).withConfigDescriptionURI(addonEntry.configDescriptionURI).build();
     }
 }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/model/AddonEntryDTO.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/model/AddonEntryDTO.java
index 68b859e24a2..d764ab2e5d2 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/model/AddonEntryDTO.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/model/AddonEntryDTO.java
@@ -31,5 +31,7 @@ public class AddonEntryDTO {
     public String maturity = "unstable";
     @SerializedName("content_type")
     public String contentType = "";
+    @SerializedName("image_url")
+    public String imageUrl;
     public String url = "";
 }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java
new file mode 100644
index 00000000000..87f2d5bd81a
--- /dev/null
+++ b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java
@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.addon.test;
+
+import java.io.IOException;
+import java.util.Dictionary;
+import java.util.Hashtable;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.addon.Addon;
+import org.openhab.core.events.Event;
+import org.openhab.core.events.EventPublisher;
+import org.openhab.core.storage.Storage;
+import org.openhab.core.storage.StorageService;
+import org.openhab.core.test.storage.VolatileStorage;
+import org.osgi.service.cm.Configuration;
+import org.osgi.service.cm.ConfigurationAdmin;
+
+/**
+ * The {@link AbstractRemoteAddonServiceTest} is a
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class AbstractRemoteAddonServiceTest {
+
+    private @Mock @NonNullByDefault({}) StorageService storageService;
+    private @Mock @NonNullByDefault({}) ConfigurationAdmin configurationAdmin;
+    private @Mock @NonNullByDefault({}) EventPublisher eventPublisher;
+    private @Mock @NonNullByDefault({}) Configuration configuration;
+
+    private @NonNullByDefault({}) Storage storage;
+    private @NonNullByDefault({}) TestAddonService addonService;
+
+    private final Dictionary properties = new Hashtable<>();
+
+    @BeforeEach
+    public void initialize() throws IOException {
+        storage = new VolatileStorage<>();
+        Mockito.doReturn(storage).when(storageService).getStorage(TestAddonService.SERVICE_PID);
+        Mockito.doReturn(configuration).when(configurationAdmin).getConfiguration("org.openhab.addons", null);
+        Mockito.doReturn(properties).when(configuration).getProperties();
+
+        addonService = new TestAddonService(eventPublisher, configurationAdmin, storageService);
+        addonService.addAddonHandler(new VirtualAddonHandler());
+    }
+
+    @Test
+    public void testRemoteDisabledBlocksRemoteCalls() {
+        properties.put("remote", false);
+        List addons = addonService.getAddons(null);
+        Assertions.assertEquals(0, addons.size());
+        Assertions.assertEquals(0, addonService.getRemoteCalls());
+    }
+
+    @Test
+    public void testAddonResultsAreCached() {
+        List addons = addonService.getAddons(null);
+        Assertions.assertEquals(TestAddonService.REMOTE_ADDONS.size(), addons.size());
+        addons = addonService.getAddons(null);
+        Assertions.assertEquals(TestAddonService.REMOTE_ADDONS.size(), addons.size());
+        Assertions.assertEquals(1, addonService.getRemoteCalls());
+    }
+
+    @Test
+    public void testAddonInstallation() {
+        addonService.install(TestAddonService.TEST_ADDON);
+
+        ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Event.class);
+        Mockito.verify(eventPublisher).post(eventCaptor.capture());
+
+        Event postInstallationEvent = eventCaptor.getValue();
+        Assertions.assertEquals("openhab/addons/" + getFullAddonId(TestAddonService.TEST_ADDON) + "/installed",
+                postInstallationEvent.getTopic());
+    }
+
+    private String getFullAddonId(String id) {
+        return TestAddonService.SERVICE_PID + ":" + id;
+    }
+}
diff --git a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonService.java
new file mode 100644
index 00000000000..06577376aae
--- /dev/null
+++ b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonService.java
@@ -0,0 +1,96 @@
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.addon.test;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.addon.Addon;
+import org.openhab.core.addon.marketplace.AbstractRemoteAddonService;
+import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
+import org.openhab.core.events.EventPublisher;
+import org.openhab.core.storage.StorageService;
+import org.osgi.service.cm.ConfigurationAdmin;
+
+/**
+ * The {@link TestAddonService} is a
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class TestAddonService extends AbstractRemoteAddonService {
+    public static final String TEST_ADDON = "testAddon";
+    public static final String INSTALL_EXCEPTION_ADDON = "installException";
+    public static final String UNINSTALL_EXCEPTION_ADDON = "uninstallException";
+
+    public static final String SERVICE_PID = "testAddonService";
+
+    public static final Map REMOTE_ADDONS = Stream
+            .of(TEST_ADDON, INSTALL_EXCEPTION_ADDON, UNINSTALL_EXCEPTION_ADDON)
+            .map(id -> Addon.create(SERVICE_PID + ":" + id).withType("binding")
+                    .withContentType(VirtualAddonHandler.TEST_ADDON_CONTENT_TYPE).build())
+            .collect(Collectors.toMap(Addon::getId, a -> a));
+
+    private int remoteCalls = 0;
+
+    public TestAddonService(EventPublisher eventPublisher, ConfigurationAdmin configurationAdmin,
+            StorageService storageService) {
+        super(eventPublisher, configurationAdmin, storageService, SERVICE_PID);
+    }
+
+    public void addAddonHandler(MarketplaceAddonHandler handler) {
+        this.addonHandlers.add(handler);
+    }
+
+    public void removeAddonHandler(MarketplaceAddonHandler handler) {
+        this.addonHandlers.remove(handler);
+    }
+
+    @Override
+    protected List getRemoteAddons() {
+        remoteCalls++;
+        return new ArrayList<>(REMOTE_ADDONS.values());
+    }
+
+    @Override
+    public String getId() {
+        return SERVICE_PID;
+    }
+
+    @Override
+    public String getName() {
+        return "Test Addon Service";
+    }
+
+    @Override
+    public @Nullable Addon getAddon(String id, @Nullable Locale locale) {
+        String remoteId = SERVICE_PID + ":" + id;
+        return REMOTE_ADDONS.get(remoteId);
+    }
+
+    @Override
+    public @Nullable String getAddonId(URI addonURI) {
+        return null;
+    }
+
+    public int getRemoteCalls() {
+        return remoteCalls;
+    }
+}
diff --git a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/VirtualAddonHandler.java b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/VirtualAddonHandler.java
new file mode 100644
index 00000000000..da274da20b1
--- /dev/null
+++ b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/VirtualAddonHandler.java
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.addon.test;
+
+import static org.openhab.core.addon.test.TestAddonService.INSTALL_EXCEPTION_ADDON;
+import static org.openhab.core.addon.test.TestAddonService.UNINSTALL_EXCEPTION_ADDON;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.addon.Addon;
+import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
+import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
+
+/**
+ * The {@link VirtualAddonHandler} is a
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class VirtualAddonHandler implements MarketplaceAddonHandler {
+    private static final Set SUPPORTED_ADDON_TYPES = Set.of("binding", "automation");
+    public static final String TEST_ADDON_CONTENT_TYPE = "testAddonContentType";
+
+    private final Set installedAddons = new HashSet<>();
+
+    @Override
+    public boolean supports(String type, String contentType) {
+        return SUPPORTED_ADDON_TYPES.contains(type) && TEST_ADDON_CONTENT_TYPE.equals(contentType);
+    }
+
+    @Override
+    public boolean isInstalled(String id) {
+        return installedAddons.contains(id);
+    }
+
+    @Override
+    public void install(Addon addon) throws MarketplaceHandlerException {
+        if (INSTALL_EXCEPTION_ADDON.equals(addon.getId())) {
+            throw new MarketplaceHandlerException("Installation failed", null);
+        }
+        installedAddons.add(addon.getId());
+    }
+
+    @Override
+    public void uninstall(Addon addon) throws MarketplaceHandlerException {
+        if (UNINSTALL_EXCEPTION_ADDON.equals(addon.getId())) {
+            throw new MarketplaceHandlerException("Installation failed", null);
+        }
+        installedAddons.remove(addon.getId());
+    }
+}

From 1191186adb40d542819a8ebaea73f99ab85bc8b8 Mon Sep 17 00:00:00 2001
From: "Jan N. Klug" 
Date: Mon, 10 Jan 2022 18:24:40 +0100
Subject: [PATCH 2/6] more tests

Signed-off-by: Jan N. Klug 
---
 .../AbstractRemoteAddonService.java           |  11 +-
 .../CommunityMarketplaceAddonService.java     |   2 +-
 .../test/AbstractRemoteAddonServiceTest.java  | 156 ++++++++++++++++--
 ...ddonHandler.java => TestAddonHandler.java} |  10 +-
 .../core/addon/test/TestAddonService.java     |  56 +++++--
 5 files changed, 204 insertions(+), 31 deletions(-)
 rename bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/{VirtualAddonHandler.java => TestAddonHandler.java} (85%)

diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
index 22d0191c571..77cda64405c 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
@@ -38,6 +38,8 @@
 import org.openhab.core.storage.StorageService;
 import org.osgi.service.cm.Configuration;
 import org.osgi.service.cm.ConfigurationAdmin;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
@@ -49,6 +51,7 @@
  */
 @NonNullByDefault
 public abstract class AbstractRemoteAddonService implements AddonService {
+    private final Logger logger = LoggerFactory.getLogger(AbstractRemoteAddonService.class);
     protected static final Map TAG_ADDON_TYPE_MAP = Map.of( //
             "automation", new AddonType("automation", "Automation"), //
             "binding", new AddonType("binding", "Bindings"), //
@@ -80,7 +83,6 @@ public void refreshSource() {
         List addons = new ArrayList<>();
         installedAddonStorage.stream().map(e -> Objects.requireNonNull(gson.fromJson(e.getValue(), Addon.class)))
                 .forEach(addons::add);
-        addons.forEach(a -> a.setInstalled(true));
 
         // create lookup list to make sure installed addons take precedence
         List installedAddons = addons.stream().map(Addon::getId).collect(Collectors.toList());
@@ -90,6 +92,9 @@ public void refreshSource() {
             remoteAddons.stream().filter(a -> !installedAddons.contains(a.getId())).forEach(addons::add);
         }
 
+        // check real installation status based on handlers
+        addons.forEach(addon -> addon.setInstalled(addonHandlers.stream().anyMatch(h -> h.isInstalled(addon.getId()))));
+
         cachedAddons = addons;
         this.installedAddons = installedAddons;
     }
@@ -125,7 +130,7 @@ public void install(String id) {
                         try {
                             handler.install(addon);
                             installedAddonStorage.put(id, gson.toJson(addon));
-                            cachedRemoteAddons.invalidateValue();
+                            refreshSource();
                             postInstalledEvent(addon.getId());
                         } catch (MarketplaceHandlerException e) {
                             postFailureEvent(addon.getId(), e.getMessage());
@@ -150,7 +155,7 @@ public void uninstall(String id) {
                         try {
                             handler.uninstall(addon);
                             installedAddonStorage.remove(id);
-                            cachedRemoteAddons.invalidateValue();
+                            refreshSource();
                             postUninstalledEvent(addon.getId());
                         } catch (MarketplaceHandlerException e) {
                             postFailureEvent(addon.getId(), e.getMessage());
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java
index 7021a48dc07..a92fdc52030 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java
@@ -381,7 +381,7 @@ private Addon convertTopicToAddon(DiscourseTopicResponseDTO topic) {
             properties.put("yaml_content", unescapeEntities(yamlContent));
         }
 
-        // try to use an handler to determine if the add-on is installed
+        // try to use a handler to determine if the add-on is installed
         boolean installed = addonHandlers.stream()
                 .anyMatch(handler -> handler.supports(type, contentType) && handler.isInstalled(id));
 
diff --git a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java
index 87f2d5bd81a..1844c3095a1 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java
@@ -12,10 +12,16 @@
  */
 package org.openhab.core.addon.test;
 
+import static org.openhab.core.addon.test.TestAddonService.INSTALL_EXCEPTION_ADDON;
+import static org.openhab.core.addon.test.TestAddonService.SERVICE_PID;
+import static org.openhab.core.addon.test.TestAddonService.TEST_ADDON;
+import static org.openhab.core.addon.test.TestAddonService.UNINSTALL_EXCEPTION_ADDON;
+
 import java.io.IOException;
 import java.util.Dictionary;
 import java.util.Hashtable;
 import java.util.List;
+import java.util.Objects;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.junit.jupiter.api.Assertions;
@@ -38,7 +44,8 @@
 import org.osgi.service.cm.ConfigurationAdmin;
 
 /**
- * The {@link AbstractRemoteAddonServiceTest} is a
+ * The {@link AbstractRemoteAddonServiceTest} contains tests for the
+ * {@link org.openhab.core.addon.marketplace.AbstractRemoteAddonService}
  *
  * @author Jan N. Klug - Initial contribution
  */
@@ -46,7 +53,6 @@
 @MockitoSettings(strictness = Strictness.WARN)
 @NonNullByDefault
 public class AbstractRemoteAddonServiceTest {
-
     private @Mock @NonNullByDefault({}) StorageService storageService;
     private @Mock @NonNullByDefault({}) ConfigurationAdmin configurationAdmin;
     private @Mock @NonNullByDefault({}) EventPublisher eventPublisher;
@@ -54,20 +60,24 @@ public class AbstractRemoteAddonServiceTest {
 
     private @NonNullByDefault({}) Storage storage;
     private @NonNullByDefault({}) TestAddonService addonService;
-
+    private @NonNullByDefault({}) TestAddonHandler addonHandler;
     private final Dictionary properties = new Hashtable<>();
 
     @BeforeEach
     public void initialize() throws IOException {
         storage = new VolatileStorage<>();
-        Mockito.doReturn(storage).when(storageService).getStorage(TestAddonService.SERVICE_PID);
+        Mockito.doReturn(storage).when(storageService).getStorage(SERVICE_PID);
         Mockito.doReturn(configuration).when(configurationAdmin).getConfiguration("org.openhab.addons", null);
         Mockito.doReturn(properties).when(configuration).getProperties();
 
+        addonHandler = new TestAddonHandler();
+
         addonService = new TestAddonService(eventPublisher, configurationAdmin, storageService);
-        addonService.addAddonHandler(new VirtualAddonHandler());
+        addonService.addAddonHandler(addonHandler);
     }
 
+    // general tests
+
     @Test
     public void testRemoteDisabledBlocksRemoteCalls() {
         properties.put("remote", false);
@@ -86,18 +96,142 @@ public void testAddonResultsAreCached() {
     }
 
     @Test
-    public void testAddonInstallation() {
-        addonService.install(TestAddonService.TEST_ADDON);
+    public void testAddonIsReportedAsInstalledIfStorageEntryMissing() {
+        addonService.setInstalled(TEST_ADDON);
+        List addons = addonService.getAddons(null);
+        Addon addon = addons.stream().filter(a -> getFullAddonId(TEST_ADDON).equals(a.getId())).findAny().orElse(null);
+
+        Objects.requireNonNull(addon);
+        Assertions.assertTrue(addon.isInstalled());
+    }
+
+    // installation tests
+
+    @Test
+    public void testAddonInstall() {
+        addonService.getAddons(null);
+
+        addonService.install(TEST_ADDON);
+
+        checkResult(TEST_ADDON, getFullAddonId(TEST_ADDON) + "/installed", true, true);
+    }
+
+    @Test
+    public void testAddonInstallFailsWithHandlerException() {
+        addonService.getAddons(null);
+
+        addonService.install(INSTALL_EXCEPTION_ADDON);
+
+        checkResult(INSTALL_EXCEPTION_ADDON, getFullAddonId(INSTALL_EXCEPTION_ADDON) + "/failed", false, true);
+    }
+
+    @Test
+    public void testAddonInstallFailsOnInstalledAddon() {
+        addonService.setInstalled(TEST_ADDON);
+        addonService.addToStorage(TEST_ADDON);
+        addonService.getAddons(null);
+
+        addonService.install(TEST_ADDON);
+
+        checkResult(TEST_ADDON, getFullAddonId(TEST_ADDON) + "/failed", true, true);
+    }
+
+    @Test
+    public void testAddonInstallFailsOnUnknownAddon() {
+        addonService.getAddons(null);
+
+        addonService.install("unknown");
+
+        checkResult("unknown", "unknown/failed", false, false);
+    }
+
+    // uninstallation tests
+
+    @Test
+    public void testAddonUninstall() {
+        addonService.setInstalled(TEST_ADDON);
+        addonService.addToStorage(TEST_ADDON);
+        addonService.getAddons(null);
+
+        addonService.uninstall(TEST_ADDON);
+
+        checkResult(TEST_ADDON, getFullAddonId(TEST_ADDON) + "/uninstalled", false, true);
+    }
+
+    @Test
+    public void testAddonUninstallFailsWithHandlerException() {
+        addonService.setInstalled(UNINSTALL_EXCEPTION_ADDON);
+        addonService.addToStorage(UNINSTALL_EXCEPTION_ADDON);
+        addonService.getAddons(null);
 
+        addonService.uninstall(UNINSTALL_EXCEPTION_ADDON);
+
+        checkResult(UNINSTALL_EXCEPTION_ADDON, getFullAddonId(UNINSTALL_EXCEPTION_ADDON) + "/failed", true, true);
+    }
+
+    @Test
+    public void testAddonUninstallFailsOnUninstalledAddon() {
+        addonService.getAddons(null);
+
+        addonService.uninstall(TEST_ADDON);
+
+        checkResult(TEST_ADDON, getFullAddonId(TEST_ADDON) + "/failed", false, true);
+    }
+
+    @Test
+    public void testAddonUninstallFailsOnUnknownAddon() {
+        addonService.getAddons(null);
+
+        addonService.uninstall("unknown");
+
+        checkResult("unknown", "unknown/failed", false, false);
+    }
+
+    @Test
+    public void testAddonUninstallRemovesStorageEntryOnUninstalledAddon() {
+        addonService.addToStorage(TEST_ADDON);
+        addonService.getAddons(null);
+
+        addonService.uninstall(TEST_ADDON);
+
+        checkResult(TEST_ADDON, getFullAddonId(TEST_ADDON) + "/failed", false, true);
+    }
+
+    /**
+     * checks that a proper event is posted, the presence in storage and installation status in handler
+     *
+     * @param id add-on id (without service-prefix)
+     * @param expectedEventTopic the expected event (e.g. installed)
+     * @param installStatus the expected installation status of the add-on
+     * @param present if the addon is expected to be present after the test
+     */
+    private void checkResult(String id, String expectedEventTopic, boolean installStatus, boolean present) {
+        // assert expected event is posted
         ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Event.class);
         Mockito.verify(eventPublisher).post(eventCaptor.capture());
+        Event event = eventCaptor.getValue();
+        String topic = "openhab/addons/" + expectedEventTopic;
+
+        Assertions.assertEquals(topic, event.getTopic());
+
+        // assert addon handler was called (by checking it's installed status)
+        Assertions.assertEquals(installStatus, addonHandler.isInstalled(getFullAddonId(id)));
+
+        // assert is present in storage if installed or missing if uninstalled
+        Assertions.assertEquals(installStatus, storage.containsKey(id));
 
-        Event postInstallationEvent = eventCaptor.getValue();
-        Assertions.assertEquals("openhab/addons/" + getFullAddonId(TestAddonService.TEST_ADDON) + "/installed",
-                postInstallationEvent.getTopic());
+        // assert correct installation status is reported for addon
+        Addon addon = addonService.getAddon(id, null);
+        if (present) {
+            Assertions.assertNotNull(addon);
+            Objects.requireNonNull(addon);
+            Assertions.assertEquals(installStatus, addon.isInstalled());
+        } else {
+            Assertions.assertNull(addon);
+        }
     }
 
     private String getFullAddonId(String id) {
-        return TestAddonService.SERVICE_PID + ":" + id;
+        return SERVICE_PID + ":" + id;
     }
 }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/VirtualAddonHandler.java b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonHandler.java
similarity index 85%
rename from bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/VirtualAddonHandler.java
rename to bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonHandler.java
index da274da20b1..d5b3cb053fe 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/VirtualAddonHandler.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonHandler.java
@@ -24,12 +24,12 @@
 import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
 
 /**
- * The {@link VirtualAddonHandler} is a
+ * The {@link TestAddonHandler} is a
  *
  * @author Jan N. Klug - Initial contribution
  */
 @NonNullByDefault
-public class VirtualAddonHandler implements MarketplaceAddonHandler {
+public class TestAddonHandler implements MarketplaceAddonHandler {
     private static final Set SUPPORTED_ADDON_TYPES = Set.of("binding", "automation");
     public static final String TEST_ADDON_CONTENT_TYPE = "testAddonContentType";
 
@@ -47,7 +47,7 @@ public boolean isInstalled(String id) {
 
     @Override
     public void install(Addon addon) throws MarketplaceHandlerException {
-        if (INSTALL_EXCEPTION_ADDON.equals(addon.getId())) {
+        if (addon.getId().endsWith(":" + INSTALL_EXCEPTION_ADDON)) {
             throw new MarketplaceHandlerException("Installation failed", null);
         }
         installedAddons.add(addon.getId());
@@ -55,8 +55,8 @@ public void install(Addon addon) throws MarketplaceHandlerException {
 
     @Override
     public void uninstall(Addon addon) throws MarketplaceHandlerException {
-        if (UNINSTALL_EXCEPTION_ADDON.equals(addon.getId())) {
-            throw new MarketplaceHandlerException("Installation failed", null);
+        if (addon.getId().endsWith(":" + UNINSTALL_EXCEPTION_ADDON)) {
+            throw new MarketplaceHandlerException("Uninstallation failed", null);
         }
         installedAddons.remove(addon.getId());
     }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonService.java
index 06577376aae..11c2a24a724 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonService.java
@@ -13,18 +13,17 @@
 package org.openhab.core.addon.test;
 
 import java.net.URI;
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
-import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
-import java.util.stream.Stream;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.openhab.core.addon.Addon;
 import org.openhab.core.addon.marketplace.AbstractRemoteAddonService;
 import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
+import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
 import org.openhab.core.events.EventPublisher;
 import org.openhab.core.storage.StorageService;
 import org.osgi.service.cm.ConfigurationAdmin;
@@ -41,12 +40,8 @@ public class TestAddonService extends AbstractRemoteAddonService {
     public static final String UNINSTALL_EXCEPTION_ADDON = "uninstallException";
 
     public static final String SERVICE_PID = "testAddonService";
-
-    public static final Map REMOTE_ADDONS = Stream
-            .of(TEST_ADDON, INSTALL_EXCEPTION_ADDON, UNINSTALL_EXCEPTION_ADDON)
-            .map(id -> Addon.create(SERVICE_PID + ":" + id).withType("binding")
-                    .withContentType(VirtualAddonHandler.TEST_ADDON_CONTENT_TYPE).build())
-            .collect(Collectors.toMap(Addon::getId, a -> a));
+    public static final Set REMOTE_ADDONS = Set.of(TEST_ADDON, INSTALL_EXCEPTION_ADDON,
+            UNINSTALL_EXCEPTION_ADDON);
 
     private int remoteCalls = 0;
 
@@ -66,7 +61,10 @@ public void removeAddonHandler(MarketplaceAddonHandler handler) {
     @Override
     protected List getRemoteAddons() {
         remoteCalls++;
-        return new ArrayList<>(REMOTE_ADDONS.values());
+        return REMOTE_ADDONS.stream()
+                .map(id -> Addon.create(SERVICE_PID + ":" + id).withType("binding")
+                        .withContentType(TestAddonHandler.TEST_ADDON_CONTENT_TYPE).build())
+                .collect(Collectors.toList());
     }
 
     @Override
@@ -82,7 +80,7 @@ public String getName() {
     @Override
     public @Nullable Addon getAddon(String id, @Nullable Locale locale) {
         String remoteId = SERVICE_PID + ":" + id;
-        return REMOTE_ADDONS.get(remoteId);
+        return cachedAddons.stream().filter(a -> remoteId.equals(a.getId())).findAny().orElse(null);
     }
 
     @Override
@@ -90,7 +88,43 @@ public String getName() {
         return null;
     }
 
+    /**
+     * get the number of remote calls issued by the addon service
+     *
+     * @return number of calls
+     */
     public int getRemoteCalls() {
         return remoteCalls;
     }
+
+    /**
+     * this installs an addon to the service without calling the install method
+     *
+     * @param id id of the addon to install
+     */
+    public void setInstalled(String id) {
+        Addon addon = Addon.create(SERVICE_PID + ":" + id).withType("binding")
+                .withContentType(TestAddonHandler.TEST_ADDON_CONTENT_TYPE).build();
+
+        addonHandlers.forEach(addonHandler -> {
+            try {
+                addonHandler.install(addon);
+            } catch (MarketplaceHandlerException e) {
+                // ignore
+            }
+        });
+    }
+
+    /**
+     * add to installedStorage
+     *
+     * @param id id of the addon to add
+     */
+    public void addToStorage(String id) {
+        Addon addon = Addon.create(SERVICE_PID + ":" + id).withType("binding")
+                .withContentType(TestAddonHandler.TEST_ADDON_CONTENT_TYPE).build();
+
+        addon.setInstalled(true);
+        installedAddonStorage.put(id, gson.toJson(addon));
+    }
 }

From e2f75b9d053ac57ab43b72a50c7eac10f14080d2 Mon Sep 17 00:00:00 2001
From: "Jan N. Klug" 
Date: Mon, 10 Jan 2022 18:43:26 +0100
Subject: [PATCH 3/6] further work

Signed-off-by: Jan N. Klug 
---
 .../AbstractRemoteAddonService.java           | 22 +++++++++++++++++++
 .../CommunityMarketplaceAddonService.java     |  2 ++
 .../internal/json/JsonAddonService.java       |  2 ++
 .../test/AbstractRemoteAddonServiceTest.java  | 18 +++++++++++++++
 4 files changed, 44 insertions(+)

diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
index 77cda64405c..3d4e9b42d9b 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
@@ -99,6 +99,28 @@ public void refreshSource() {
         this.installedAddons = installedAddons;
     }
 
+    /**
+     * Add an {@link MarketplaceAddonHandler) to this service
+     *
+     * This needs to be implemented by the addon-services because the handlers are references to OSGi services and
+     * the @Reference annotation is not inherited.
+     * It is added here to make sure that implementations comply with that.
+     *
+     * @param handler the handler that shall be added
+     */
+    protected abstract void addAddonHandler(MarketplaceAddonHandler handler);
+
+    /**
+     * Remove an {@link MarketplaceAddonHandler) from this service
+     *
+     * This needs to be implemented by the addon-services because the handlers are references to OSGi services and
+     * unbind methods can't be inherited.
+     * It is added here to make sure that implementations comply with that.
+     *
+     * @param handler the handler that shall be removed
+     */
+    protected abstract void removeAddonHandler(MarketplaceAddonHandler handler);
+
     /**
      * get all addons from remote
      *
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java
index a92fdc52030..0e10e5a74d7 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java
@@ -126,11 +126,13 @@ public void modified(@Nullable Map config) {
         }
     }
 
+    @Override
     @Reference(cardinality = ReferenceCardinality.AT_LEAST_ONE, policy = ReferencePolicy.DYNAMIC)
     protected void addAddonHandler(MarketplaceAddonHandler handler) {
         this.addonHandlers.add(handler);
     }
 
+    @Override
     protected void removeAddonHandler(MarketplaceAddonHandler handler) {
         this.addonHandlers.remove(handler);
     }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java
index c35184249c6..46fb6828fb8 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java
@@ -92,11 +92,13 @@ public void modified(@Nullable Map config) {
         }
     }
 
+    @Override
     @Reference(cardinality = ReferenceCardinality.AT_LEAST_ONE, policy = ReferencePolicy.DYNAMIC)
     protected void addAddonHandler(MarketplaceAddonHandler handler) {
         this.addonHandlers.add(handler);
     }
 
+    @Override
     protected void removeAddonHandler(MarketplaceAddonHandler handler) {
         this.addonHandlers.remove(handler);
     }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java
index 1844c3095a1..5fb9b79e8b1 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java
@@ -105,6 +105,24 @@ public void testAddonIsReportedAsInstalledIfStorageEntryMissing() {
         Assertions.assertTrue(addon.isInstalled());
     }
 
+    @Test
+    public void testInstalledAddonIsStillPresentAfterRemoteIsDisabledOrMissing() {
+        addonService.setInstalled(TEST_ADDON);
+        addonService.addToStorage(TEST_ADDON);
+
+        // check all addons are present
+        List addons = addonService.getAddons(null);
+        Assertions.assertEquals(TestAddonService.REMOTE_ADDONS.size(), addons.size());
+
+        // disable remote repo
+        properties.put("remote", false);
+
+        // check only the installed addon is present
+        addons = addonService.getAddons(null);
+        Assertions.assertEquals(1, addons.size());
+        Assertions.assertEquals(getFullAddonId(TEST_ADDON), addons.get(0).getId());
+    }
+
     // installation tests
 
     @Test

From 5d6e8ae0ccded6424883f3a60c31b73ac07e69ff Mon Sep 17 00:00:00 2001
From: "Jan N. Klug" 
Date: Sat, 15 Jan 2022 18:33:25 +0100
Subject: [PATCH 4/6] fix broken config handling

Signed-off-by: Jan N. Klug 
---
 .../core/addon/marketplace/AbstractRemoteAddonService.java    | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
index 3d4e9b42d9b..916ba5f75d8 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
@@ -209,7 +209,9 @@ protected boolean remoteEnabled() {
                 // if we can't determine a set property, we use true (default is remote enabled)
                 return true;
             }
-            return (boolean) Objects.requireNonNullElse(properties.get("remote"), true);
+            Object remoteEnabled = properties.get("remote");
+            return (remoteEnabled == null || "true".equals(remoteEnabled.toString()));
+
         } catch (IOException e) {
             return true;
         }

From 4b7b1ecdccb723a06773c91368a811c1767b7fb4 Mon Sep 17 00:00:00 2001
From: "Jan N. Klug" 
Date: Sun, 30 Jan 2022 11:42:46 +0100
Subject: [PATCH 5/6] address review comment

Signed-off-by: Jan N. Klug 
---
 .../core/addon/marketplace/AbstractRemoteAddonService.java   | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
index 916ba5f75d8..b4e7936e452 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
@@ -32,6 +32,7 @@
 import org.openhab.core.addon.AddonService;
 import org.openhab.core.addon.AddonType;
 import org.openhab.core.cache.ExpiringCache;
+import org.openhab.core.config.core.ConfigParser;
 import org.openhab.core.events.Event;
 import org.openhab.core.events.EventPublisher;
 import org.openhab.core.storage.Storage;
@@ -209,9 +210,7 @@ protected boolean remoteEnabled() {
                 // if we can't determine a set property, we use true (default is remote enabled)
                 return true;
             }
-            Object remoteEnabled = properties.get("remote");
-            return (remoteEnabled == null || "true".equals(remoteEnabled.toString()));
-
+            return ConfigParser.valueAsOrElse(properties.get("remote"), Boolean.class, true);
         } catch (IOException e) {
             return true;
         }

From fcbd3f060d42081255ea448a29f5d0e9a9fbff3e Mon Sep 17 00:00:00 2001
From: "Jan N. Klug" 
Date: Sun, 30 Jan 2022 14:07:30 +0100
Subject: [PATCH 6/6] fix rebase conflicts

Signed-off-by: Jan N. Klug 
---
 .../core/addon/marketplace/AbstractRemoteAddonService.java     | 3 ---
 .../internal/community/CommunityMarketplaceAddonService.java   | 2 --
 .../core/addon/marketplace/internal/json/JsonAddonService.java | 2 --
 3 files changed, 7 deletions(-)

diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
index b4e7936e452..3866605fc9e 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java
@@ -39,8 +39,6 @@
 import org.openhab.core.storage.StorageService;
 import org.osgi.service.cm.Configuration;
 import org.osgi.service.cm.ConfigurationAdmin;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
@@ -52,7 +50,6 @@
  */
 @NonNullByDefault
 public abstract class AbstractRemoteAddonService implements AddonService {
-    private final Logger logger = LoggerFactory.getLogger(AbstractRemoteAddonService.class);
     protected static final Map TAG_ADDON_TYPE_MAP = Map.of( //
             "automation", new AddonType("automation", "Automation"), //
             "binding", new AddonType("binding", "Bindings"), //
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java
index 0e10e5a74d7..ab6a7538eea 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java
@@ -22,7 +22,6 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Date;
-import java.util.Dictionary;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
@@ -44,7 +43,6 @@
 import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO.DiscourseUser;
 import org.openhab.core.addon.marketplace.internal.community.model.DiscourseTopicResponseDTO;
 import org.openhab.core.addon.marketplace.internal.community.model.DiscourseTopicResponseDTO.DiscoursePostLink;
-import org.openhab.core.config.core.ConfigParser;
 import org.openhab.core.config.core.ConfigurableService;
 import org.openhab.core.events.EventPublisher;
 import org.openhab.core.storage.StorageService;
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java
index 46fb6828fb8..31e38c211bf 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java
@@ -20,7 +20,6 @@
 import java.net.URL;
 import java.net.URLConnection;
 import java.util.Arrays;
-import java.util.Dictionary;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
@@ -35,7 +34,6 @@
 import org.openhab.core.addon.marketplace.AbstractRemoteAddonService;
 import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
 import org.openhab.core.addon.marketplace.internal.json.model.AddonEntryDTO;
-import org.openhab.core.config.core.ConfigParser;
 import org.openhab.core.config.core.ConfigurableService;
 import org.openhab.core.events.EventPublisher;
 import org.openhab.core.storage.StorageService;