Merge branch 'master' into 'master'

Add app subscribing feature improvement

See merge request entgra/carbon-device-mgt!822
feature/traccar-sync
Dharmakeerthi Lasantha 3 years ago
commit dd1474c040

@ -33,24 +33,43 @@ import java.util.Properties;
* This interface manages all the operations related with ApplicationDTO Subscription. * This interface manages all the operations related with ApplicationDTO Subscription.
*/ */
public interface SubscriptionManager { public interface SubscriptionManager {
/** /**
* Performs bulk subscription operation for a given application and a subscriber list. * Performs bulk subscription operation for a given application and a subscriber list.
* @param applicationUUID UUID of the application to subscribe/unsubscribe * @param applicationUUID UUID of the application to subscribe/unsubscribe
* @param params list of subscribers. This list can be of either * @param params list of subscribers.
* {@link DeviceIdentifier} if {@param subType} is equal * This list can be of either {@link DeviceIdentifier} if {@param subType} is equal to
* to DEVICE or * DEVICE or {@link String} if {@param subType} is USER, ROLE or GROUP
* {@link String} if {@param subType} is USER, ROLE or GROUP * @param subType subscription type. E.g. <code>DEVICE, USER, ROLE, GROUP</code>
* @param subType subscription type. E.g. <code>DEVICE, USER, ROLE, GROUP</code> {@see { * @param action subscription action. E.g. <code>INSTALL/UNINSTALL</code>
* @param action subscription action. E.g. <code>INSTALL/UNINSTALL</code> {@see {
* @param <T> generic type of the method. * @param <T> generic type of the method.
* @param properties Application properties that need to be sent with operation payload to the device
* @return {@link ApplicationInstallResponse}
* @throws ApplicationManagementException if error occurs when subscribing to the given application
*/
<T> ApplicationInstallResponse performBulkAppOperation(String applicationUUID, List<T> params, String subType,
String action, Properties properties)
throws ApplicationManagementException;
/**
* Performs bulk subscription operation for a given application and a subscriber list.
* @param applicationUUID UUID of the application to subscribe/unsubscribe
* @param params list of subscribers.
* This list can be of either {@link DeviceIdentifier} if {@param subType} is equal to
* DEVICE or {@link String} if {@param subType} is USER, ROLE or GROUP
* @param subType subscription type. E.g. <code>DEVICE, USER, ROLE, GROUP</code>
* @param action subscription action. E.g. <code>INSTALL/UNINSTALL</code>
* @param <T> generic type of the method.
* @param properties Application properties that need to be sent with operation payload to the device
* @param isOperationReExecutingDisabled To prevent adding the application subscribing operation to devices that are
* already subscribed application successfully.
* @return {@link ApplicationInstallResponse} * @return {@link ApplicationInstallResponse}
* @throws ApplicationManagementException if error occurs when subscribing to the given application * @throws ApplicationManagementException if error occurs when subscribing to the given application
* @link io.entgra.application.mgt.common.SubscriptionType}}
* @link io.entgra.application.mgt.common.SubAction}}
* @param properties
*/ */
<T> ApplicationInstallResponse performBulkAppOperation(String applicationUUID, List<T> params, String subType, <T> ApplicationInstallResponse performBulkAppOperation(String applicationUUID, List<T> params, String subType,
String action, Properties properties) throws ApplicationManagementException; String action, Properties properties,
boolean isOperationReExecutingDisabled)
throws ApplicationManagementException;
/** /**
* Create an entry related to the scheduled task in the database. * Create an entry related to the scheduled task in the database.
@ -119,7 +138,7 @@ public interface SubscriptionManager {
* This is used in enterprise app installing policy. * This is used in enterprise app installing policy.
* *
* @param deviceIdentifier Device identifiers * @param deviceIdentifier Device identifiers
* @param releaseUUID UUIs of applicatios * @param apps Applications
* @throws ApplicationManagementException if error occurred while installing given applications into the given * @throws ApplicationManagementException if error occurred while installing given applications into the given
* device * device
*/ */

@ -123,7 +123,16 @@ public class SubscriptionManagerImpl implements SubscriptionManager {
@Override @Override
public <T> ApplicationInstallResponse performBulkAppOperation(String applicationUUID, List<T> params, public <T> ApplicationInstallResponse performBulkAppOperation(String applicationUUID, List<T> params,
String subType, String action, Properties properties) throws ApplicationManagementException { String subType, String action, Properties properties)
throws ApplicationManagementException {
return performBulkAppOperation(applicationUUID, params, subType, action, properties, false);
}
@Override
public <T> ApplicationInstallResponse performBulkAppOperation(String applicationUUID, List<T> params,
String subType, String action, Properties properties,
boolean isOperationReExecutingDisabled)
throws ApplicationManagementException {
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("Install application release which has UUID " + applicationUUID + " to " + params.size() log.debug("Install application release which has UUID " + applicationUUID + " to " + params.size()
+ " users."); + " users.");
@ -136,7 +145,7 @@ public class SubscriptionManagerImpl implements SubscriptionManager {
params); params);
ApplicationInstallResponse applicationInstallResponse = performActionOnDevices( ApplicationInstallResponse applicationInstallResponse = performActionOnDevices(
applicationSubscriptionInfo.getAppSupportingDeviceTypeName(), applicationSubscriptionInfo.getDevices(), applicationSubscriptionInfo.getAppSupportingDeviceTypeName(), applicationSubscriptionInfo.getDevices(),
applicationDTO, subType, applicationSubscriptionInfo.getSubscribers(), action, properties); applicationDTO, subType, applicationSubscriptionInfo.getSubscribers(), action, properties, isOperationReExecutingDisabled);
applicationInstallResponse.setErrorDeviceIdentifiers(applicationSubscriptionInfo.getErrorDeviceIdentifiers()); applicationInstallResponse.setErrorDeviceIdentifiers(applicationSubscriptionInfo.getErrorDeviceIdentifiers());
return applicationInstallResponse; return applicationInstallResponse;
@ -621,12 +630,17 @@ public class SubscriptionManagerImpl implements SubscriptionManager {
* @param subType Subscription type (i.e USER, ROLE, GROUP or DEVICE) * @param subType Subscription type (i.e USER, ROLE, GROUP or DEVICE)
* @param subscribers Subscribers * @param subscribers Subscribers
* @param action Performing action. (i.e INSTALL or UNINSTALL) * @param action Performing action. (i.e INSTALL or UNINSTALL)
* @param isOperationReExecutingDisabled To prevent adding the application subscribing operation to devices that are
* already subscribed application successfully.
* @return {@link ApplicationInstallResponse} * @return {@link ApplicationInstallResponse}
* @throws ApplicationManagementException if error occured when adding operation on device or updating subscription * @throws ApplicationManagementException if error occurred when adding operation on device or updating subscription
* data. * data.
*/ */
private ApplicationInstallResponse performActionOnDevices(String deviceType, List<Device> devices, private ApplicationInstallResponse performActionOnDevices(String deviceType, List<Device> devices,
ApplicationDTO applicationDTO, String subType, List<String> subscribers, String action, Properties properties) ApplicationDTO applicationDTO, String subType,
List<String> subscribers, String action,
Properties properties,
boolean isOperationReExecutingDisabled)
throws ApplicationManagementException { throws ApplicationManagementException {
//Get app subscribing info of each device //Get app subscribing info of each device
@ -641,15 +655,19 @@ public class SubscriptionManagerImpl implements SubscriptionManager {
if (SubAction.INSTALL.toString().equalsIgnoreCase(action)) { if (SubAction.INSTALL.toString().equalsIgnoreCase(action)) {
deviceIdentifiers.addAll(new ArrayList<>(subscribingDeviceIdHolder.getAppInstallableDevices().keySet())); deviceIdentifiers.addAll(new ArrayList<>(subscribingDeviceIdHolder.getAppInstallableDevices().keySet()));
deviceIdentifiers.addAll(new ArrayList<>(subscribingDeviceIdHolder.getAppReInstallableDevices().keySet())); deviceIdentifiers.addAll(new ArrayList<>(subscribingDeviceIdHolder.getAppReInstallableDevices().keySet()));
if (!isOperationReExecutingDisabled) {
deviceIdentifiers.addAll(new ArrayList<>(subscribingDeviceIdHolder.getAppInstalledDevices().keySet())); deviceIdentifiers.addAll(new ArrayList<>(subscribingDeviceIdHolder.getAppInstalledDevices().keySet()));
} else { }
if (SubAction.UNINSTALL.toString().equalsIgnoreCase(action)) { } else if (SubAction.UNINSTALL.toString().equalsIgnoreCase(action)) {
deviceIdentifiers.addAll(new ArrayList<>(subscribingDeviceIdHolder.getAppInstalledDevices().keySet())); deviceIdentifiers.addAll(new ArrayList<>(subscribingDeviceIdHolder.getAppInstalledDevices().keySet()));
deviceIdentifiers deviceIdentifiers
.addAll(new ArrayList<>(subscribingDeviceIdHolder.getAppReUnInstallableDevices().keySet())); .addAll(new ArrayList<>(subscribingDeviceIdHolder.getAppReUnInstallableDevices().keySet()));
ignoredDeviceIdentifiers ignoredDeviceIdentifiers
.addAll(new ArrayList<>(subscribingDeviceIdHolder.getAppInstallableDevices().keySet())); .addAll(new ArrayList<>(subscribingDeviceIdHolder.getAppInstallableDevices().keySet()));
} } else {
String msg = "Found invalid Action: " + action + ". Hence, terminating the application subscribing.";
log.error(msg);
throw new ApplicationManagementException(msg);
} }
if (deviceIdentifiers.isEmpty()) { if (deviceIdentifiers.isEmpty()) {
@ -721,15 +739,14 @@ public class SubscriptionManagerImpl implements SubscriptionManager {
} else { } else {
if (deviceSubscriptionDTO.isUnsubscribed()) { if (deviceSubscriptionDTO.isUnsubscribed()) {
if (!Operation.Status.COMPLETED.toString().equals(deviceSubscriptionDTO.getStatus())) { if (!Operation.Status.COMPLETED.toString().equals(deviceSubscriptionDTO.getStatus())) {
/*We can't ensure whether app is uninstalled successfully or not hence allow to perform both /*If the uninstalling operation has failed, we can't ensure whether the app is uninstalled
install and uninstall operations*/ successfully or not. Therefore, allowing to perform both install and uninstall operations*/
subscribingDeviceIdHolder.getAppReUnInstallableDevices() subscribingDeviceIdHolder.getAppReUnInstallableDevices()
.put(deviceIdentifier, device.getId()); .put(deviceIdentifier, device.getId());
} }
subscribingDeviceIdHolder.getAppReInstallableDevices().put(deviceIdentifier, device.getId()); subscribingDeviceIdHolder.getAppReInstallableDevices().put(deviceIdentifier, device.getId());
} else { } else {
if (!deviceSubscriptionDTO.isUnsubscribed() && Operation.Status.COMPLETED.toString() if (Operation.Status.COMPLETED.toString().equals(deviceSubscriptionDTO.getStatus())) {
.equals(deviceSubscriptionDTO.getStatus())) {
subscribingDeviceIdHolder.getAppInstalledDevices().put(deviceIdentifier, device.getId()); subscribingDeviceIdHolder.getAppInstalledDevices().put(deviceIdentifier, device.getId());
} else { } else {
subscribingDeviceIdHolder.getAppReInstallableDevices() subscribingDeviceIdHolder.getAppReInstallableDevices()
@ -964,11 +981,11 @@ public class SubscriptionManagerImpl implements SubscriptionManager {
ConnectionManagerUtil.openDBConnection(); ConnectionManagerUtil.openDBConnection();
return this.subscriptionDAO.getDeviceSubscriptions(deviceIds, appReleaseId, tenantId); return this.subscriptionDAO.getDeviceSubscriptions(deviceIds, appReleaseId, tenantId);
} catch (ApplicationManagementDAOException e) { } catch (ApplicationManagementDAOException e) {
String msg = "Error occured when getting device subscriptions for given device IDs"; String msg = "Error occurred when getting device subscriptions for given device IDs";
log.error(msg, e); log.error(msg, e);
throw new ApplicationManagementException(msg, e); throw new ApplicationManagementException(msg, e);
} catch (DBConnectionException e) { } catch (DBConnectionException e) {
String msg = "Error occured while getting database connection for getting device subscriptions."; String msg = "Error occurred while getting database connection for getting device subscriptions.";
log.error(msg, e); log.error(msg, e);
throw new ApplicationManagementException(msg, e); throw new ApplicationManagementException(msg, e);
} finally { } finally {

@ -42,7 +42,7 @@ import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class ScheduledAppSubscriptionTask extends RandomlyAssignedScheduleTask { public class ScheduledAppSubscriptionTask extends RandomlyAssignedScheduleTask {
private static Log log = LogFactory.getLog(ScheduledAppSubscriptionTask.class); private static final Log log = LogFactory.getLog(ScheduledAppSubscriptionTask.class);
private static final String TASK_NAME = "SCHEDULE_APP_SUBSCRIPTION"; private static final String TASK_NAME = "SCHEDULE_APP_SUBSCRIPTION";
private SubscriptionManager subscriptionManager; private SubscriptionManager subscriptionManager;
@ -55,6 +55,7 @@ public class ScheduledAppSubscriptionTask extends RandomlyAssignedScheduleTask {
private String tenantDomain; private String tenantDomain;
private String taskName; private String taskName;
private int tenantId; private int tenantId;
private boolean isOperationReExecutingDisabled;
@Override @Override
public void setProperties(Map<String, String> map) { public void setProperties(Map<String, String> map) {
@ -67,6 +68,7 @@ public class ScheduledAppSubscriptionTask extends RandomlyAssignedScheduleTask {
this.tenantDomain = map.get(Constants.TENANT_DOMAIN); this.tenantDomain = map.get(Constants.TENANT_DOMAIN);
this.tenantId = Integer.parseInt(map.get(Constants.TENANT_ID)); this.tenantId = Integer.parseInt(map.get(Constants.TENANT_ID));
this.taskName = map.get(Constants.TASK_NAME); this.taskName = map.get(Constants.TASK_NAME);
this.isOperationReExecutingDisabled = Boolean.parseBoolean(map.get(Constants.OPERATION_RE_EXECUtING));
} }
@Override @Override
@ -108,7 +110,7 @@ public class ScheduledAppSubscriptionTask extends RandomlyAssignedScheduleTask {
try { try {
Properties properties = new Gson().fromJson(payload, Properties.class); Properties properties = new Gson().fromJson(payload, Properties.class);
subscriptionManager.performBulkAppOperation(this.application, subscriberList, subscriptionManager.performBulkAppOperation(this.application, subscriberList,
this.subscriptionType, this.action, properties); this.subscriptionType, this.action, properties, isOperationReExecutingDisabled);
subscriptionDTO.setStatus(ExecutionStatus.EXECUTED); subscriptionDTO.setStatus(ExecutionStatus.EXECUTED);
} catch (ApplicationManagementException e) { } catch (ApplicationManagementException e) {
log.error( log.error(

@ -69,10 +69,14 @@ public class ScheduledAppSubscriptionTaskManager {
* @param action action subscription action. E.g. {@code INSTALL/UNINSTALL} * @param action action subscription action. E.g. {@code INSTALL/UNINSTALL}
* {@see {@link SubAction}} * {@see {@link SubAction}}
* @param timestamp timestamp to schedule the application subscription * @param timestamp timestamp to schedule the application subscription
* @param properties Properties sending to the device via operation
* @param isOperationReExecutingDisabled To prevent adding the application subscribing
* already subscribed application successfully.
* @throws ApplicationOperationTaskException if error occurred while scheduling the subscription * @throws ApplicationOperationTaskException if error occurred while scheduling the subscription
*/ */
public void scheduleAppSubscriptionTask(String applicationUUID, List<?> subscribers, public void scheduleAppSubscriptionTask(String applicationUUID, List<?> subscribers,
SubscriptionType subscriptionType, SubAction action, long timestamp, Properties properties) SubscriptionType subscriptionType, SubAction action, long timestamp,
Properties properties, boolean isOperationReExecutingDisabled)
throws ApplicationOperationTaskException { throws ApplicationOperationTaskException {
Calendar calendar = Calendar.getInstance(); Calendar calendar = Calendar.getInstance();
calendar.setTime(new Date(timestamp * 1000)); calendar.setTime(new Date(timestamp * 1000));
@ -105,14 +109,15 @@ public class ScheduledAppSubscriptionTaskManager {
taskProperties.put(Constants.APP_UUID, applicationUUID); taskProperties.put(Constants.APP_UUID, applicationUUID);
taskProperties.put(Constants.TENANT_DOMAIN, carbonContext.getTenantDomain(true)); taskProperties.put(Constants.TENANT_DOMAIN, carbonContext.getTenantDomain(true));
taskProperties.put(Constants.SUBSCRIBER, carbonContext.getUsername()); taskProperties.put(Constants.SUBSCRIBER, carbonContext.getUsername());
taskProperties.put(Constants.OPERATION_RE_EXECUtING, String.valueOf(isOperationReExecutingDisabled));
String subscribersString; String subscribersString;
if (SubscriptionType.DEVICE.equals(subscriptionType)) { if (SubscriptionType.DEVICE.equals(subscriptionType)) {
subscribersString = new Gson().toJson(subscribers); subscribersString = new Gson().toJson(subscribers);
taskProperties.put(Constants.SUBSCRIBERS, subscribersString);
} else { } else {
subscribersString = subscribers.stream().map(String.class::cast).collect(Collectors.joining(",")); subscribersString = subscribers.stream().map(String.class::cast).collect(Collectors.joining(","));
taskProperties.put(Constants.SUBSCRIBERS, subscribersString);
} }
taskProperties.put(Constants.SUBSCRIBERS, subscribersString);
if(properties != null) { if(properties != null) {
String payload = new Gson().toJson(properties); String payload = new Gson().toJson(properties);
taskProperties.put(Constants.PAYLOAD, payload); taskProperties.put(Constants.PAYLOAD, payload);

@ -60,6 +60,7 @@ public class Constants {
public static final String APP_UUID = "APP_UUID"; public static final String APP_UUID = "APP_UUID";
public static final String APP_PROPERTIES = "APP_PROPERTIES"; public static final String APP_PROPERTIES = "APP_PROPERTIES";
public static final String SUBSCRIBER = "SUBSCRIBER"; public static final String SUBSCRIBER = "SUBSCRIBER";
public static final String OPERATION_RE_EXECUtING = "OPERATION_RE_EXECUtING";
public static final String TENANT_DOMAIN = "TENANT_DOMAIN"; public static final String TENANT_DOMAIN = "TENANT_DOMAIN";
public static final String TENANT_ID = "__TENANT_ID_PROP__"; public static final String TENANT_ID = "__TENANT_ID_PROP__";
public static final String TASK_NAME = "TASK_NAME"; public static final String TASK_NAME = "TASK_NAME";

@ -196,7 +196,12 @@ public interface SubscriptionManagementAPI {
name = "block-uninstall", name = "block-uninstall",
value = "App removal status of the install operation" value = "App removal status of the install operation"
) )
@QueryParam("block-uninstall") Boolean isUninstallBlocked @QueryParam("block-uninstall") Boolean isUninstallBlocked,
@ApiParam(
name = "disable-operation-re-executing",
value = "Disable Operation re-executing"
)
@QueryParam("disable-operation-re-executing") boolean isOperationReExecutingDisabled
); );
@POST @POST

@ -123,7 +123,8 @@ public class SubscriptionManagementAPIImpl implements SubscriptionManagementAPI{
@PathParam("action") String action, @PathParam("action") String action,
@Valid List<String> subscribers, @Valid List<String> subscribers,
@QueryParam("timestamp") long timestamp, @QueryParam("timestamp") long timestamp,
@QueryParam("block-uninstall") Boolean isUninstallBlocked @QueryParam("block-uninstall") Boolean isUninstallBlocked,
@QueryParam("disable-operation-re-executing") boolean isOperationReExecutingDisabled
) { ) {
Properties properties = new Properties(); Properties properties = new Properties();
if (isUninstallBlocked != null) { if (isUninstallBlocked != null) {
@ -132,13 +133,14 @@ public class SubscriptionManagementAPIImpl implements SubscriptionManagementAPI{
try { try {
if (0 == timestamp) { if (0 == timestamp) {
SubscriptionManager subscriptionManager = APIUtil.getSubscriptionManager(); SubscriptionManager subscriptionManager = APIUtil.getSubscriptionManager();
ApplicationInstallResponse response = subscriptionManager ApplicationInstallResponse response =
.performBulkAppOperation(uuid, subscribers, subType, action, properties); subscriptionManager.performBulkAppOperation(uuid, subscribers, subType, action, properties,
isOperationReExecutingDisabled);
return Response.status(Response.Status.OK).entity(response).build(); return Response.status(Response.Status.OK).entity(response).build();
} else { } else {
return scheduleApplicationOperationTask(uuid, subscribers, return scheduleApplicationOperationTask(uuid, subscribers,
SubscriptionType.valueOf(subType.toUpperCase()), SubAction.valueOf(action.toUpperCase()), SubscriptionType.valueOf(subType.toUpperCase()), SubAction.valueOf(action.toUpperCase()),
timestamp, properties); timestamp, properties, isOperationReExecutingDisabled);
} }
} catch (NotFoundException e) { } catch (NotFoundException e) {
String msg = "Couldn't found an application release for UUID: " + uuid + ". Hence, verify the payload"; String msg = "Couldn't found an application release for UUID: " + uuid + ". Hence, verify the payload";
@ -252,6 +254,28 @@ public class SubscriptionManagementAPIImpl implements SubscriptionManagementAPI{
} }
} }
/**
* Schedule the application subscription for the given timestamp
*
* @param applicationUUID UUID of the application to install
* @param subscribers list of subscribers. This list can be of
* either {@link org.wso2.carbon.device.mgt.common.DeviceIdentifier} if {@param subType} is
* equal to DEVICE or {@link String} if {@param subType} is USER, ROLE or GROUP
* @param subType subscription type. E.g. <code>DEVICE, USER, ROLE, GROUP</code>
* {@see {@link io.entgra.application.mgt.common.SubscriptionType}}
* @param subAction action subscription action. E.g. <code>INSTALL/UNINSTALL</code>
* {@see {@link io.entgra.application.mgt.common.SubAction}}
* @param payload Properties sending to the device via operation
* @param timestamp timestamp to schedule the application subscription
* @return {@link Response} of the operation
*/
private Response scheduleApplicationOperationTask(String applicationUUID, List<?> subscribers,
SubscriptionType subType, SubAction subAction, long timestamp,
Properties payload) {
return scheduleApplicationOperationTask(applicationUUID, subscribers, subType, subAction, timestamp, payload,
false);
}
/** /**
* Schedule the application subscription for the given timestamp * Schedule the application subscription for the given timestamp
* *
@ -264,14 +288,18 @@ public class SubscriptionManagementAPIImpl implements SubscriptionManagementAPI{
* @param subAction action subscription action. E.g. <code>INSTALL/UNINSTALL</code> * @param subAction action subscription action. E.g. <code>INSTALL/UNINSTALL</code>
* {@see {@link io.entgra.application.mgt.common.SubAction}} * {@see {@link io.entgra.application.mgt.common.SubAction}}
* @param timestamp timestamp to schedule the application subscription * @param timestamp timestamp to schedule the application subscription
* @param payload Properties sending to the device via operation
* @param isOperationReExecutingDisabled To prevent adding the application subscribing operation to devices that are
* already subscribed application successfully.
* @return {@link Response} of the operation * @return {@link Response} of the operation
*/ */
private Response scheduleApplicationOperationTask(String applicationUUID, List<?> subscribers, private Response scheduleApplicationOperationTask(String applicationUUID, List<?> subscribers,
SubscriptionType subType, SubAction subAction, long timestamp, Properties payload) { SubscriptionType subType, SubAction subAction, long timestamp,
Properties payload, boolean isOperationReExecutingDisabled) {
try { try {
ScheduledAppSubscriptionTaskManager subscriptionTaskManager = new ScheduledAppSubscriptionTaskManager(); ScheduledAppSubscriptionTaskManager subscriptionTaskManager = new ScheduledAppSubscriptionTaskManager();
subscriptionTaskManager.scheduleAppSubscriptionTask(applicationUUID, subscribers, subType, subAction, subscriptionTaskManager.scheduleAppSubscriptionTask(applicationUUID, subscribers, subType, subAction,
timestamp, payload); timestamp, payload, isOperationReExecutingDisabled);
} catch (ApplicationOperationTaskException e) { } catch (ApplicationOperationTaskException e) {
String msg = "Error occurred while scheduling the application install operation"; String msg = "Error occurred while scheduling the application install operation";
log.error(msg, e); log.error(msg, e);

Loading…
Cancel
Save