Added strategy checking
This commit is contained in:
parent
d228208072
commit
15be6d60a3
@ -15,7 +15,6 @@
|
||||
*/
|
||||
package org.thingsboard.server.service.device;
|
||||
|
||||
import com.datastax.oss.driver.api.core.uuid.Uuids;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
@ -30,9 +29,11 @@ import org.thingsboard.server.common.data.Device;
|
||||
import org.thingsboard.server.common.data.DeviceProfile;
|
||||
import org.thingsboard.server.common.data.DeviceProfileType;
|
||||
import org.thingsboard.server.common.data.audit.ActionType;
|
||||
import org.thingsboard.server.common.data.device.data.ProvisionDeviceConfiguration;
|
||||
import org.thingsboard.server.common.data.device.profile.ProvisionDeviceProfileConfiguration;
|
||||
import org.thingsboard.server.common.data.device.profile.ProvisionRequestValidationStrategyType;
|
||||
import org.thingsboard.server.common.data.id.CustomerId;
|
||||
import org.thingsboard.server.common.data.id.TenantId;
|
||||
import org.thingsboard.server.common.data.id.UserId;
|
||||
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
|
||||
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
|
||||
import org.thingsboard.server.common.data.kv.StringDataEntry;
|
||||
@ -45,6 +46,7 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
|
||||
import org.thingsboard.server.dao.attributes.AttributesService;
|
||||
import org.thingsboard.server.dao.audit.AuditLogService;
|
||||
import org.thingsboard.server.dao.device.DeviceCredentialsService;
|
||||
import org.thingsboard.server.dao.device.DeviceDao;
|
||||
import org.thingsboard.server.dao.device.DeviceProfileDao;
|
||||
import org.thingsboard.server.dao.device.DeviceProvisionService;
|
||||
import org.thingsboard.server.dao.device.DeviceService;
|
||||
@ -65,8 +67,6 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID;
|
||||
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@ -77,10 +77,11 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService {
|
||||
private static final String DEVICE_PROVISION_STATE = "provisionState";
|
||||
private static final String PROVISIONED_STATE = "provisioned";
|
||||
|
||||
private static final UserId PROVISION_USER_ID = UserId.fromString(NULL_UUID.toString());
|
||||
|
||||
private final ReentrantLock deviceCreationLock = new ReentrantLock();
|
||||
|
||||
@Autowired
|
||||
DeviceDao deviceDao;
|
||||
|
||||
@Autowired
|
||||
DeviceProfileDao deviceProfileDao;
|
||||
|
||||
@ -105,39 +106,52 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService {
|
||||
|
||||
@Override
|
||||
public ListenableFuture<ProvisionResponse> provisionDevice(ProvisionRequest provisionRequest) {
|
||||
DeviceProfile targetProfile = deviceProfileDao.findProfileByTenantIdAndProfileDataProvisionConfigurationPair(
|
||||
Device targetDevice = deviceDao.findDeviceByTenantIdAndDeviceDataProvisionConfigurationPair(
|
||||
TenantId.SYS_TENANT_ID,
|
||||
provisionRequest.getCredentials().getProvisionDeviceKey(),
|
||||
provisionRequest.getCredentials().getProvisionDeviceSecret());
|
||||
|
||||
if (targetProfile.getProfileData().getConfiguration().getType() != DeviceProfileType.PROVISION) {
|
||||
return Futures.immediateFuture(new ProvisionResponse(null, ProvisionResponseStatus.NOT_FOUND));
|
||||
}
|
||||
if (targetDevice != null) {
|
||||
if (targetDevice.getDeviceData().getConfiguration().getType() != DeviceProfileType.PROVISION) {
|
||||
return Futures.immediateFuture(new ProvisionResponse(null, ProvisionResponseStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
ProvisionDeviceProfileConfiguration currentProfileConfiguration = (ProvisionDeviceProfileConfiguration) targetProfile.getProfileData().getConfiguration();
|
||||
if (!new ProvisionDeviceProfileConfiguration(provisionRequest.getCredentials().getProvisionDeviceKey(), provisionRequest.getCredentials().getProvisionDeviceSecret()).equals(currentProfileConfiguration)) {
|
||||
return Futures.immediateFuture(new ProvisionResponse(null, ProvisionResponseStatus.NOT_FOUND));
|
||||
}
|
||||
DeviceProfile targetProfile = deviceProfileDao.findById(TenantId.SYS_TENANT_ID, targetDevice.getDeviceProfileId().getId());
|
||||
|
||||
Device device = deviceService.findDeviceByTenantIdAndName(targetProfile.getTenantId(), provisionRequest.getDeviceName());
|
||||
switch (currentProfileConfiguration.getStrategy()) {
|
||||
case CHECK_NEW_DEVICE:
|
||||
if (device == null) {
|
||||
return createDevice(provisionRequest, targetProfile);
|
||||
} else {
|
||||
log.warn("[{}] The device is present and could not be provisioned once more!", device.getName());
|
||||
notify(device, provisionRequest, DataConstants.PROVISION_FAILURE, false);
|
||||
ProvisionDeviceConfiguration currentProfileConfiguration = (ProvisionDeviceConfiguration) targetDevice.getDeviceData().getConfiguration();
|
||||
if (!new ProvisionDeviceConfiguration(provisionRequest.getCredentials().getProvisionDeviceKey(), provisionRequest.getCredentials().getProvisionDeviceSecret()).equals(currentProfileConfiguration)) {
|
||||
return Futures.immediateFuture(new ProvisionResponse(null, ProvisionResponseStatus.NOT_FOUND));
|
||||
}
|
||||
ProvisionRequestValidationStrategyType targetStrategy = getStrategy(targetProfile);
|
||||
switch (targetStrategy) {
|
||||
case CHECK_NEW_DEVICE:
|
||||
log.warn("[{}] The device is present and could not be provisioned once more!", targetDevice.getName());
|
||||
notify(targetDevice, provisionRequest, DataConstants.PROVISION_FAILURE, false);
|
||||
return Futures.immediateFuture(new ProvisionResponse(null, ProvisionResponseStatus.FAILURE));
|
||||
}
|
||||
case CHECK_PRE_PROVISIONED_DEVICE:
|
||||
if (device == null) {
|
||||
case CHECK_PRE_PROVISIONED_DEVICE:
|
||||
return processProvision(targetDevice, provisionRequest);
|
||||
default:
|
||||
throw new RuntimeException("Strategy is not supported - " + targetStrategy.name());
|
||||
}
|
||||
} else {
|
||||
DeviceProfile targetProfile = deviceProfileDao.findProfileByTenantIdAndProfileDataProvisionConfigurationPair(
|
||||
TenantId.SYS_TENANT_ID,
|
||||
provisionRequest.getCredentials().getProvisionDeviceKey(),
|
||||
provisionRequest.getCredentials().getProvisionDeviceSecret()
|
||||
);
|
||||
if (targetProfile.getProfileData().getConfiguration().getType() != DeviceProfileType.PROVISION) {
|
||||
return Futures.immediateFuture(new ProvisionResponse(null, ProvisionResponseStatus.NOT_FOUND));
|
||||
}
|
||||
ProvisionRequestValidationStrategyType targetStrategy = getStrategy(targetProfile);
|
||||
switch (targetStrategy) {
|
||||
case CHECK_NEW_DEVICE:
|
||||
return createDevice(provisionRequest, targetProfile);
|
||||
case CHECK_PRE_PROVISIONED_DEVICE:
|
||||
log.warn("[{}] Failed to find pre provisioned device!", provisionRequest.getDeviceName());
|
||||
return Futures.immediateFuture(new ProvisionResponse(null, ProvisionResponseStatus.FAILURE));
|
||||
} else {
|
||||
return processProvision(device, provisionRequest);
|
||||
}
|
||||
default:
|
||||
throw new RuntimeException("Strategy is not supported - " + currentProfileConfiguration.getStrategy().name());
|
||||
default:
|
||||
throw new RuntimeException("Strategy is not supported - " + targetStrategy.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -178,6 +192,16 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService {
|
||||
}
|
||||
}
|
||||
|
||||
private void notify(Device device, ProvisionRequest provisionRequest, String type, boolean success) {
|
||||
pushProvisionEventToRuleEngine(provisionRequest, device, type);
|
||||
logAction(device.getTenantId(), device.getCustomerId(), device, success, provisionRequest);
|
||||
}
|
||||
|
||||
private ProvisionRequestValidationStrategyType getStrategy(DeviceProfile profile) {
|
||||
return ((ProvisionDeviceProfileConfiguration) profile.getProfileData().getConfiguration()).getStrategy();
|
||||
|
||||
}
|
||||
|
||||
private ListenableFuture<ProvisionResponse> processCreateDevice(ProvisionRequest provisionRequest, DeviceProfile profile) {
|
||||
Device device = deviceService.findDeviceByTenantIdAndName(profile.getTenantId(), provisionRequest.getDeviceName());
|
||||
if (device == null) {
|
||||
@ -221,15 +245,10 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
private void notify(Device device, ProvisionRequest provisionRequest, String type, boolean success) {
|
||||
pushProvisionEventToRuleEngine(provisionRequest, device, type);
|
||||
logAction(device.getTenantId(), device, success, provisionRequest);
|
||||
}
|
||||
|
||||
private void pushProvisionEventToRuleEngine(ProvisionRequest request, Device device, String type) {
|
||||
try {
|
||||
ObjectNode entityNode = JacksonUtil.OBJECT_MAPPER.valueToTree(request);
|
||||
TbMsg msg = new TbMsg(Uuids.timeBased(), type, device.getId(), createTbMsgMetaData(device), JacksonUtil.OBJECT_MAPPER.writeValueAsString(entityNode), null, null, 0L);
|
||||
TbMsg msg = TbMsg.newMsg(type, device.getId(), createTbMsgMetaData(device), JacksonUtil.OBJECT_MAPPER.writeValueAsString(entityNode));
|
||||
sendToRuleEngine(device.getTenantId(), msg, null);
|
||||
} catch (JsonProcessingException | IllegalArgumentException e) {
|
||||
log.warn("[{}] Failed to push device action to rule engine: {}", device.getId(), type, e);
|
||||
@ -239,7 +258,7 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService {
|
||||
private void pushDeviceCreatedEventToRuleEngine(Device device) {
|
||||
try {
|
||||
ObjectNode entityNode = JacksonUtil.OBJECT_MAPPER.valueToTree(device);
|
||||
TbMsg msg = new TbMsg(Uuids.timeBased(), DataConstants.ENTITY_CREATED, device.getId(), createTbMsgMetaData(device), JacksonUtil.OBJECT_MAPPER.writeValueAsString(entityNode), null, null, 0L);
|
||||
TbMsg msg = TbMsg.newMsg(DataConstants.ENTITY_CREATED, device.getId(), createTbMsgMetaData(device), JacksonUtil.OBJECT_MAPPER.writeValueAsString(entityNode));
|
||||
sendToRuleEngine(device.getTenantId(), msg, null);
|
||||
} catch (JsonProcessingException | IllegalArgumentException e) {
|
||||
log.warn("[{}] Failed to push device action to rule engine: {}", device.getId(), DataConstants.ENTITY_CREATED, e);
|
||||
@ -260,8 +279,8 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService {
|
||||
return metaData;
|
||||
}
|
||||
|
||||
private void logAction(TenantId tenantId, Device device, boolean success, ProvisionRequest provisionRequest) {
|
||||
private void logAction(TenantId tenantId, CustomerId customerId, Device device, boolean success, ProvisionRequest provisionRequest) {
|
||||
ActionType actionType = success ? ActionType.PROVISION_SUCCESS : ActionType.PROVISION_FAILURE;
|
||||
auditLogService.logEntityAction(tenantId, null, null, device.getName(), device.getId(), device, actionType, null, provisionRequest);
|
||||
auditLogService.logEntityAction(tenantId, customerId, null, device.getName(), device.getId(), device, actionType, null, provisionRequest);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ package org.thingsboard.server.dao.device.provision;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import org.thingsboard.server.common.data.device.profile.ProvisionDeviceProfileConfiguration;
|
||||
import org.thingsboard.server.common.data.device.data.ProvisionDeviceConfiguration;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@ -25,5 +25,5 @@ public class ProvisionRequest {
|
||||
private String deviceName;
|
||||
private String deviceType;
|
||||
private String x509CertPubKey;
|
||||
private ProvisionDeviceProfileConfiguration credentials;
|
||||
private ProvisionDeviceConfiguration credentials;
|
||||
}
|
||||
|
||||
@ -72,11 +72,12 @@ public class DataConstants {
|
||||
public static final String SECRET_KEY_FIELD_NAME = "secretKey";
|
||||
public static final String DURATION_MS_FIELD_NAME = "durationMs";
|
||||
|
||||
public static final String PROVISION = "provision";
|
||||
public static final String PROVISION_KEY = "provisionDeviceKey";
|
||||
public static final String PROVISION_SECRET = "provisionDeviceSecret";
|
||||
|
||||
public static final String DEVICE_NAME = "deviceName";
|
||||
public static final String DEVICE_TYPE = "deviceType";
|
||||
public static final String CERT_PUB_KEY = "x509CertPubKey";
|
||||
|
||||
public static final String PROVISION_KEY = "provisionDeviceKey";
|
||||
public static final String PROVISION_SECRET = "provisionDeviceSecret";
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright © 2016-2020 The Thingsboard Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.thingsboard.server.common.data.device.data;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import org.thingsboard.server.common.data.DeviceProfileType;
|
||||
import org.thingsboard.server.common.data.device.profile.DeviceProfileConfiguration;
|
||||
import org.thingsboard.server.common.data.device.profile.ProvisionRequestValidationStrategyType;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@Data
|
||||
public class ProvisionDeviceConfiguration implements DeviceConfiguration {
|
||||
|
||||
private String provisionDeviceKey;
|
||||
private String provisionDeviceSecret;
|
||||
|
||||
@Override
|
||||
public DeviceProfileType getType() {
|
||||
return DeviceProfileType.PROVISION;
|
||||
}
|
||||
|
||||
@JsonCreator
|
||||
public ProvisionDeviceConfiguration(@JsonProperty("provisionDeviceKey") String provisionProfileKey, @JsonProperty("provisionDeviceSecret") String provisionProfileSecret) {
|
||||
this.provisionDeviceKey = provisionProfileKey;
|
||||
this.provisionDeviceSecret = provisionProfileSecret;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
ProvisionDeviceConfiguration that = (ProvisionDeviceConfiguration) o;
|
||||
return provisionDeviceKey.equals(that.provisionDeviceKey) &&
|
||||
provisionDeviceSecret.equals(that.provisionDeviceSecret);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(provisionDeviceKey, provisionDeviceSecret);
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,8 @@ import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import org.thingsboard.server.common.data.DeviceProfileType;
|
||||
import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials;
|
||||
import org.thingsboard.server.common.data.device.data.ProvisionDeviceConfiguration;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
@ -34,10 +34,12 @@ import io.netty.handler.codec.mqtt.MqttSubscribeMessage;
|
||||
import io.netty.handler.codec.mqtt.MqttTopicSubscription;
|
||||
import io.netty.handler.codec.mqtt.MqttUnsubscribeMessage;
|
||||
import io.netty.handler.ssl.SslHandler;
|
||||
import io.netty.util.CharsetUtil;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
import io.netty.util.concurrent.Future;
|
||||
import io.netty.util.concurrent.GenericFutureListener;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.thingsboard.server.common.data.DataConstants;
|
||||
import org.thingsboard.server.common.data.DeviceProfile;
|
||||
import org.thingsboard.server.common.data.DeviceTransportType;
|
||||
import org.thingsboard.server.common.data.device.profile.MqttTopics;
|
||||
@ -51,6 +53,7 @@ import org.thingsboard.server.common.transport.auth.TransportDeviceInfo;
|
||||
import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse;
|
||||
import org.thingsboard.server.common.transport.service.DefaultTransportService;
|
||||
import org.thingsboard.server.gen.transport.TransportProtos;
|
||||
import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto;
|
||||
import org.thingsboard.server.gen.transport.TransportProtos.ProvisionDeviceResponseMsg;
|
||||
import org.thingsboard.server.gen.transport.TransportProtos.SessionEvent;
|
||||
import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg;
|
||||
@ -420,11 +423,19 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement
|
||||
|
||||
private void processConnect(ChannelHandlerContext ctx, MqttConnectMessage msg) {
|
||||
log.info("[{}] Processing connect msg for client: {}!", sessionId, msg.payload().clientIdentifier());
|
||||
X509Certificate cert;
|
||||
if (sslHandler != null && (cert = getX509Certificate()) != null) {
|
||||
processX509CertConnect(ctx, cert);
|
||||
String userName = msg.payload().userName();
|
||||
if (DataConstants.PROVISION.equals(userName)) {
|
||||
deviceSessionCtx.setDeviceInfo(new TransportDeviceInfo());
|
||||
deviceSessionCtx.setProvisionOnly(true);
|
||||
ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED));
|
||||
} else {
|
||||
processAuthTokenConnect(ctx, msg);
|
||||
X509Certificate cert;
|
||||
|
||||
if (sslHandler != null && (cert = getX509Certificate()) != null) {
|
||||
processX509CertConnect(ctx, cert);
|
||||
} else {
|
||||
processAuthTokenConnect(ctx, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -215,4 +215,6 @@ public interface DeviceDao extends Dao<Device> {
|
||||
*/
|
||||
PageData<Device> findDevicesByTenantIdAndProfileId(UUID tenantId, UUID profileId, PageLink pageLink);
|
||||
|
||||
Device findDeviceByTenantIdAndDeviceDataProvisionConfigurationPair(TenantId tenantId, String provisionDeviceKey, String provisionDeviceSecret);
|
||||
|
||||
}
|
||||
|
||||
@ -20,8 +20,10 @@ import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.PagingAndSortingRepository;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.thingsboard.server.common.data.DeviceProfileInfo;
|
||||
import org.thingsboard.server.dao.model.sql.DeviceEntity;
|
||||
import org.thingsboard.server.dao.model.sql.DeviceInfoEntity;
|
||||
import org.thingsboard.server.dao.model.sql.DeviceProfileEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@ -169,4 +171,12 @@ public interface DeviceRepository extends PagingAndSortingRepository<DeviceEntit
|
||||
|
||||
Long countByDeviceProfileId(UUID deviceProfileId);
|
||||
|
||||
@Query(value = "SELECT d FROM Device d " +
|
||||
"WHERE d.tenant_id = :tenantId " +
|
||||
"AND d.device_data::jsonb->>('configuration', 'provisionDeviceKey') = :provisionDeviceKey " +
|
||||
"AND d.device_data::jsonb->>('configuration', 'provisionDeviceSecret') = :provisionDeviceSecret",
|
||||
nativeQuery = true)
|
||||
DeviceEntity findDeviceByTenantIdAndDeviceDataProvisionConfigurationPair(@Param("tenantId") UUID tenantId,
|
||||
@Param("provisionDeviceKey") String provisionDeviceKey,
|
||||
@Param("provisionDeviceSecret") String provisionDeviceSecret);
|
||||
}
|
||||
|
||||
@ -22,6 +22,8 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.thingsboard.server.common.data.Device;
|
||||
import org.thingsboard.server.common.data.DeviceInfo;
|
||||
import org.thingsboard.server.common.data.DeviceProfile;
|
||||
import org.thingsboard.server.common.data.DeviceProfileInfo;
|
||||
import org.thingsboard.server.common.data.EntitySubtype;
|
||||
import org.thingsboard.server.common.data.EntityType;
|
||||
import org.thingsboard.server.common.data.id.TenantId;
|
||||
@ -219,6 +221,11 @@ public class JpaDeviceDao extends JpaAbstractSearchTextDao<DeviceEntity, Device>
|
||||
return deviceRepository.countByDeviceProfileId(deviceProfileId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Device findDeviceByTenantIdAndDeviceDataProvisionConfigurationPair(TenantId tenantId, String provisionDeviceKey, String provisionDeviceSecret) {
|
||||
return DaoUtil.getData(deviceRepository.findDeviceByTenantIdAndDeviceDataProvisionConfigurationPair(tenantId.getId(), provisionDeviceKey, provisionDeviceSecret));
|
||||
}
|
||||
|
||||
private List<EntitySubtype> convertTenantDeviceTypesToDto(UUID tenantId, List<String> types) {
|
||||
List<EntitySubtype> list = Collections.emptyList();
|
||||
if (types != null && !types.isEmpty()) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user