MicrosoftTeamsNotificationChannel
This commit is contained in:
parent
97831351a0
commit
7528fceeb2
@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.notification.NotificationRequest;
|
||||
import org.thingsboard.server.common.data.notification.NotificationRequestInfo;
|
||||
import org.thingsboard.server.common.data.notification.NotificationRequestPreview;
|
||||
import org.thingsboard.server.common.data.notification.settings.NotificationSettings;
|
||||
import org.thingsboard.server.common.data.notification.targets.MicrosoftTeamsNotificationTargetConfig;
|
||||
import org.thingsboard.server.common.data.notification.targets.NotificationRecipient;
|
||||
import org.thingsboard.server.common.data.notification.targets.NotificationTarget;
|
||||
import org.thingsboard.server.common.data.notification.targets.NotificationTargetType;
|
||||
@ -294,15 +295,27 @@ public class NotificationController extends BaseController {
|
||||
int recipientsCount;
|
||||
List<NotificationRecipient> recipientsPart;
|
||||
NotificationTargetType targetType = target.getConfiguration().getType();
|
||||
if (targetType == NotificationTargetType.PLATFORM_USERS) {
|
||||
switch (targetType) {
|
||||
case PLATFORM_USERS: {
|
||||
PageData<User> recipients = notificationTargetService.findRecipientsForNotificationTargetConfig(user.getTenantId(),
|
||||
(PlatformUsersNotificationTargetConfig) target.getConfiguration(), new PageLink(recipientsPreviewSize, 0, null,
|
||||
new SortOrder("createdTime", SortOrder.Direction.DESC)));
|
||||
recipientsCount = (int) recipients.getTotalElements();
|
||||
recipientsPart = recipients.getData().stream().map(r -> (NotificationRecipient) r).collect(Collectors.toList());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
case SLACK: {
|
||||
recipientsCount = 1;
|
||||
recipientsPart = List.of(((SlackNotificationTargetConfig) target.getConfiguration()).getConversation());
|
||||
break;
|
||||
}
|
||||
case MICROSOFT_TEAMS: {
|
||||
recipientsCount = 1;
|
||||
recipientsPart = List.of(((MicrosoftTeamsNotificationTargetConfig) target.getConfiguration()));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new IllegalArgumentException("Target type " + targetType + " not supported");
|
||||
}
|
||||
firstRecipient.putIfAbsent(targetType, !recipientsPart.isEmpty() ? recipientsPart.get(0) : null);
|
||||
for (NotificationRecipient recipient : recipientsPart) {
|
||||
|
||||
@ -39,6 +39,7 @@ import org.thingsboard.server.common.data.notification.NotificationRequestStatus
|
||||
import org.thingsboard.server.common.data.notification.NotificationStatus;
|
||||
import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo;
|
||||
import org.thingsboard.server.common.data.notification.settings.NotificationSettings;
|
||||
import org.thingsboard.server.common.data.notification.targets.MicrosoftTeamsNotificationTargetConfig;
|
||||
import org.thingsboard.server.common.data.notification.targets.NotificationRecipient;
|
||||
import org.thingsboard.server.common.data.notification.targets.NotificationTarget;
|
||||
import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig;
|
||||
@ -210,6 +211,11 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple
|
||||
recipients = List.of(targetConfig.getConversation());
|
||||
break;
|
||||
}
|
||||
case MICROSOFT_TEAMS: {
|
||||
MicrosoftTeamsNotificationTargetConfig targetConfig = (MicrosoftTeamsNotificationTargetConfig) target.getConfiguration();
|
||||
recipients = List.of(targetConfig);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
recipients = Collections.emptyList();
|
||||
}
|
||||
|
||||
@ -0,0 +1,143 @@
|
||||
package org.thingsboard.server.service.notification.channels;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.base.Strings;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.thingsboard.server.common.data.id.TenantId;
|
||||
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
|
||||
import org.thingsboard.server.common.data.notification.targets.MicrosoftTeamsNotificationTargetConfig;
|
||||
import org.thingsboard.server.common.data.notification.template.MicrosoftTeamsDeliveryMethodNotificationTemplate;
|
||||
import org.thingsboard.server.service.notification.NotificationProcessingContext;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MicrosoftTeamsNotificationChannel implements NotificationChannel<MicrosoftTeamsNotificationTargetConfig, MicrosoftTeamsDeliveryMethodNotificationTemplate> {
|
||||
|
||||
@Setter
|
||||
private RestTemplate restTemplate = new RestTemplateBuilder()
|
||||
.setConnectTimeout(Duration.of(15, ChronoUnit.SECONDS))
|
||||
.setReadTimeout(Duration.of(15, ChronoUnit.SECONDS))
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public void sendNotification(MicrosoftTeamsNotificationTargetConfig targetConfig, MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws Exception {
|
||||
if (StringUtils.isNotEmpty(processedTemplate.getCustomMessageCardJson())) {
|
||||
restTemplate.postForEntity(targetConfig.getWebhookUrl(), processedTemplate.getCustomMessageCardJson(), String.class);
|
||||
return;
|
||||
}
|
||||
|
||||
Message message = new Message();
|
||||
message.setThemeColor(Strings.emptyToNull(processedTemplate.getThemeColor()));
|
||||
if (StringUtils.isEmpty(processedTemplate.getSubject())) {
|
||||
message.setText(processedTemplate.getBody());
|
||||
} else {
|
||||
message.setSummary(processedTemplate.getSubject());
|
||||
Message.Section section = new Message.Section();
|
||||
section.setActivityTitle(processedTemplate.getSubject());
|
||||
section.setActivitySubtitle(processedTemplate.getBody());
|
||||
message.setSections(List.of(section));
|
||||
}
|
||||
if (processedTemplate.getButton() != null) {
|
||||
var button = processedTemplate.getButton();
|
||||
Message.ActionCard actionCard = new Message.ActionCard();
|
||||
actionCard.setType("OpenUri");
|
||||
actionCard.setName(button.getName());
|
||||
var target = new Message.ActionCard.Target("default", button.getUri());
|
||||
actionCard.setTargets(List.of(target));
|
||||
message.setPotentialAction(List.of(actionCard));
|
||||
}
|
||||
|
||||
restTemplate.postForEntity(targetConfig.getWebhookUrl(), message, String.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void check(TenantId tenantId) throws Exception {
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationDeliveryMethod getDeliveryMethod() {
|
||||
return NotificationDeliveryMethod.MICROSOFT_TEAMS;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Message {
|
||||
@JsonProperty("@type")
|
||||
private final String type = "MessageCard";
|
||||
@JsonProperty("@context")
|
||||
private final String context = "http://schema.org/extensions";
|
||||
private String themeColor;
|
||||
private String summary;
|
||||
private String text;
|
||||
private List<Section> sections;
|
||||
private List<ActionCard> potentialAction;
|
||||
|
||||
@Data
|
||||
public static class Section {
|
||||
private String activityTitle;
|
||||
private String activitySubtitle;
|
||||
private String activityImage;
|
||||
private List<Fact> facts;
|
||||
private boolean markdown;
|
||||
|
||||
@Data
|
||||
public static class Fact {
|
||||
private final String name;
|
||||
private final String value;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ActionCard {
|
||||
@JsonProperty("@type")
|
||||
private String type; // ActionCard, OpenUri
|
||||
private String name;
|
||||
private List<Input> inputs; // for ActionCard
|
||||
private List<Action> actions; // for ActionCard
|
||||
private List<Target> targets;
|
||||
|
||||
@Data
|
||||
public static class Input {
|
||||
@JsonProperty("@type")
|
||||
private String type; // TextInput, DateInput, MultichoiceInput
|
||||
private String id;
|
||||
private boolean isMultiple;
|
||||
private String title;
|
||||
private boolean isMultiSelect;
|
||||
|
||||
@Data
|
||||
public static class Choice {
|
||||
private final String display;
|
||||
private final String value;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Action {
|
||||
@JsonProperty("@type")
|
||||
private final String type; // HttpPOST
|
||||
private final String name;
|
||||
private final String target; // url
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Target {
|
||||
private final String os;
|
||||
private final String uri;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -20,7 +20,10 @@ import org.assertj.core.data.Offset;
|
||||
import org.java_websocket.client.WebSocketClient;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.thingsboard.common.util.JacksonUtil;
|
||||
import org.thingsboard.rule.engine.api.NotificationCenter;
|
||||
import org.thingsboard.server.common.data.User;
|
||||
import org.thingsboard.server.common.data.id.NotificationTargetId;
|
||||
@ -33,8 +36,10 @@ import org.thingsboard.server.common.data.notification.NotificationRequestPrevie
|
||||
import org.thingsboard.server.common.data.notification.NotificationRequestStats;
|
||||
import org.thingsboard.server.common.data.notification.NotificationRequestStatus;
|
||||
import org.thingsboard.server.common.data.notification.NotificationType;
|
||||
import org.thingsboard.server.common.data.notification.info.AlarmNotificationInfo;
|
||||
import org.thingsboard.server.common.data.notification.settings.NotificationSettings;
|
||||
import org.thingsboard.server.common.data.notification.settings.SlackNotificationDeliveryMethodConfig;
|
||||
import org.thingsboard.server.common.data.notification.targets.MicrosoftTeamsNotificationTargetConfig;
|
||||
import org.thingsboard.server.common.data.notification.targets.NotificationTarget;
|
||||
import org.thingsboard.server.common.data.notification.targets.platform.CustomerUsersFilter;
|
||||
import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig;
|
||||
@ -44,6 +49,7 @@ import org.thingsboard.server.common.data.notification.targets.slack.SlackConver
|
||||
import org.thingsboard.server.common.data.notification.targets.slack.SlackNotificationTargetConfig;
|
||||
import org.thingsboard.server.common.data.notification.template.DeliveryMethodNotificationTemplate;
|
||||
import org.thingsboard.server.common.data.notification.template.EmailDeliveryMethodNotificationTemplate;
|
||||
import org.thingsboard.server.common.data.notification.template.MicrosoftTeamsDeliveryMethodNotificationTemplate;
|
||||
import org.thingsboard.server.common.data.notification.template.NotificationTemplate;
|
||||
import org.thingsboard.server.common.data.notification.template.NotificationTemplateConfig;
|
||||
import org.thingsboard.server.common.data.notification.template.SlackDeliveryMethodNotificationTemplate;
|
||||
@ -53,6 +59,7 @@ import org.thingsboard.server.common.data.security.Authority;
|
||||
import org.thingsboard.server.dao.notification.NotificationDao;
|
||||
import org.thingsboard.server.dao.service.DaoSqlTest;
|
||||
import org.thingsboard.server.service.executors.DbCallbackExecutorService;
|
||||
import org.thingsboard.server.service.notification.channels.MicrosoftTeamsNotificationChannel;
|
||||
import org.thingsboard.server.service.ws.notification.cmd.UnreadNotificationsUpdate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@ -62,12 +69,17 @@ import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.in;
|
||||
import static org.assertj.core.api.InstanceOfAssertFactories.type;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.timeout;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DaoSqlTest
|
||||
@Slf4j
|
||||
@ -79,6 +91,8 @@ public class NotificationApiTest extends AbstractNotificationApiTest {
|
||||
private NotificationDao notificationDao;
|
||||
@Autowired
|
||||
private DbCallbackExecutorService executor;
|
||||
@Autowired
|
||||
private MicrosoftTeamsNotificationChannel microsoftTeamsNotificationChannel;
|
||||
|
||||
@Before
|
||||
public void beforeEach() throws Exception {
|
||||
@ -524,6 +538,63 @@ public class NotificationApiTest extends AbstractNotificationApiTest {
|
||||
assertThat(stats.getErrors().get(NotificationDeliveryMethod.SLACK).values()).containsExactly(errorMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMicrosoftTeamsNotifications() throws Exception {
|
||||
RestTemplate restTemplate = mock(RestTemplate.class);
|
||||
microsoftTeamsNotificationChannel.setRestTemplate(restTemplate);
|
||||
|
||||
String webhookUrl = "https://webhook.com/webhookb2/9628fa60-d873-11ed-913c-a196b1f9b445";
|
||||
var targetConfig = new MicrosoftTeamsNotificationTargetConfig();
|
||||
targetConfig.setWebhookUrl(webhookUrl);
|
||||
targetConfig.setChannelName("My channel");
|
||||
NotificationTarget target = new NotificationTarget();
|
||||
target.setName("Microsoft Teams channel");
|
||||
target.setConfiguration(targetConfig);
|
||||
target = saveNotificationTarget(target);
|
||||
|
||||
var template = new MicrosoftTeamsDeliveryMethodNotificationTemplate();
|
||||
template.setEnabled(true);
|
||||
template.setSubject("This is subject");
|
||||
template.setBody("This is text");
|
||||
template.setThemeColor("ff0000");
|
||||
var button = new MicrosoftTeamsDeliveryMethodNotificationTemplate.Button();
|
||||
button.setName("Go to ThingsBoard Cloud");
|
||||
button.setUri("https://thingsboard.cloud");
|
||||
template.setButton(button);
|
||||
NotificationTemplate notificationTemplate = new NotificationTemplate();
|
||||
notificationTemplate.setName("Notification to Teams");
|
||||
notificationTemplate.setNotificationType(NotificationType.GENERAL);
|
||||
NotificationTemplateConfig templateConfig = new NotificationTemplateConfig();
|
||||
templateConfig.setDeliveryMethodsTemplates(Map.of(
|
||||
NotificationDeliveryMethod.MICROSOFT_TEAMS, template
|
||||
));
|
||||
notificationTemplate.setConfiguration(templateConfig);
|
||||
notificationTemplate = saveNotificationTemplate(notificationTemplate);
|
||||
|
||||
NotificationRequest notificationRequest = NotificationRequest.builder()
|
||||
.tenantId(tenantId)
|
||||
.originatorEntityId(tenantAdminUserId)
|
||||
.templateId(notificationTemplate.getId())
|
||||
.targets(List.of(target.getUuidId()))
|
||||
.build();
|
||||
|
||||
NotificationRequestPreview preview = doPost("/api/notification/request/preview", notificationRequest, NotificationRequestPreview.class);
|
||||
System.err.println(preview);
|
||||
assertThat(preview.getRecipientsCountByTarget().get(target.getName())).isEqualTo(1);
|
||||
assertThat(preview.getRecipientsPreview()).containsOnly(targetConfig.getChannelName());
|
||||
|
||||
var messageCaptor = ArgumentCaptor.forClass(MicrosoftTeamsNotificationChannel.Message.class);
|
||||
doPost("/api/notification/request", notificationRequest, NotificationRequest.class);
|
||||
verify(restTemplate, timeout(20000)).postForEntity(eq(webhookUrl), messageCaptor.capture(), any());
|
||||
|
||||
var message = messageCaptor.getValue();
|
||||
assertThat(message.getThemeColor()).isEqualTo(template.getThemeColor());
|
||||
assertThat(message.getSections().get(0).getActivityTitle()).isEqualTo(template.getSubject());
|
||||
assertThat(message.getSections().get(0).getActivitySubtitle()).isEqualTo(template.getBody());
|
||||
assertThat(message.getPotentialAction().get(0).getName()).isEqualTo(button.getName());
|
||||
assertThat(message.getPotentialAction().get(0).getTargets().get(0).getUri()).isEqualTo(button.getUri());
|
||||
}
|
||||
|
||||
private void checkFullNotificationsUpdate(UnreadNotificationsUpdate notificationsUpdate, String... expectedNotifications) {
|
||||
assertThat(notificationsUpdate.getNotifications()).extracting(Notification::getText).containsOnly(expectedNotifications);
|
||||
assertThat(notificationsUpdate.getNotifications()).extracting(Notification::getType).containsOnly(DEFAULT_NOTIFICATION_TYPE);
|
||||
|
||||
@ -24,7 +24,8 @@ public enum NotificationDeliveryMethod {
|
||||
WEB("web"),
|
||||
EMAIL("email"),
|
||||
SMS("SMS"),
|
||||
SLACK("Slack");
|
||||
SLACK("Slack"),
|
||||
MICROSOFT_TEAMS("Microsoft Teams");
|
||||
|
||||
@Getter
|
||||
private final String name;
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
package org.thingsboard.server.common.data.notification.targets;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class MicrosoftTeamsNotificationTargetConfig extends NotificationTargetConfig implements NotificationRecipient {
|
||||
|
||||
@NotBlank
|
||||
private String webhookUrl;
|
||||
@NotEmpty
|
||||
private String channelName;
|
||||
|
||||
@Override
|
||||
public NotificationTargetType getType() {
|
||||
return NotificationTargetType.MICROSOFT_TEAMS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getId() {
|
||||
return webhookUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return channelName;
|
||||
}
|
||||
|
||||
}
|
||||
@ -21,10 +21,16 @@ public interface NotificationRecipient {
|
||||
|
||||
String getTitle();
|
||||
|
||||
String getFirstName();
|
||||
default String getFirstName() {
|
||||
return null;
|
||||
}
|
||||
|
||||
String getLastName();
|
||||
default String getLastName() {
|
||||
return null;
|
||||
}
|
||||
|
||||
String getEmail();
|
||||
default String getEmail() {
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -30,7 +30,8 @@ import org.thingsboard.server.common.data.validation.NoXss;
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||
@JsonSubTypes({
|
||||
@Type(value = PlatformUsersNotificationTargetConfig.class, name = "PLATFORM_USERS"),
|
||||
@Type(value = SlackNotificationTargetConfig.class, name = "SLACK")
|
||||
@Type(value = SlackNotificationTargetConfig.class, name = "SLACK"),
|
||||
@Type(value = MicrosoftTeamsNotificationTargetConfig.class, name = "MICROSOFT_TEAMS")
|
||||
})
|
||||
@Data
|
||||
public abstract class NotificationTargetConfig {
|
||||
|
||||
@ -26,7 +26,8 @@ import java.util.Set;
|
||||
public enum NotificationTargetType {
|
||||
|
||||
PLATFORM_USERS(Set.of(NotificationDeliveryMethod.WEB, NotificationDeliveryMethod.EMAIL, NotificationDeliveryMethod.SMS)),
|
||||
SLACK(Set.of(NotificationDeliveryMethod.SLACK));
|
||||
SLACK(Set.of(NotificationDeliveryMethod.SLACK)),
|
||||
MICROSOFT_TEAMS(Set.of(NotificationDeliveryMethod.MICROSOFT_TEAMS));
|
||||
|
||||
@Getter
|
||||
private final Set<NotificationDeliveryMethod> supportedDeliveryMethods;
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
package org.thingsboard.server.common.data.notification.template;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@ToString(callSuper = true)
|
||||
public class MicrosoftTeamsDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject {
|
||||
|
||||
private String subject;
|
||||
private String themeColor;
|
||||
private Button button;
|
||||
|
||||
private String customMessageCardJson;
|
||||
|
||||
public MicrosoftTeamsDeliveryMethodNotificationTemplate(DeliveryMethodNotificationTemplate other) {
|
||||
super(other);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationDeliveryMethod getMethod() {
|
||||
return NotificationDeliveryMethod.MICROSOFT_TEAMS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MicrosoftTeamsDeliveryMethodNotificationTemplate copy() {
|
||||
return new MicrosoftTeamsDeliveryMethodNotificationTemplate(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TemplatableValue> getTemplatableValues() {
|
||||
return List.of(
|
||||
TemplatableValue.of(body, this::setBody),
|
||||
TemplatableValue.of(subject, this::setSubject),
|
||||
TemplatableValue.of(button, Button::getName, Button::setName),
|
||||
TemplatableValue.of(button, Button::getUri, Button::setUri),
|
||||
TemplatableValue.of(customMessageCardJson, this::setCustomMessageCardJson)
|
||||
);
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Button {
|
||||
private String name;
|
||||
private String uri;
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user