[addonservices] allow uninstalling of removed addons and fix other issues (#2607)
* [addonservices] allow uninstalling of removed addons Signed-off-by: Jan N. Klug <github@klug.nrw>pull/2737/head
parent
3e94dd6e30
commit
c4e1b14d00
|
@ -38,6 +38,12 @@
|
|||
<version>${project.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.test</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
|
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* 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.config.core.ConfigParser;
|
||||
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<String, AddonType> 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<MarketplaceAddonHandler> addonHandlers = new HashSet<>();
|
||||
protected final Storage<String> installedAddonStorage;
|
||||
protected final EventPublisher eventPublisher;
|
||||
protected final ConfigurationAdmin configurationAdmin;
|
||||
protected final ExpiringCache<List<Addon>> cachedRemoteAddons = new ExpiringCache<>(Duration.ofMinutes(15),
|
||||
this::getRemoteAddons);
|
||||
protected List<Addon> cachedAddons = List.of();
|
||||
protected List<String> 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<Addon> addons = new ArrayList<>();
|
||||
installedAddonStorage.stream().map(e -> Objects.requireNonNull(gson.fromJson(e.getValue(), Addon.class)))
|
||||
.forEach(addons::add);
|
||||
|
||||
// create lookup list to make sure installed addons take precedence
|
||||
List<String> installedAddons = addons.stream().map(Addon::getId).collect(Collectors.toList());
|
||||
|
||||
if (remoteEnabled()) {
|
||||
List<Addon> remoteAddons = Objects.requireNonNullElse(cachedRemoteAddons.getValue(), List.of());
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @return a list of {@link Addon} that are available on the remote side
|
||||
*/
|
||||
protected abstract List<Addon> getRemoteAddons();
|
||||
|
||||
@Override
|
||||
public List<Addon> getAddons(@Nullable Locale locale) {
|
||||
refreshSource();
|
||||
return cachedAddons;
|
||||
}
|
||||
|
||||
@Override
|
||||
public abstract @Nullable Addon getAddon(String id, @Nullable Locale locale);
|
||||
|
||||
@Override
|
||||
public List<AddonType> 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));
|
||||
refreshSource();
|
||||
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);
|
||||
refreshSource();
|
||||
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<String, Object> 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("remote"), Boolean.class, 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);
|
||||
}
|
||||
}
|
|
@ -14,7 +14,6 @@ package org.openhab.core.addon.marketplace.internal.community;
|
|||
|
||||
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;
|
||||
|
@ -23,37 +22,31 @@ import java.net.URLConnection;
|
|||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
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;
|
||||
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.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 +57,17 @@ 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;
|
||||
|
||||
/**
|
||||
* 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 +75,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 = "<pre><code class=\"lang-json\">";
|
||||
private static final String YAML_CODE_MARKUP_START = "<pre><code class=\"lang-yaml\">";
|
||||
|
@ -106,76 +99,55 @@ public class CommunityMarketplaceAddonService implements AddonService {
|
|||
|
||||
private static final String PUBLISHED_TAG = "published";
|
||||
|
||||
private static final Map<String, AddonType> 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<MarketplaceAddonHandler> 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<String, Object> config) {
|
||||
@Reference ConfigurationAdmin configurationAdmin, @Reference StorageService storageService,
|
||||
Map<String, Object> config) {
|
||||
super(eventPublisher, configurationAdmin, storageService, SERVICE_PID);
|
||||
modified(config);
|
||||
}
|
||||
|
||||
@Modified
|
||||
void modified(@Nullable Map<String, Object> config) {
|
||||
public void modified(@Nullable Map<String, Object> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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<Addon> getAddons(@Nullable Locale locale) {
|
||||
if (!remoteEnabled()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
protected List<Addon> getRemoteAddons() {
|
||||
List<Addon> addons = new ArrayList<>();
|
||||
try {
|
||||
List<DiscourseCategoryResponseDTO> pages = new ArrayList<>();
|
||||
|
||||
|
@ -190,11 +162,11 @@ public class CommunityMarketplaceAddonService implements AddonService {
|
|||
|
||||
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 class CommunityMarketplaceAddonService implements AddonService {
|
|||
}
|
||||
|
||||
List<DiscourseUser> 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 class CommunityMarketplaceAddonService implements AddonService {
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AddonType> 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 @@ public class CommunityMarketplaceAddonService implements AddonService {
|
|||
List<String> 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 @@ public class CommunityMarketplaceAddonService implements AddonService {
|
|||
boolean installed = addonHandlers.stream()
|
||||
.anyMatch(handler -> 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 @@ public class CommunityMarketplaceAddonService implements AddonService {
|
|||
String id = ADDON_ID_PREFIX + topic.id.toString();
|
||||
List<String> 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 @@ public class CommunityMarketplaceAddonService implements AddonService {
|
|||
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);
|
||||
}
|
||||
|
@ -453,43 +381,14 @@ public class CommunityMarketplaceAddonService implements AddonService {
|
|||
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));
|
||||
|
||||
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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,8 @@ package org.openhab.core.addon.marketplace.internal.community.model;
|
|||
|
||||
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 @@ import java.util.Date;
|
|||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,8 @@ package org.openhab.core.addon.marketplace.internal.community.model;
|
|||
|
||||
import java.util.Date;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* A DTO class mapped to the Discourse topic API.
|
||||
*
|
||||
|
@ -22,57 +24,72 @@ import java.util.Date;
|
|||
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;
|
||||
@SerializedName("posts_count")
|
||||
public Integer postsCount;
|
||||
@SerializedName("image_url")
|
||||
public String imageUrl;
|
||||
|
||||
public Date created_at;
|
||||
public Date updated_at;
|
||||
public Date last_posted;
|
||||
@SerializedName("created_at")
|
||||
public Date createdAt;
|
||||
@SerializedName("updated_at")
|
||||
public Date updatedAt;
|
||||
@SerializedName("last_posted")
|
||||
public Date lastPosted;
|
||||
|
||||
public Integer like_count;
|
||||
@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;
|
||||
}
|
||||
|
|
|
@ -19,33 +19,25 @@ import java.lang.reflect.Type;
|
|||
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,77 +45,58 @@ import org.osgi.service.component.annotations.Modified;
|
|||
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<String, AddonType> 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<MarketplaceAddonHandler> addonHandlers = new HashSet<>();
|
||||
|
||||
private List<String> addonserviceUrls = List.of();
|
||||
private List<AddonEntryDTO> cachedAddons = List.of();
|
||||
|
||||
private List<String> 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<String, Object> config) {
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.configurationAdmin = configurationAdmin;
|
||||
public JsonAddonService(@Reference EventPublisher eventPublisher, @Reference StorageService storageService,
|
||||
@Reference ConfigurationAdmin configurationAdmin, Map<String, Object> config) {
|
||||
super(eventPublisher, configurationAdmin, storageService, SERVICE_PID);
|
||||
modified(config);
|
||||
}
|
||||
|
||||
@Modified
|
||||
public void modified(Map<String, Object> 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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
@ -140,13 +113,8 @@ public class JsonAddonService implements AddonService {
|
|||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void refreshSource() {
|
||||
if (!remoteEnabled()) {
|
||||
cachedAddons = List.of();
|
||||
return;
|
||||
}
|
||||
|
||||
cachedAddons = (List<AddonEntryDTO>) addonserviceUrls.stream().map(urlString -> {
|
||||
protected List<Addon> getRemoteAddons() {
|
||||
return addonServiceUrls.stream().map(urlString -> {
|
||||
try {
|
||||
URL url = new URL(urlString);
|
||||
URLConnection connection = url.openConnection();
|
||||
|
@ -158,72 +126,15 @@ public class JsonAddonService implements AddonService {
|
|||
} 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<Addon> 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<AddonType> 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 @@ public class JsonAddonService implements AddonService {
|
|||
.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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = "";
|
||||
}
|
||||
|
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* 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.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;
|
||||
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} contains tests for the
|
||||
* {@link org.openhab.core.addon.marketplace.AbstractRemoteAddonService}
|
||||
*
|
||||
* @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<String> storage;
|
||||
private @NonNullByDefault({}) TestAddonService addonService;
|
||||
private @NonNullByDefault({}) TestAddonHandler addonHandler;
|
||||
private final Dictionary<String, Object> properties = new Hashtable<>();
|
||||
|
||||
@BeforeEach
|
||||
public void initialize() throws IOException {
|
||||
storage = new VolatileStorage<>();
|
||||
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(addonHandler);
|
||||
}
|
||||
|
||||
// general tests
|
||||
|
||||
@Test
|
||||
public void testRemoteDisabledBlocksRemoteCalls() {
|
||||
properties.put("remote", false);
|
||||
List<Addon> addons = addonService.getAddons(null);
|
||||
Assertions.assertEquals(0, addons.size());
|
||||
Assertions.assertEquals(0, addonService.getRemoteCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAddonResultsAreCached() {
|
||||
List<Addon> 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 testAddonIsReportedAsInstalledIfStorageEntryMissing() {
|
||||
addonService.setInstalled(TEST_ADDON);
|
||||
List<Addon> 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());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInstalledAddonIsStillPresentAfterRemoteIsDisabledOrMissing() {
|
||||
addonService.setInstalled(TEST_ADDON);
|
||||
addonService.addToStorage(TEST_ADDON);
|
||||
|
||||
// check all addons are present
|
||||
List<Addon> 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
|
||||
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<Event> 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));
|
||||
|
||||
// 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 SERVICE_PID + ":" + id;
|
||||
}
|
||||
}
|
|
@ -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 TestAddonHandler} is a
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class TestAddonHandler implements MarketplaceAddonHandler {
|
||||
private static final Set<String> SUPPORTED_ADDON_TYPES = Set.of("binding", "automation");
|
||||
public static final String TEST_ADDON_CONTENT_TYPE = "testAddonContentType";
|
||||
|
||||
private final Set<String> 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 (addon.getId().endsWith(":" + INSTALL_EXCEPTION_ADDON)) {
|
||||
throw new MarketplaceHandlerException("Installation failed", null);
|
||||
}
|
||||
installedAddons.add(addon.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void uninstall(Addon addon) throws MarketplaceHandlerException {
|
||||
if (addon.getId().endsWith(":" + UNINSTALL_EXCEPTION_ADDON)) {
|
||||
throw new MarketplaceHandlerException("Uninstallation failed", null);
|
||||
}
|
||||
installedAddons.remove(addon.getId());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* 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.List;
|
||||
import java.util.Locale;
|
||||
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.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;
|
||||
|
||||
/**
|
||||
* 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 Set<String> REMOTE_ADDONS = Set.of(TEST_ADDON, INSTALL_EXCEPTION_ADDON,
|
||||
UNINSTALL_EXCEPTION_ADDON);
|
||||
|
||||
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<Addon> getRemoteAddons() {
|
||||
remoteCalls++;
|
||||
return REMOTE_ADDONS.stream()
|
||||
.map(id -> Addon.create(SERVICE_PID + ":" + id).withType("binding")
|
||||
.withContentType(TestAddonHandler.TEST_ADDON_CONTENT_TYPE).build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@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 cachedAddons.stream().filter(a -> remoteId.equals(a.getId())).findAny().orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable String getAddonId(URI addonURI) {
|
||||
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));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue