[addonservices] Add version filtering (#2811)

Signed-off-by: Jan N. Klug <github@klug.nrw>
pull/2861/head
J-N-K 2022-03-20 18:43:07 +01:00 committed by GitHub
parent c2ba4dcd16
commit 4577562f08
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 437 additions and 49 deletions

View File

@ -27,6 +27,7 @@ import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.OpenHAB;
import org.openhab.core.addon.Addon;
import org.openhab.core.addon.AddonEventFactory;
import org.openhab.core.addon.AddonService;
@ -37,6 +38,7 @@ 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.framework.FrameworkUtil;
import org.osgi.service.cm.Configuration;
import org.osgi.service.cm.ConfigurationAdmin;
import org.slf4j.Logger;
@ -52,6 +54,9 @@ import com.google.gson.GsonBuilder;
*/
@NonNullByDefault
public abstract class AbstractRemoteAddonService implements AddonService {
static final String CONFIG_REMOTE_ENABLED = "remote";
static final String CONFIG_INCLUDE_INCOMPATIBLE = "includeIncompatible";
protected static final Map<String, AddonType> TAG_ADDON_TYPE_MAP = Map.of( //
"automation", new AddonType("automation", "Automation"), //
"binding", new AddonType("binding", "Bindings"), //
@ -61,6 +66,8 @@ public abstract class AbstractRemoteAddonService implements AddonService {
"ui", new AddonType("ui", "User Interfaces"), //
"voice", new AddonType("voice", "Voice"));
protected final BundleVersion coreVersion;
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;
@ -78,6 +85,11 @@ public abstract class AbstractRemoteAddonService implements AddonService {
this.eventPublisher = eventPublisher;
this.configurationAdmin = configurationAdmin;
this.installedAddonStorage = storageService.getStorage(servicePid);
this.coreVersion = getCoreVersion();
}
protected BundleVersion getCoreVersion() {
return new BundleVersion(FrameworkUtil.getBundle(OpenHAB.class).getVersion().toString());
}
@Override
@ -102,6 +114,10 @@ public abstract class AbstractRemoteAddonService implements AddonService {
// check real installation status based on handlers
addons.forEach(addon -> addon.setInstalled(addonHandlers.stream().anyMatch(h -> h.isInstalled(addon.getId()))));
// remove incompatible add-ons if not enabled
boolean showIncompatible = includeIncompatible();
addons.removeIf(addon -> !addon.getCompatible() && !showIncompatible);
cachedAddons = addons;
this.installedAddons = installedAddons;
}
@ -216,12 +232,26 @@ public abstract class AbstractRemoteAddonService implements AddonService {
// 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);
return ConfigParser.valueAsOrElse(properties.get(CONFIG_REMOTE_ENABLED), Boolean.class, true);
} catch (IOException e) {
return true;
}
}
protected boolean includeIncompatible() {
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 false (default is show compatible only)
return true;
}
return ConfigParser.valueAsOrElse(properties.get(CONFIG_INCLUDE_INCOMPATIBLE), Boolean.class, false);
} catch (IOException e) {
return false;
}
}
private void postInstalledEvent(String extensionId) {
Event event = AddonEventFactory.createAddonInstalledEvent(extensionId);
eventPublisher.post(event);

View File

@ -0,0 +1,153 @@
/**
* 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.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link BundleVersion} wraps a bundle version and provides a method to compare them
*
* @author Jan N. Klug - Initial contribution
*/
@NonNullByDefault
public class BundleVersion {
private static final Pattern VERSION_PATTERN = Pattern.compile(
"(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<micro>\\d+)(\\.((?<rc>RC)|(?<milestone>M))?(?<qualifier>\\d+))?");
public static final Pattern RANGE_PATTERN = Pattern.compile(
"\\[(?<start>\\d+\\.\\d+(?<startmicro>\\.\\d+(\\.\\w+)?)?);(?<end>\\d+\\.\\d+(?<endmicro>\\.\\d+(\\.\\w+)?)?)(?<endtype>[)\\]])");
private final Logger logger = LoggerFactory.getLogger(BundleVersion.class);
private final int major;
private final int minor;
private final int micro;
private final @Nullable Long qualifier;
public BundleVersion(String version) {
Matcher matcher = VERSION_PATTERN.matcher(version);
if (matcher.matches()) {
this.major = Integer.parseInt(matcher.group("major"));
this.minor = Integer.parseInt(matcher.group("minor"));
this.micro = Integer.parseInt(matcher.group("micro"));
String qualifier = matcher.group("qualifier");
if (qualifier != null) {
long intQualifier = Long.parseLong(qualifier);
if (matcher.group("rc") != null) {
// we can safely assume that there are less than Integer.MAX_VALUE milestones
// so RCs are always newer than milestones
// since snapshot qualifiers are larger than 10*Integer.MAX_VALUE they are
// still considered newer
this.qualifier = intQualifier + Integer.MAX_VALUE;
} else {
this.qualifier = intQualifier;
}
} else {
this.qualifier = null;
}
} else {
throw new IllegalArgumentException("Input does not match pattern");
}
}
/**
* Test if this version is within the provided range
*
* @param range a Maven like version range
* @return {@code true} if this version is inside range, {@code false} otherwise
* @throws IllegalArgumentException if {@code range} does not represent a valid range
*/
public boolean inRange(@Nullable String range) throws IllegalArgumentException {
if (range == null || range.isBlank()) {
// if no range is given, we assume the range covers everything
return true;
}
Matcher matcher = RANGE_PATTERN.matcher(range);
if (!matcher.matches()) {
throw new IllegalArgumentException(range + "is not a valid version range");
}
String startString = matcher.group("startmicro") != null ? matcher.group("start")
: matcher.group("start") + ".0";
BundleVersion startVersion = new BundleVersion(startString);
if (this.compareTo(startVersion) < 0) {
return false;
}
String endString = matcher.group("endmicro") != null ? matcher.group("end") : matcher.group("stop") + ".0";
boolean inclusive = "]".equals(matcher.group("endtype"));
BundleVersion endVersion = new BundleVersion(endString);
int comparison = this.compareTo(endVersion);
return (inclusive && comparison == 0) || comparison < 0;
}
@Override
public boolean equals(@Nullable Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
BundleVersion version = (BundleVersion) o;
return major == version.major && minor == version.minor && micro == version.micro
&& Objects.equals(qualifier, version.qualifier);
}
@Override
public int hashCode() {
return Objects.hash(major, minor, micro, qualifier);
}
/**
* Compares two bundle versions
*
* @param other the other bundle version
* @return a positive integer if this version is newer than the other version, a negative number if this version is
* older than the other version and 0 if the versions are equal
*/
public int compareTo(BundleVersion other) {
int result = major - other.major;
if (result != 0) {
return result;
}
result = minor - other.minor;
if (result != 0) {
return result;
}
result = micro - other.micro;
if (result != 0) {
return result;
}
if (Objects.equals(qualifier, other.qualifier)) {
return 0;
}
// the release is always newer than a milestone or snapshot
if (qualifier == null) { // we are the release
return 1;
}
if (other.qualifier == null) { // the other is the release
return -1;
}
// both versions are milestones, we can compare them
return Long.compare(qualifier, other.qualifier);
}
}

View File

@ -27,6 +27,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -36,6 +37,7 @@ import org.openhab.core.addon.Addon;
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.BundleVersion;
import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO.DiscoursePosterInfo;
@ -43,6 +45,7 @@ import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCate
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;
@ -80,6 +83,7 @@ public class CommunityMarketplaceAddonService extends AbstractRemoteAddonService
static final String CONFIG_URI = "system:marketplace";
static final String CONFIG_API_KEY = "apiKey";
static final String CONFIG_SHOW_UNPUBLISHED_ENTRIES_KEY = "showUnpublished";
static final String CONFIG_ENABLED_KEY = "enabled";
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";
@ -103,6 +107,7 @@ public class CommunityMarketplaceAddonService extends AbstractRemoteAddonService
private @Nullable String apiKey = null;
private boolean showUnpublished = false;
private boolean enabled = true;
@Activate
public CommunityMarketplaceAddonService(final @Reference EventPublisher eventPublisher,
@ -116,9 +121,9 @@ public class CommunityMarketplaceAddonService extends AbstractRemoteAddonService
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());
this.showUnpublished = ConfigParser.valueAsOrElse(config.get(CONFIG_SHOW_UNPUBLISHED_ENTRIES_KEY),
Boolean.class, false);
this.enabled = ConfigParser.valueAsOrElse(config.get(CONFIG_ENABLED_KEY), Boolean.class, true);
cachedRemoteAddons.invalidateValue();
refreshSource();
}
@ -147,6 +152,10 @@ public class CommunityMarketplaceAddonService extends AbstractRemoteAddonService
@Override
protected List<Addon> getRemoteAddons() {
if (!enabled) {
return List.of();
}
List<Addon> addons = new ArrayList<>();
try {
List<DiscourseCategoryResponseDTO> pages = new ArrayList<>();
@ -277,6 +286,23 @@ public class CommunityMarketplaceAddonService extends AbstractRemoteAddonService
String contentType = getContentType(topic.categoryId, tags);
String title = topic.title;
boolean compatible = true;
int compatibilityStart = topic.title.lastIndexOf("["); // version range always starts with [
if (topic.title.lastIndexOf(" ") < compatibilityStart) { // check includes [ not present
String potentialRange = topic.title.substring(compatibilityStart + 1);
Matcher matcher = BundleVersion.RANGE_PATTERN.matcher(potentialRange);
if (matcher.matches()) {
try {
compatible = coreVersion.inRange(potentialRange);
title = topic.title.substring(0, compatibilityStart).trim();
} catch (IllegalArgumentException e) {
logger.debug("Failed to determine compatibility for addon {}: {}", topic.title, e.getMessage());
compatible = true;
}
}
}
String link = COMMUNITY_TOPIC_URL + topic.id.toString();
int likeCount = topic.likeCount;
int views = topic.views;
@ -303,7 +329,7 @@ public class CommunityMarketplaceAddonService extends AbstractRemoteAddonService
return Addon.create(id).withType(type).withContentType(contentType).withImageLink(topic.imageUrl)
.withAuthor(author).withProperties(properties).withLabel(title).withInstalled(installed)
.withMaturity(maturity).withLink(link).build();
.withMaturity(maturity).withCompatible(compatible).withLink(link).build();
}
/**

View File

@ -45,6 +45,8 @@ 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.reflect.TypeToken;
@ -69,6 +71,8 @@ public class JsonAddonService extends AbstractRemoteAddonService {
private static final String CONFIG_URLS = "urls";
private static final String CONFIG_SHOW_UNSTABLE = "showUnstable";
private final Logger logger = LoggerFactory.getLogger(JsonAddonService.class);
private List<String> addonServiceUrls = List.of();
private boolean showUnstable = false;
@ -158,11 +162,19 @@ public class JsonAddonService extends AbstractRemoteAddonService {
properties.put("yaml_download_url", addonEntry.url);
}
boolean compatible = true;
try {
compatible = coreVersion.inRange(addonEntry.compatibleVersions);
} catch (IllegalArgumentException e) {
logger.debug("Failed to determine compatibility for addon {}: {}", addonEntry.id, e.getMessage());
}
return Addon.create(fullId).withType(addonEntry.type).withInstalled(installed)
.withDetailedDescription(addonEntry.description).withContentType(addonEntry.contentType)
.withAuthor(addonEntry.author).withVersion(addonEntry.version).withLabel(addonEntry.title)
.withMaturity(addonEntry.maturity).withProperties(properties).withLink(addonEntry.link)
.withImageLink(addonEntry.imageUrl).withConfigDescriptionURI(addonEntry.configDescriptionURI)
.withLoggerPackages(addonEntry.loggerPackages).build();
.withCompatible(compatible).withMaturity(addonEntry.maturity).withProperties(properties)
.withLink(addonEntry.link).withImageLink(addonEntry.imageUrl)
.withConfigDescriptionURI(addonEntry.configDescriptionURI).withLoggerPackages(addonEntry.loggerPackages)
.build();
}
}

View File

@ -28,6 +28,8 @@ public class AddonEntryDTO {
public String title = "";
public String link = "";
public String version = "";
@SerializedName("compatible_versions")
public String compatibleVersions = "";
public String author = "";
public String configDescriptionURI = "";
public String maturity = "unstable";

View File

@ -6,6 +6,12 @@
https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="system:marketplace">
<parameter name="enable" type="boolean">
<label>Enable Community Marketplace</label>
<default>true</default>
<description>If set to false no add-ons from the community marketplace will be shown. Already installed add-ons will
still be available.</description>
</parameter>
<parameter name="showUnpublished" type="boolean">
<label>Show Unpublished Entries</label>
<default>false</default>
@ -29,9 +35,9 @@
thus harm your system.</description>
</parameter>
<parameter name="showUnstable" type="boolean">
<label>Show Non-Stable Bundles</label>
<label>Show Non-Stable Add-ons</label>
<default>false</default>
<description>Include entries which have not been tagged as "stable". These bundles should be used for testing
<description>Include entries which have not been tagged as "stable". These add-ons should be used for testing
purposes only and are not considered production-system ready.</description>
</parameter>
</config-description>

View File

@ -1,5 +1,5 @@
system.config.jsonaddonservice.showUnstable.label = Show Non-Stable Bundles
system.config.jsonaddonservice.showUnstable.description = Include entries which have not been tagged as "stable". These bundles should be used for testing purposes only and are not considered production-system ready.
system.config.jsonaddonservice.showUnstable.label = Show Non-Stable Add-ons
system.config.jsonaddonservice.showUnstable.description = Include entries which have not been tagged as "stable". These add-ons should be used for testing purposes only and are not considered production-system ready.
system.config.jsonaddonservice.urls.label = Add-on Service URLs
system.config.jsonaddonservice.urls.description = Pipe (|) separated list of URLS that provide 3rd party add-on services via Json files. Warning: Bundles distributed over 3rd party add-on services may lack proper review and can potentially contain malicious code and thus harm your system.
system.config.marketplace.apiKey.label = API Key for community.openhab.org

View File

@ -10,21 +10,28 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.addon.test;
package org.openhab.core.addon.marketplace;
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 static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.openhab.core.addon.marketplace.AbstractRemoteAddonService.CONFIG_REMOTE_ENABLED;
import static org.openhab.core.addon.marketplace.test.TestAddonService.ALL_ADDON_COUNT;
import static org.openhab.core.addon.marketplace.test.TestAddonService.COMPATIBLE_ADDON_COUNT;
import static org.openhab.core.addon.marketplace.test.TestAddonService.INSTALL_EXCEPTION_ADDON;
import static org.openhab.core.addon.marketplace.test.TestAddonService.SERVICE_PID;
import static org.openhab.core.addon.marketplace.test.TestAddonService.TEST_ADDON;
import static org.openhab.core.addon.marketplace.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;
@ -35,6 +42,8 @@ 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.addon.marketplace.test.TestAddonHandler;
import org.openhab.core.addon.marketplace.test.TestAddonService;
import org.openhab.core.events.Event;
import org.openhab.core.events.EventPublisher;
import org.openhab.core.storage.Storage;
@ -87,24 +96,24 @@ public class AbstractRemoteAddonServiceTest {
addonService.addAddonHandler(addonHandler);
List<Addon> addons = addonService.getAddons(null);
Assertions.assertEquals(0, addons.size());
assertThat(addons, empty());
}
@Test
public void testRemoteDisabledBlocksRemoteCalls() {
properties.put("remote", false);
List<Addon> addons = addonService.getAddons(null);
Assertions.assertEquals(0, addons.size());
Assertions.assertEquals(0, addonService.getRemoteCalls());
assertThat(addons, empty());
assertThat(addonService.getRemoteCalls(), is(0));
}
@Test
public void testAddonResultsAreCached() {
List<Addon> addons = addonService.getAddons(null);
Assertions.assertEquals(TestAddonService.REMOTE_ADDONS.size(), addons.size());
assertThat(addons, hasSize(COMPATIBLE_ADDON_COUNT));
addons = addonService.getAddons(null);
Assertions.assertEquals(TestAddonService.REMOTE_ADDONS.size(), addons.size());
Assertions.assertEquals(1, addonService.getRemoteCalls());
assertThat(addons, hasSize(COMPATIBLE_ADDON_COUNT));
assertThat(addonService.getRemoteCalls(), is(1));
}
@Test
@ -113,8 +122,8 @@ public class AbstractRemoteAddonServiceTest {
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());
assertThat(addon, notNullValue());
assertThat(addon.isInstalled(), is(true));
}
@Test
@ -124,15 +133,26 @@ public class AbstractRemoteAddonServiceTest {
// check all addons are present
List<Addon> addons = addonService.getAddons(null);
Assertions.assertEquals(TestAddonService.REMOTE_ADDONS.size(), addons.size());
assertThat(addons, hasSize(COMPATIBLE_ADDON_COUNT));
// disable remote repo
properties.put("remote", false);
properties.put(CONFIG_REMOTE_ENABLED, 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());
assertThat(addons, hasSize(1));
assertThat(addons.get(0).getId(), is(getFullAddonId(TEST_ADDON)));
}
@Test
public void testIncompatibleAddonsNotIncludedByDefault() {
assertThat(addonService.getAddons(null), hasSize(COMPATIBLE_ADDON_COUNT));
}
@Test
public void testIncompatibleAddonsAreIncludedIfRequested() {
properties.put("includeIncompatible", true);
assertThat(addonService.getAddons(null), hasSize(ALL_ADDON_COUNT));
}
// installation tests
@ -242,22 +262,21 @@ public class AbstractRemoteAddonServiceTest {
Event event = eventCaptor.getValue();
String topic = "openhab/addons/" + expectedEventTopic;
Assertions.assertEquals(topic, event.getTopic());
assertThat(event.getTopic(), is(topic));
// assert addon handler was called (by checking it's installed status)
Assertions.assertEquals(installStatus, addonHandler.isInstalled(getFullAddonId(id)));
assertThat(addonHandler.isInstalled(getFullAddonId(id)), is(installStatus));
// assert is present in storage if installed or missing if uninstalled
Assertions.assertEquals(installStatus, storage.containsKey(id));
assertThat(storage.containsKey(id), is(installStatus));
// 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());
assertThat(addon, notNullValue());
assertThat(addon.isInstalled(), is(installStatus));
} else {
Assertions.assertNull(addon);
assertThat(addon, nullValue());
}
}

View File

@ -0,0 +1,107 @@
/**
* 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 static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.lessThan;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
/**
* The {@link BundleVersionTest} contains tests for the {@link BundleVersion} class
*
* @author Jan N. Klug - Initial contribution
*/
@NonNullByDefault
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.WARN)
public class BundleVersionTest {
private static Stream<Arguments> provideCompareVersionsArguments() {
return Stream.of( //
Arguments.of("3.1.0", "3.1.0", Result.EQUAL), // same versions are equal
Arguments.of("3.1.0", "3.0.2", Result.NEWER), // minor version is more important than micro
Arguments.of("3.7.0", "4.0.1.202105311711", Result.OLDER), // major version is more important than minor
Arguments.of("3.9.1.M1", "3.9.0.M5", Result.NEWER), // micro version is more important than qualifier
Arguments.of("3.0.0.202105311032", "3.0.0.202106011144", Result.OLDER), // snapshots
Arguments.of("3.1.0.M3", "3.1.0.M1", Result.NEWER), // milestones are compared numerically
Arguments.of("3.1.0.M1", "3.1.0.197705310021", Result.OLDER), // snapshot is newer than milestone
Arguments.of("3.3.0", "3.3.0.202206302115", Result.NEWER), // release is newer than snapshot
Arguments.of("3.3.0", "3.3.0.RC1", Result.NEWER), // releases are newer than release candidates
Arguments.of("3.3.0.M5", "3.3.0.RC1", Result.OLDER), // milestones are older than release candidates
Arguments.of("3.3.0.RC2", "3.3.0.202305201715", Result.OLDER) // snapshots are newer than release
// candidates
);
}
@Test
public void testIllegalRangeThrowsException() {
BundleVersion bundleVersion = new BundleVersion("3.1.0");
assertThrows(IllegalArgumentException.class, () -> bundleVersion.inRange("illegal"));
}
@ParameterizedTest
@MethodSource("provideCompareVersionsArguments")
public void testCompareVersions(String v1, String v2, Result result) {
BundleVersion version1 = new BundleVersion(v1);
BundleVersion version2 = new BundleVersion(v2);
switch (result) {
case OLDER:
assertThat(version1.compareTo(version2), lessThan(0));
break;
case NEWER:
assertThat(version1.compareTo(version2), greaterThan(0));
break;
case EQUAL:
assertThat(version1.compareTo(version2), is(0));
break;
}
}
private static Stream<Arguments> provideInRangeArguments() {
return Stream.of(Arguments.of("[3.1.0;3.2.1)", true), // in range
Arguments.of("[3.1.0;3.2.0)", false), // at end of range, non-inclusive
Arguments.of("[3.1.0;3.2.0]", true), // at end of range, inclusive
Arguments.of("[3.1.0;3.1.5)", false), // above range
Arguments.of("[3.3.0;3.4.0)", false), // below range
Arguments.of("", true), // empty range assumes in range
Arguments.of(null, true));
}
@ParameterizedTest
@MethodSource("provideInRangeArguments")
public void inRangeTest(@Nullable String range, boolean result) {
BundleVersion frameworkVersion = new BundleVersion("3.2.0");
assertThat(frameworkVersion.inRange(range), is(result));
}
private enum Result {
OLDER,
NEWER,
EQUAL
}
}

View File

@ -10,10 +10,10 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.addon.test;
package org.openhab.core.addon.marketplace.test;
import static org.openhab.core.addon.test.TestAddonService.INSTALL_EXCEPTION_ADDON;
import static org.openhab.core.addon.test.TestAddonService.UNINSTALL_EXCEPTION_ADDON;
import static org.openhab.core.addon.marketplace.test.TestAddonService.INSTALL_EXCEPTION_ADDON;
import static org.openhab.core.addon.marketplace.test.TestAddonService.UNINSTALL_EXCEPTION_ADDON;
import java.util.HashSet;
import java.util.Set;

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.addon.test;
package org.openhab.core.addon.marketplace.test;
import java.net.URI;
import java.util.List;
@ -22,6 +22,7 @@ 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.BundleVersion;
import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
import org.openhab.core.events.EventPublisher;
@ -38,10 +39,14 @@ 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 INCOMPATIBLE_VERSION = "incompatibleVersion";
public static final String SERVICE_PID = "testAddonService";
public static final Set<String> REMOTE_ADDONS = Set.of(TEST_ADDON, INSTALL_EXCEPTION_ADDON,
UNINSTALL_EXCEPTION_ADDON);
UNINSTALL_EXCEPTION_ADDON, INCOMPATIBLE_VERSION);
public static final int COMPATIBLE_ADDON_COUNT = REMOTE_ADDONS.size() - 1;
public static final int ALL_ADDON_COUNT = REMOTE_ADDONS.size();
private int remoteCalls = 0;
@ -50,6 +55,11 @@ public class TestAddonService extends AbstractRemoteAddonService {
super(eventPublisher, configurationAdmin, storageService, SERVICE_PID);
}
@Override
protected BundleVersion getCoreVersion() {
return new BundleVersion("3.2.0");
}
public void addAddonHandler(MarketplaceAddonHandler handler) {
this.addonHandlers.add(handler);
}
@ -63,7 +73,8 @@ public class TestAddonService extends AbstractRemoteAddonService {
remoteCalls++;
return REMOTE_ADDONS.stream()
.map(id -> Addon.create(SERVICE_PID + ":" + id).withType("binding")
.withContentType(TestAddonHandler.TEST_ADDON_CONTENT_TYPE).build())
.withContentType(TestAddonHandler.TEST_ADDON_CONTENT_TYPE)
.withCompatible(!id.equals(INCOMPATIBLE_VERSION)).build())
.collect(Collectors.toList());
}

View File

@ -32,6 +32,7 @@ public class Addon {
private final String label;
private final String version;
private final @Nullable String maturity;
private boolean compatible;
private final String contentType;
private final @Nullable String link;
private final String author;
@ -58,6 +59,7 @@ public class Addon {
* @param label the label of the add-on
* @param version the version of the add-on
* @param maturity the maturity level of this version
* @param compatible if this add-on is compatible with the current core version
* @param contentType the content type of the add-on
* @param link the link to find more information about the add-on (may be null)
* @param author the author of the add-on
@ -76,8 +78,8 @@ public class Addon {
* @param properties a {@link Map} containing addition information
* @param loggerPackages a {@link List} containing the package names belonging to this add-on
*/
private Addon(String id, String type, String label, String version, @Nullable String maturity, String contentType,
@Nullable String link, String author, boolean verifiedAuthor, boolean installed,
private Addon(String id, String type, String label, String version, @Nullable String maturity, boolean compatible,
String contentType, @Nullable String link, String author, boolean verifiedAuthor, boolean installed,
@Nullable String description, @Nullable String detailedDescription, String configDescriptionURI,
String keywords, String countries, @Nullable String license, String connection,
@Nullable String backgroundColor, @Nullable String imageLink, @Nullable Map<String, Object> properties,
@ -86,6 +88,7 @@ public class Addon {
this.label = label;
this.version = version;
this.maturity = maturity;
this.compatible = compatible;
this.contentType = contentType;
this.description = description;
this.detailedDescription = detailedDescription;
@ -161,6 +164,13 @@ public class Addon {
return maturity;
}
/**
* The (expected) compatibility of this add-on
*/
public boolean getCompatible() {
return compatible;
}
/**
* The content type of the add-on
*/
@ -268,6 +278,7 @@ public class Addon {
private String label;
private String version = "";
private @Nullable String maturity;
private boolean compatible = true;
private String contentType;
private @Nullable String link;
private String author = "";
@ -305,6 +316,11 @@ public class Addon {
return this;
}
public Builder withCompatible(boolean compatible) {
this.compatible = compatible;
return this;
}
public Builder withContentType(String contentType) {
this.contentType = contentType;
return this;
@ -397,9 +413,9 @@ public class Addon {
}
public Addon build() {
return new Addon(id, type, label, version, maturity, contentType, link, author, verifiedAuthor, installed,
description, detailedDescription, configDescriptionURI, keywords, countries, license, connection,
backgroundColor, imageLink, properties.isEmpty() ? null : properties, loggerPackages);
return new Addon(id, type, label, version, maturity, compatible, contentType, link, author, verifiedAuthor,
installed, description, detailedDescription, configDescriptionURI, keywords, countries, license,
connection, backgroundColor, imageLink, properties.isEmpty() ? null : properties, loggerPackages);
}
}
}

View File

@ -11,6 +11,12 @@
<description>Defines whether openHAB should access the remote repository for add-on installation.</description>
<default>true</default>
</parameter>
<parameter name="includeIncompatible" type="boolean">
<label>Include (Potentially) Incompatible Add-ons</label>
<description>Some add-on services may provide add-ons where compatibility with the currently running system is not
expected. Enabling this option will include these entries in the list of available add-ons.</description>
<default>false</default>
</parameter>
</config-description>
</config-description:config-descriptions>