diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json
index 39996839f1..5a839d4c82 100644
--- a/application/src/main/data/json/system/widget_bundles/cards.json
+++ b/application/src/main/data/json/system/widget_bundles/cards.json
@@ -17,6 +17,7 @@
"cards.label_widget",
"cards.dashboard_state_widget",
"cards.qr_code",
+ "cards.mobile_app_qr_code",
"cards.attributes_card",
"cards.html_card",
"cards.html_value_card",
diff --git a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json
index a30d6ee76f..0af30e4df2 100644
--- a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json
+++ b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json
@@ -13,6 +13,7 @@
"home_page_widgets.quick_links",
"home_page_widgets.documentation_links",
"home_page_widgets.dashboards",
- "home_page_widgets.usage_info"
+ "home_page_widgets.usage_info",
+ "home_page_widgets.home_mobile_app_qr_code"
]
}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json
new file mode 100644
index 0000000000..88109fc902
--- /dev/null
+++ b/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json
@@ -0,0 +1,21 @@
+{
+ "fqn": "home_page_widgets.home_mobile_app_qr_code",
+ "name": "Mobile app QR code",
+ "deprecated": false,
+ "image": null,
+ "description": null,
+ "descriptor": {
+ "type": "static",
+ "sizeX": 6,
+ "sizeY": 3,
+ "resources": [],
+ "templateHtml": "\n",
+ "templateCss": "",
+ "controllerScript": "self.onInit = function() {\n}",
+ "settingsSchema": "",
+ "dataKeySettingsSchema": "",
+ "settingsDirective": "",
+ "defaultConfig": "{\"datasources\":[{\"type\":\"static\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"cardHtml\":\"
HTML code here
\",\"cardCss\":\".card {\\n font-weight: bold;\\n font-size: 32px;\\n color: #999;\\n width: 100%;\\n height: 100%;\\n display: flex;\\n align-items: center;\\n justify-content: center;\\n}\"},\"title\":\"Mobile app QR code\",\"dropShadow\":true}"
+ },
+ "tags": null
+}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json
new file mode 100644
index 0000000000..82ce9025ed
--- /dev/null
+++ b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json
@@ -0,0 +1,21 @@
+{
+ "fqn": "cards.mobile_app_qr_code",
+ "name": "Mobile app QR code",
+ "deprecated": false,
+ "image": null,
+ "description": null,
+ "descriptor": {
+ "type": "static",
+ "sizeX": 7.5,
+ "sizeY": 3,
+ "resources": [],
+ "templateHtml": "\n",
+ "templateCss": "",
+ "controllerScript": "self.onInit = function() {\n}\n",
+ "settingsSchema": "",
+ "dataKeySettingsSchema": "",
+ "settingsDirective": "tb-mobile-app-qr-code-widget-settings",
+ "defaultConfig": "{\"datasources\":[{\"type\":\"static\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"useDefaultApp\":true,\"androidConfig\":{\"enabled\":true,\"appPackage\":\"\",\"sha256CertFingerprints\":\"\"},\"iosConfig\":{\"enabled\":true,\"appId\":\"\"},\"qrCodeConfig\":{\"badgeEnabled\":true,\"badgeStyle\":\"ORIGINAL\",\"badgePosition\":\"RIGHT\",\"qrCodeLabelEnabled\":true,\"qrCodeLabel\":\"Scan to connect or download mobile app\"}},\"title\":\"Mobile app QR code\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"showTitleIcon\":false,\"titleTooltip\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"
+ },
+ "tags": null
+}
\ No newline at end of file
diff --git a/application/src/main/data/upgrade/3.6.4/schema_update.sql b/application/src/main/data/upgrade/3.6.4/schema_update.sql
index af354acf62..6fb1e35156 100644
--- a/application/src/main/data/upgrade/3.6.4/schema_update.sql
+++ b/application/src/main/data/upgrade/3.6.4/schema_update.sql
@@ -145,3 +145,18 @@ DELETE FROM asset WHERE type='TbServiceQueue';
DELETE FROM asset_profile WHERE name ='TbServiceQueue';
-- QUEUE STATS UPDATE END
+
+-- MOBILE APP SETTINGS TABLE CREATE START
+
+CREATE TABLE IF NOT EXISTS mobile_app_settings (
+ id uuid NOT NULL CONSTRAINT mobile_app_settings_pkey PRIMARY KEY,
+ created_time bigint NOT NULL,
+ tenant_id uuid NOT NULL,
+ use_default_app boolean,
+ android_config VARCHAR(1000),
+ ios_config VARCHAR(1000),
+ qr_code_config VARCHAR(100000),
+ CONSTRAINT mobile_app_settings_tenant_id_unq_key UNIQUE (tenant_id)
+);
+
+-- MOBILE APP SETTINGS TABLE CREATE END
\ No newline at end of file
diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
index 6c90e6e27d..a3111cb137 100644
--- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
+++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
@@ -76,7 +76,7 @@ public class ThingsboardSecurityConfiguration {
public static final String FORM_BASED_LOGIN_ENTRY_POINT = "/api/auth/login";
public static final String PUBLIC_LOGIN_ENTRY_POINT = "/api/auth/login/public";
public static final String TOKEN_REFRESH_ENTRY_POINT = "/api/auth/token";
- protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[]{"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**", "/api/images/public/**"};
+ protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[]{"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**", "/api/images/public/**", "/.well-known/**"};
public static final String TOKEN_BASED_AUTH_ENTRY_POINT = "/api/**";
public static final String WS_ENTRY_POINT = "/api/ws/**";
public static final String MAIL_OAUTH2_PROCESSING_ENTRY_POINT = "/api/admin/mail/oauth2/code";
diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java
new file mode 100644
index 0000000000..626f6f3b0f
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java
@@ -0,0 +1,188 @@
+/**
+ * Copyright © 2016-2024 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.controller;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import io.swagger.v3.oas.annotations.Parameter;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RestController;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.exception.ThingsboardException;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.mobile.AndroidConfig;
+import org.thingsboard.server.common.data.mobile.IosConfig;
+import org.thingsboard.server.common.data.mobile.MobileAppSettings;
+import org.thingsboard.server.common.data.security.model.JwtPair;
+import org.thingsboard.server.config.annotations.ApiOperation;
+import org.thingsboard.server.dao.mobile.MobileAppSettingsService;
+import org.thingsboard.server.queue.util.TbCoreComponent;
+import org.thingsboard.server.service.mobile.secret.MobileAppSecretService;
+import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.permission.Operation;
+import org.thingsboard.server.service.security.permission.Resource;
+import org.thingsboard.server.service.security.system.SystemSecurityService;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER;
+import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH;
+
+@RequiredArgsConstructor
+@RestController
+@TbCoreComponent
+public class MobileApplicationController extends BaseController {
+
+ @Value("${cache.specs.mobileSecretKey.timeToLiveInMinutes:2}")
+ private int mobileSecretKeyTtl;
+
+ public static final String ASSET_LINKS_PATTERN = "[{\n" +
+ " \"relation\": [\"delegate_permission/common.handle_all_urls\"],\n" +
+ " \"target\": {\n" +
+ " \"namespace\": \"android_app\",\n" +
+ " \"package_name\": \"%s\",\n" +
+ " \"sha256_cert_fingerprints\":\n" +
+ " [\"%s\"]\n" +
+ " }\n" +
+ "}]";
+
+ public static final String APPLE_APP_SITE_ASSOCIATION_PATTERN = "{\n" +
+ " \"applinks\": {\n" +
+ " \"apps\": [],\n" +
+ " \"details\": [\n" +
+ " {\n" +
+ " \"appID\": \"%s\",\n" +
+ " \"paths\": [ \"/api/noauth/qr\" ]\n" +
+ " }\n" +
+ " ]\n" +
+ " }\n" +
+ "}";
+
+ public static final String ANDROID_APPLICATION_STORE_LINK = "https://play.google.com/store/apps/details?id=org.thingsboard.demo.app";
+ public static final String APPLE_APPLICATION_STORE_LINK = "https://apps.apple.com/us/app/thingsboard-live/id1594355695";
+ public static final String SECRET = "secret";
+ public static final String SECRET_PARAM_DESCRIPTION = "A string value representing short-lived secret key";
+ public static final String DEFAULT_APP_DOMAIN = "demo.thingsboard.io";
+ public static final String DEEP_LINK_PATTERN = "https://%s/api/noauth/qr?secret=%s&ttl=%s";
+
+ private final SystemSecurityService systemSecurityService;
+ private final MobileAppSecretService mobileAppSecretService;
+ private final MobileAppSettingsService mobileAppSettingsService;
+
+ @ApiOperation(value = "Get associated android applications (getAssetLinks)")
+ @GetMapping(value = "/.well-known/assetlinks.json")
+ public ResponseEntity getAssetLinks() {
+ MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID);
+ AndroidConfig androidConfig = mobileAppSettings.getAndroidConfig();
+ if (androidConfig != null && androidConfig.isEnabled() && !androidConfig.getAppPackage().isBlank() && !androidConfig.getSha256CertFingerprints().isBlank()) {
+ return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(ASSET_LINKS_PATTERN, androidConfig.getAppPackage(), androidConfig.getSha256CertFingerprints())));
+ } else {
+ return ResponseEntity.notFound().build();
+ }
+ }
+
+ @ApiOperation(value = "Get associated ios applications (getAppleAppSiteAssociation)")
+ @GetMapping(value = "/.well-known/apple-app-site-association")
+ public ResponseEntity getAppleAppSiteAssociation() {
+ MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID);
+ IosConfig iosConfig = mobileAppSettings.getIosConfig();
+ if (iosConfig != null && iosConfig.isEnabled() && !iosConfig.getAppId().isBlank()) {
+ return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(APPLE_APP_SITE_ASSOCIATION_PATTERN, iosConfig.getAppId())));
+ } else {
+ return ResponseEntity.notFound().build();
+ }
+ }
+
+ @ApiOperation(value = "Create Or Update the Mobile application settings (saveMobileAppSettings)",
+ notes = "The request payload contains configuration for android/iOS applications and platform qr code widget settings." + SYSTEM_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAnyAuthority('SYS_ADMIN')")
+ @PostMapping(value = "/api/mobile/app/settings")
+ public MobileAppSettings saveMobileAppSettings(@Parameter(description = "A JSON value representing the mobile apps configuration")
+ @RequestBody MobileAppSettings mobileAppSettings) throws ThingsboardException {
+ SecurityUser currentUser = getCurrentUser();
+ accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.WRITE);
+ mobileAppSettings.setTenantId(getTenantId());
+ return mobileAppSettingsService.saveMobileAppSettings(currentUser.getTenantId(), mobileAppSettings);
+ }
+
+ @ApiOperation(value = "Get Mobile application settings (getMobileAppSettings)",
+ notes = "The response payload contains configuration for android/iOS applications and platform qr code widget settings." + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
+ @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
+ @GetMapping(value = "/api/mobile/app/settings")
+ public MobileAppSettings getMobileAppSettings() throws ThingsboardException {
+ SecurityUser currentUser = getCurrentUser();
+ accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.READ);
+ return mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID);
+ }
+
+ @ApiOperation(value = "Get the deep link to the associated mobile application (getMobileAppDeepLink)",
+ notes = "Fetch the url that takes user to linked mobile application " + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
+ @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
+ @GetMapping(value = "/api/mobile/deepLink", produces = "text/plain")
+ public String getMobileAppDeepLink(HttpServletRequest request) throws ThingsboardException, URISyntaxException {
+ String secret = mobileAppSecretService.generateMobileAppSecret(getCurrentUser());
+ String baseUrl = systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, null, request);
+ String platformDomain = new URI(baseUrl).getHost();
+ MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID);
+ String appDomain;
+ if (!mobileAppSettings.isUseDefaultApp()) {
+ appDomain = platformDomain;
+ } else {
+ appDomain = DEFAULT_APP_DOMAIN;
+ }
+ String deepLink = String.format(DEEP_LINK_PATTERN, appDomain, secret, mobileSecretKeyTtl);
+ if (!appDomain.equals(platformDomain)) {
+ deepLink = deepLink + "&host=" + baseUrl;
+ }
+ return "\"" + deepLink + "\"";
+ }
+
+ @ApiOperation(value = "Get User Token (getUserTokenByMobileSecret)",
+ notes = "Returns the token of the User based on the provided secret key.")
+ @GetMapping(value = "/api/noauth/qr/{secret}")
+ public JwtPair getUserTokenByMobileSecret(@Parameter(description = SECRET_PARAM_DESCRIPTION)
+ @PathVariable(SECRET) String secret) throws ThingsboardException {
+ checkParameter(SECRET, secret);
+ return mobileAppSecretService.getJwtPair(secret);
+ }
+
+ @GetMapping(value = "/api/noauth/qr")
+ public ResponseEntity> getApplicationRedirect(@RequestHeader(value = "User-Agent") String userAgent) {
+ if (userAgent.contains("Android")) {
+ return ResponseEntity.status(HttpStatus.FOUND)
+ .header("Location", ANDROID_APPLICATION_STORE_LINK)
+ .build();
+ } else if (userAgent.contains("iPhone") || userAgent.contains("iPad")) {
+ return ResponseEntity.status(HttpStatus.FOUND)
+ .header("Location", APPLE_APPLICATION_STORE_LINK)
+ .build();
+ } else {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .build();
+ }
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
index cb2033b29d..bc12171306 100644
--- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
@@ -34,10 +34,13 @@ import org.thingsboard.server.common.data.SystemParams;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.mobile.MobileAppSettings;
+import org.thingsboard.server.common.data.mobile.QRCodeConfig;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.settings.UserSettings;
import org.thingsboard.server.common.data.settings.UserSettingsType;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
+import org.thingsboard.server.dao.mobile.MobileAppSettingsService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal;
@@ -45,6 +48,7 @@ import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService;
import java.util.Collections;
import java.util.List;
+import java.util.Optional;
import java.util.stream.Collectors;
@Hidden
@@ -72,6 +76,9 @@ public class SystemInfoController extends BaseController {
@Autowired
private EntitiesVersionControlService versionControlService;
+ @Autowired
+ private MobileAppSettingsService mobileAppSettingsService;
+
@PostConstruct
public void init() {
JsonNode info = buildInfoObject();
@@ -135,6 +142,9 @@ public class SystemInfoController extends BaseController {
DefaultTenantProfileConfiguration tenantProfileConfiguration = tenantProfileCache.get(tenantId).getDefaultProfileConfiguration();
systemParams.setMaxResourceSize(tenantProfileConfiguration.getMaxResourceSize());
}
+ systemParams.setMobileQrEnabled(Optional.ofNullable(mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID))
+ .map(MobileAppSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage)
+ .orElse(false));
return systemParams;
}
diff --git a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretService.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretService.java
new file mode 100644
index 0000000000..83c1b55c6e
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretService.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2024 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.service.mobile.secret;
+
+import org.thingsboard.server.common.data.exception.ThingsboardException;
+import org.thingsboard.server.common.data.security.model.JwtPair;
+import org.thingsboard.server.service.security.model.SecurityUser;
+
+public interface MobileAppSecretService {
+
+ String generateMobileAppSecret(SecurityUser securityUser);
+
+ JwtPair getJwtPair(String secret) throws ThingsboardException;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java
new file mode 100644
index 0000000000..bef31622ff
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java
@@ -0,0 +1,66 @@
+/**
+ * Copyright © 2016-2024 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.service.mobile.secret;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.event.TransactionalEventListener;
+import org.thingsboard.server.cache.TbCacheValueWrapper;
+import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
+import org.thingsboard.server.common.data.exception.ThingsboardException;
+import org.thingsboard.server.common.data.security.model.JwtPair;
+import org.thingsboard.server.dao.entity.AbstractCachedService;
+import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
+import org.thingsboard.server.service.security.system.SystemSecurityService;
+
+import static org.thingsboard.server.service.security.system.DefaultSystemSecurityService.DEFAULT_MOBILE_SECRET_KEY_LENGTH;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class MobileAppSecretServiceImpl extends AbstractCachedService implements MobileAppSecretService {
+
+ private final JwtTokenFactory tokenFactory;
+ private final SystemSecurityService systemSecurityService;
+
+ @Override
+ public String generateMobileAppSecret(SecurityUser securityUser) {
+ log.trace("Executing generateSecret for user [{}]", securityUser.getId());
+ Integer mobileSecretKeyLength = systemSecurityService.getSecuritySettings().getMobileSecretKeyLength();
+ String secret = StringUtils.generateSafeToken(mobileSecretKeyLength == null ? DEFAULT_MOBILE_SECRET_KEY_LENGTH : mobileSecretKeyLength);
+ cache.put(secret, tokenFactory.createTokenPair(securityUser));
+ return secret;
+ }
+
+ @Override
+ public JwtPair getJwtPair(String secret) throws ThingsboardException {
+ TbCacheValueWrapper jwtPair = cache.get(secret);
+ if (jwtPair != null) {
+ return jwtPair.get();
+ } else {
+ throw new ThingsboardException("Jwt token not found or expired!", ThingsboardErrorCode.JWT_TOKEN_EXPIRED);
+ }
+ }
+
+ @TransactionalEventListener(classes = MobileSecretEvictEvent.class)
+ @Override
+ public void handleEvictEvent(MobileSecretEvictEvent event) {
+ cache.evict(event.getSecret());
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretCaffeineCache.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretCaffeineCache.java
new file mode 100644
index 0000000000..ce21041d27
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretCaffeineCache.java
@@ -0,0 +1,33 @@
+/**
+ * Copyright © 2016-2024 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.service.mobile.secret;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.cache.CacheManager;
+import org.springframework.stereotype.Service;
+import org.thingsboard.server.cache.CaffeineTbTransactionalCache;
+import org.thingsboard.server.common.data.CacheConstants;
+import org.thingsboard.server.common.data.security.model.JwtPair;
+
+@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true)
+@Service("MobileSecretCache")
+public class MobileSecretCaffeineCache extends CaffeineTbTransactionalCache {
+
+ public MobileSecretCaffeineCache(CacheManager cacheManager) {
+ super(cacheManager, CacheConstants.MOBILE_SECRET_KEY_CACHE);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretEvictEvent.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretEvictEvent.java
new file mode 100644
index 0000000000..b015291d78
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretEvictEvent.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright © 2016-2024 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.service.mobile.secret;
+
+import lombok.Data;
+
+@Data
+public class MobileSecretEvictEvent {
+
+ private final String secret;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java
new file mode 100644
index 0000000000..81fa8672f8
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java
@@ -0,0 +1,36 @@
+/**
+ * Copyright © 2016-2024 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.service.mobile.secret;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.stereotype.Service;
+import org.thingsboard.server.cache.CacheSpecsMap;
+import org.thingsboard.server.cache.RedisTbTransactionalCache;
+import org.thingsboard.server.cache.TBRedisCacheConfiguration;
+import org.thingsboard.server.cache.TbJsonRedisSerializer;
+import org.thingsboard.server.common.data.CacheConstants;
+import org.thingsboard.server.common.data.id.UserId;
+import org.thingsboard.server.common.data.security.model.JwtPair;
+
+@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis")
+@Service("MobileSecretCache")
+public class MobileSecretRedisCache extends RedisTbTransactionalCache {
+
+ public MobileSecretRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) {
+ super(CacheConstants.MOBILE_SECRET_KEY_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(JwtPair.class));
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java
index d26e49e4ab..af1b34ae71 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java
@@ -49,6 +49,7 @@ public class CustomerUserPermissions extends AbstractPermissions {
put(Resource.DEVICE_PROFILE, profilePermissionChecker);
put(Resource.ASSET_PROFILE, profilePermissionChecker);
put(Resource.TB_RESOURCE, customerResourcePermissionChecker);
+ put(Resource.MOBILE_APP_SETTINGS, new PermissionChecker.GenericPermissionChecker(Operation.READ));
}
private static final PermissionChecker customerAlarmPermissionChecker = new PermissionChecker() {
diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java
index 5aa869f2a8..d43663fb97 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java
@@ -46,8 +46,8 @@ public enum Resource {
QUEUE(EntityType.QUEUE),
VERSION_CONTROL,
NOTIFICATION(EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_TEMPLATE,
- EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE);
-
+ EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE),
+ MOBILE_APP_SETTINGS;
private final Set entityTypes;
Resource() {
diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java
index 67cc105c91..d906938e47 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java
@@ -41,6 +41,7 @@ public class SysAdminPermissions extends AbstractPermissions {
put(Resource.TB_RESOURCE, systemEntityPermissionChecker);
put(Resource.QUEUE, systemEntityPermissionChecker);
put(Resource.NOTIFICATION, systemEntityPermissionChecker);
+ put(Resource.MOBILE_APP_SETTINGS, PermissionChecker.allowAllPermissionChecker);
}
private static final PermissionChecker systemEntityPermissionChecker = new PermissionChecker() {
diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java
index bfe0af966f..10807e4b5a 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java
@@ -50,6 +50,7 @@ public class TenantAdminPermissions extends AbstractPermissions {
put(Resource.QUEUE, queuePermissionChecker);
put(Resource.VERSION_CONTROL, PermissionChecker.allowAllPermissionChecker);
put(Resource.NOTIFICATION, tenantEntityPermissionChecker);
+ put(Resource.MOBILE_APP_SETTINGS, new PermissionChecker.GenericPermissionChecker(Operation.READ));
}
public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() {
diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java
index d3f945cda1..b12e6d4581 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java
@@ -57,7 +57,6 @@ import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.dao.user.UserServiceImpl;
import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails;
import org.thingsboard.server.service.security.exception.UserPasswordExpiredException;
-import org.thingsboard.server.service.security.exception.UserPasswordNotValidException;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.utils.MiscUtils;
import ua_parser.Client;
@@ -75,6 +74,8 @@ import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTING
@Slf4j
public class DefaultSystemSecurityService implements SystemSecurityService {
+ public static final int DEFAULT_MOBILE_SECRET_KEY_LENGTH = 64;
+
@Autowired
private AdminSettingsService adminSettingsService;
@@ -109,6 +110,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
securitySettings.setPasswordPolicy(new UserPasswordPolicy());
securitySettings.getPasswordPolicy().setMinimumLength(6);
securitySettings.getPasswordPolicy().setMaximumLength(72);
+ securitySettings.setMobileSecretKeyLength(DEFAULT_MOBILE_SECRET_KEY_LENGTH);
}
return securitySettings;
}
diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml
index 191d810c64..bfd470b611 100644
--- a/application/src/main/resources/thingsboard.yml
+++ b/application/src/main/resources/thingsboard.yml
@@ -585,6 +585,12 @@ cache:
alarmTypes:
timeToLiveInMinutes: "${CACHE_SPECS_ALARM_TYPES_TTL:60}" # Alarm types cache TTL
maxSize: "${CACHE_SPECS_ALARM_TYPES_MAX_SIZE:10000}" # 0 means the cache is disabled
+ mobileAppSettings:
+ timeToLiveInMinutes: "${CACHE_SPECS_MOBILE_APP_SETTINGS_TTL:1440}" # Mobile application cache TTL
+ maxSize: "${CACHE_SPECS_MOBILE_APP_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled
+ mobileSecretKey:
+ timeToLiveInMinutes: "${CACHE_MOBILE_SECRET_KEY_TTL:2}" # QR secret key cache TTL
+ maxSize: "${CACHE_MOBILE_SECRET_KEY_MAX_SIZE:10000}" # 0 means the cache is disabled
# Deliberately placed outside the 'specs' group above
notificationRules:
diff --git a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java
new file mode 100644
index 0000000000..39e2ef6a40
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java
@@ -0,0 +1,253 @@
+/**
+ * Copyright © 2016-2024 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.controller;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.beans.factory.annotation.Value;
+import org.thingsboard.server.common.data.mobile.AndroidConfig;
+import org.thingsboard.server.common.data.mobile.IosConfig;
+import org.thingsboard.server.common.data.mobile.MobileAppSettings;
+import org.thingsboard.server.common.data.mobile.QRCodeConfig;
+import org.thingsboard.server.common.data.security.model.JwtPair;
+import org.thingsboard.server.dao.service.DaoSqlTest;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@Slf4j
+@DaoSqlTest
+public class MobileApplicationControllerTest extends AbstractControllerTest {
+
+ @Value("${cache.specs.mobileSecretKey.timeToLiveInMinutes:2}")
+ private int mobileSecretKeyTtl;
+ private static final String ANDROID_PACKAGE_NAME = "testAppPackage";
+ private static final String ANDROID_APP_SHA256 = "DF:28:32:66:8B:A7:D3:EC:7D:73:CF:CC";
+ private static final String APPLE_APP_ID = "testId";
+ private static final String TEST_LABEL = "Test label";
+
+ @Before
+ public void setUp() throws Exception {
+ loginSysAdmin();
+
+ MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class);
+ QRCodeConfig qrCodeConfig = new QRCodeConfig();
+ qrCodeConfig.setQrCodeLabel(TEST_LABEL);
+
+ mobileAppSettings.setUseDefaultApp(true);
+ AndroidConfig androidConfig = AndroidConfig.builder()
+ .appPackage(ANDROID_PACKAGE_NAME)
+ .sha256CertFingerprints(ANDROID_APP_SHA256)
+ .enabled(true)
+ .build();
+
+ IosConfig iosConfig = IosConfig.builder()
+ .appId(APPLE_APP_ID)
+ .enabled(true)
+ .build();
+ mobileAppSettings.setAndroidConfig(androidConfig);
+ mobileAppSettings.setIosConfig(iosConfig);
+ mobileAppSettings.setQrCodeConfig(qrCodeConfig);
+
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testSaveMobileAppSettings() throws Exception {
+ loginSysAdmin();
+ MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class);
+ assertThat(mobileAppSettings.getQrCodeConfig().getQrCodeLabel()).isEqualTo(TEST_LABEL);
+ assertThat(mobileAppSettings.isUseDefaultApp()).isTrue();
+
+ mobileAppSettings.setUseDefaultApp(false);
+
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isOk());
+
+ MobileAppSettings updatedMobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class);
+ assertThat(updatedMobileAppSettings.isUseDefaultApp()).isFalse();
+ }
+
+ @Test
+ public void testShouldNotSaveMobileAppSettingsWithoutRequiredConfig() throws Exception {
+ loginSysAdmin();
+ MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class);
+
+ mobileAppSettings.setUseDefaultApp(false);
+ mobileAppSettings.setAndroidConfig(null);
+ mobileAppSettings.setIosConfig(null);
+ mobileAppSettings.setQrCodeConfig(null);
+
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString("Android/ios settings are required to use custom application!")));
+
+ mobileAppSettings.setAndroidConfig(AndroidConfig.builder().enabled(false).build());
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString("Android/ios settings are required to use custom application!")));
+
+ mobileAppSettings.setIosConfig(IosConfig.builder().enabled(false).build());
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString("Qr code configuration is required!")));
+
+ mobileAppSettings.setQrCodeConfig(QRCodeConfig.builder().showOnHomePage(false).build());
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testShouldNotSaveMobileAppSettingsWithoutRequiredAndroidConf() throws Exception {
+ loginSysAdmin();
+ MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class);
+ mobileAppSettings.setUseDefaultApp(false);
+ AndroidConfig androidConfig = AndroidConfig.builder()
+ .enabled(true)
+ .appPackage(null)
+ .sha256CertFingerprints(null)
+ .build();
+ mobileAppSettings.setAndroidConfig(androidConfig);
+
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString("Application package and sha256 cert fingerprints are required for custom android application!")));
+
+ androidConfig.setAppPackage("test_app_package");
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString("Application package and sha256 cert fingerprints are required for custom android application!")));
+
+ androidConfig.setSha256CertFingerprints("test_sha_256");
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testShouldNotSaveMobileAppSettingsWithoutRequiredIosConf() throws Exception {
+ loginSysAdmin();
+ MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class);
+ mobileAppSettings.setUseDefaultApp(false);
+ IosConfig iosConfig = IosConfig.builder()
+ .enabled(true)
+ .appId(null)
+ .build();
+ mobileAppSettings.setIosConfig(iosConfig);
+
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString("Application id is required for custom ios application!")));
+
+ iosConfig.setAppId("test_app_id");
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testShouldSaveMobileAppSettingsForDefaultApp() throws Exception {
+ loginSysAdmin();
+ MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class);
+ mobileAppSettings.setUseDefaultApp(true);
+ mobileAppSettings.setIosConfig(null);
+ mobileAppSettings.setAndroidConfig(null);
+
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testGetApplicationAssociations() throws Exception {
+ loginSysAdmin();
+ MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class);
+ mobileAppSettings.setUseDefaultApp(false);
+ doPost("/api/mobile/app/settings", mobileAppSettings)
+ .andExpect(status().isOk());
+
+ JsonNode assetLinks = doGet("/.well-known/assetlinks.json", JsonNode.class);
+ assertThat(assetLinks.get(0).get("target").get("package_name").asText()).isEqualTo(ANDROID_PACKAGE_NAME);
+ assertThat(assetLinks.get(0).get("target").get("sha256_cert_fingerprints").get(0).asText()).isEqualTo(ANDROID_APP_SHA256);
+
+ JsonNode appleAssociation = doGet("/.well-known/apple-app-site-association", JsonNode.class);
+ assertThat(appleAssociation.get("applinks").get("details").get(0).get("appID").asText()).isEqualTo(APPLE_APP_ID);
+ }
+
+ @Test
+ public void testGetMobileDeepLink() throws Exception {
+ loginSysAdmin();
+ String deepLink = doGet("/api/mobile/deepLink", String.class);
+
+ Pattern expectedPattern = Pattern.compile("\"https://([^/]+)/api/noauth/qr\\?secret=([^&]+)&ttl=([^&]+)&host=([^&]+)\"");
+ Matcher parsedDeepLink = expectedPattern.matcher(deepLink);
+ assertThat(parsedDeepLink.matches()).isTrue();
+ String appHost = parsedDeepLink.group(1);
+ String secret = parsedDeepLink.group(2);
+ String ttl = parsedDeepLink.group(3);
+ assertThat(appHost).isEqualTo("demo.thingsboard.io");
+ assertThat(ttl).isEqualTo(String.valueOf(mobileSecretKeyTtl));
+
+ JwtPair jwtPair = doGet("/api/noauth/qr/" + secret, JwtPair.class);
+ assertThat(jwtPair).isNotNull();
+
+ loginTenantAdmin();
+ String tenantDeepLink = doGet("/api/mobile/deepLink", String.class);
+ Matcher tenantParsedDeepLink = expectedPattern.matcher(tenantDeepLink);
+ assertThat(tenantParsedDeepLink.matches()).isTrue();
+ String tenantSecret = tenantParsedDeepLink.group(2);
+
+ JwtPair tenantJwtPair = doGet("/api/noauth/qr/" + tenantSecret, JwtPair.class);
+ assertThat(tenantJwtPair).isNotNull();
+
+ loginCustomerUser();
+ String customerDeepLink = doGet("/api/mobile/deepLink", String.class);
+ Matcher customerParsedDeepLink = expectedPattern.matcher(customerDeepLink);
+ assertThat(customerParsedDeepLink.matches()).isTrue();
+ String customerSecret = customerParsedDeepLink.group(2);
+
+ JwtPair customerJwtPair = doGet("/api/noauth/qr/" + customerSecret, JwtPair.class);
+ assertThat(customerJwtPair).isNotNull();
+
+ // update mobile setting to use custom one
+ loginSysAdmin();
+ MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class);
+ mobileAppSettings.setUseDefaultApp(false);
+ doPost("/api/mobile/app/settings", mobileAppSettings);
+
+ String customAppDeepLink = doGet("/api/mobile/deepLink", String.class);
+ Pattern customAppExpectedPattern = Pattern.compile("\"https://([^/]+)/api/noauth/qr\\?secret=([^&]+)&ttl=([^&]+)\"");
+ Matcher customAppParsedDeepLink = customAppExpectedPattern.matcher(customAppDeepLink);
+ assertThat(customAppParsedDeepLink.matches()).isTrue();
+ assertThat(customAppParsedDeepLink.group(1)).isEqualTo("localhost");
+
+ loginTenantAdmin();
+ String tenantCustomAppDeepLink = doGet("/api/mobile/deepLink", String.class);
+ Matcher tenantCustomAppParsedDeepLink = customAppExpectedPattern.matcher(tenantCustomAppDeepLink);
+ assertThat(tenantCustomAppParsedDeepLink.matches()).isTrue();
+ assertThat(tenantCustomAppParsedDeepLink.group(1)).isEqualTo("localhost");
+
+ loginCustomerUser();
+ String customerCustomAppDeepLink = doGet("/api/mobile/deepLink", String.class);
+ Matcher customerCustomAppParsedDeepLink = customAppExpectedPattern.matcher(customerCustomAppDeepLink);
+ assertThat(customerCustomAppParsedDeepLink.matches()).isTrue();
+ assertThat(customerCustomAppParsedDeepLink.group(1)).isEqualTo("localhost");
+ }
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java
index e004dd218d..c95259aec5 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java
@@ -48,4 +48,6 @@ public class CacheConstants {
public static final String ENTITY_COUNT_CACHE = "entityCount";
public static final String RESOURCE_INFO_CACHE = "resourceInfo";
public static final String ALARM_TYPES_CACHE = "alarmTypes";
+ public static final String MOBILE_APP_SETTINGS_CACHE = "mobileAppSettings";
+ public static final String MOBILE_SECRET_KEY_CACHE = "mobileSecretKey";
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
index e022e56c5b..5b4312f7b0 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
@@ -31,4 +31,5 @@ public class SystemParams {
JsonNode userSettings;
long maxDatapointsLimit;
long maxResourceSize;
+ boolean mobileQrEnabled;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppSettingsId.java
new file mode 100644
index 0000000000..d152701fc4
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppSettingsId.java
@@ -0,0 +1,37 @@
+/**
+ * Copyright © 2016-2024 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.id;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.util.UUID;
+
+@Schema
+public class MobileAppSettingsId extends UUIDBased {
+
+ private static final long serialVersionUID = 1L;
+
+ @JsonCreator
+ public MobileAppSettingsId(@JsonProperty("id") UUID id) {
+ super(id);
+ }
+
+ public static MobileAppSettingsId fromString(String mobileAppSettingsId) {
+ return new MobileAppSettingsId(UUID.fromString(mobileAppSettingsId));
+ }
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/AndroidConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/AndroidConfig.java
new file mode 100644
index 0000000000..e1fb5056fc
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/AndroidConfig.java
@@ -0,0 +1,38 @@
+/**
+ * Copyright © 2016-2024 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.mobile;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.validation.NoXss;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode
+public class AndroidConfig {
+
+ private boolean enabled;
+ @NoXss
+ private String appPackage;
+ @NoXss
+ private String sha256CertFingerprints;
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgePosition.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgePosition.java
new file mode 100644
index 0000000000..89c46a5c12
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgePosition.java
@@ -0,0 +1,23 @@
+/**
+ * Copyright © 2016-2024 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.mobile;
+
+public enum BadgePosition {
+
+ RIGHT,
+ LEFT;
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgeStyle.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgeStyle.java
new file mode 100644
index 0000000000..51606a2340
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgeStyle.java
@@ -0,0 +1,24 @@
+/**
+ * Copyright © 2016-2024 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.mobile;
+
+
+public enum BadgeStyle {
+
+ ORIGINAL,
+ WHITE;
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/IosConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/IosConfig.java
new file mode 100644
index 0000000000..f03b446b3b
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/IosConfig.java
@@ -0,0 +1,36 @@
+/**
+ * Copyright © 2016-2024 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.mobile;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.validation.NoXss;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode
+public class IosConfig {
+
+ private boolean enabled;
+ @NoXss
+ private String appId;
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java
new file mode 100644
index 0000000000..9a7b88f22c
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java
@@ -0,0 +1,45 @@
+/**
+ * Copyright © 2016-2024 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.mobile;
+
+import jakarta.validation.Valid;
+import lombok.Data;
+import org.thingsboard.server.common.data.BaseData;
+import org.thingsboard.server.common.data.HasTenantId;
+import org.thingsboard.server.common.data.id.MobileAppSettingsId;
+import org.thingsboard.server.common.data.id.TenantId;
+
+@Data
+public class MobileAppSettings extends BaseData implements HasTenantId {
+
+ private static final long serialVersionUID = 2628323657987010348L;
+
+ private TenantId tenantId;
+ private boolean useDefaultApp;
+ @Valid
+ private AndroidConfig androidConfig;
+ @Valid
+ private IosConfig iosConfig;
+ @Valid
+ private QRCodeConfig qrCodeConfig;
+
+ public MobileAppSettings() {
+ }
+ public MobileAppSettings(MobileAppSettingsId id) {
+ super(id);
+ }
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java
new file mode 100644
index 0000000000..b3f975870e
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright © 2016-2024 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.mobile;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.validation.NoXss;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode
+public class QRCodeConfig {
+
+ private boolean showOnHomePage;
+ private boolean badgeEnabled;
+ private boolean qrCodeLabelEnabled;
+ private BadgePosition badgePosition;
+ private BadgeStyle badgeStyle;
+ @NoXss
+ private String qrCodeLabel;
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java
index 459aa7b26f..ae484136ce 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java
@@ -20,10 +20,12 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import org.thingsboard.server.common.data.security.Authority;
+import java.io.Serializable;
+
@Schema(description = "JWT Pair")
@Data
@NoArgsConstructor
-public class JwtPair {
+public class JwtPair implements Serializable {
@Schema(description = "The JWT Access Token. Used to perform API calls.", example = "AAB254FF67D..")
private String token;
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java
index 07262c7eb4..aa4d303440 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java
@@ -32,4 +32,6 @@ public class SecuritySettings implements Serializable {
private Integer maxFailedLoginAttempts;
@Schema(description = "Email to use for notifications about locked users." )
private String userLockoutNotificationEmail;
+ @Schema(description = "Mobile secret key length" )
+ private Integer mobileSecretKeyLength;
}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java
new file mode 100644
index 0000000000..4e8a2cd503
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java
@@ -0,0 +1,112 @@
+/**
+ * Copyright © 2016-2024 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.dao.mobile;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.event.TransactionalEventListener;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.mobile.AndroidConfig;
+import org.thingsboard.server.common.data.mobile.BadgePosition;
+import org.thingsboard.server.common.data.mobile.BadgeStyle;
+import org.thingsboard.server.common.data.mobile.IosConfig;
+import org.thingsboard.server.common.data.mobile.MobileAppSettings;
+import org.thingsboard.server.common.data.mobile.QRCodeConfig;
+import org.thingsboard.server.dao.entity.AbstractCachedEntityService;
+import org.thingsboard.server.dao.service.DataValidator;
+
+import java.util.Map;
+
+import static org.thingsboard.server.dao.service.Validator.validateId;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class BaseMobileAppSettingsService extends AbstractCachedEntityService implements MobileAppSettingsService {
+
+ public static final String INCORRECT_TENANT_ID = "Incorrect tenantId ";
+ private static final String DEFAULT_QR_CODE_LABEL = "Scan to connect or download mobile app";
+
+ private final MobileAppSettingsDao mobileAppSettingsDao;
+ private final DataValidator mobileAppSettingsDataValidator;
+
+ @Override
+ public MobileAppSettings saveMobileAppSettings(TenantId tenantId, MobileAppSettings mobileAppSettings) {
+ mobileAppSettingsDataValidator.validate(mobileAppSettings, s -> tenantId);
+ try {
+ MobileAppSettings savedMobileAppSettings = mobileAppSettingsDao.save(tenantId, mobileAppSettings);
+ publishEvictEvent(new MobileAppSettingsEvictEvent(tenantId));
+ return savedMobileAppSettings;
+ } catch (Exception e) {
+ handleEvictEvent(new MobileAppSettingsEvictEvent(tenantId));
+ checkConstraintViolation(e, Map.of(
+ "mobile_app_settings_tenant_id_unq_key", "Mobile application for specified tenant already exists!"
+ ));
+ throw e;
+ }
+ }
+
+ @Override
+ public MobileAppSettings getMobileAppSettings(TenantId tenantId) {
+ log.trace("Executing getMobileAppSettings for tenant [{}] ", tenantId);
+ MobileAppSettings mobileAppSettings = cache.getAndPutInTransaction(tenantId,
+ () -> mobileAppSettingsDao.findByTenantId(tenantId), true);
+ return constructMobileAppSettings(mobileAppSettings);
+ }
+
+ @Override
+ public void deleteByTenantId(TenantId tenantId) {
+ log.trace("Executing deleteByTenantId, tenantId [{}]", tenantId);
+ validateId(tenantId, id -> INCORRECT_TENANT_ID + id);
+ mobileAppSettingsDao.removeByTenantId(tenantId);
+ }
+
+ @TransactionalEventListener(classes = MobileAppSettingsEvictEvent.class)
+ @Override
+ public void handleEvictEvent(MobileAppSettingsEvictEvent event) {
+ cache.evict(event.getTenantId());
+ }
+
+ private MobileAppSettings constructMobileAppSettings(MobileAppSettings mobileAppSettings) {
+ if (mobileAppSettings == null) {
+ mobileAppSettings = new MobileAppSettings();
+ mobileAppSettings.setUseDefaultApp(true);
+
+ AndroidConfig androidConfig = AndroidConfig.builder()
+ .enabled(true)
+ .build();
+ IosConfig iosConfig = IosConfig.builder()
+ .enabled(true)
+ .build();
+ QRCodeConfig qrCodeConfig = QRCodeConfig.builder()
+ .showOnHomePage(true)
+ .qrCodeLabelEnabled(true)
+ .qrCodeLabel(DEFAULT_QR_CODE_LABEL)
+ .badgeEnabled(true)
+ .badgePosition(BadgePosition.RIGHT)
+ .badgeStyle(BadgeStyle.ORIGINAL)
+ .badgeEnabled(true)
+ .build();
+
+ mobileAppSettings.setQrCodeConfig(qrCodeConfig);
+ mobileAppSettings.setAndroidConfig(androidConfig);
+ mobileAppSettings.setIosConfig(iosConfig);
+ }
+ return mobileAppSettings;
+ }
+
+}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsCaffeineCache.java
new file mode 100644
index 0000000000..9e15ae8572
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsCaffeineCache.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright © 2016-2024 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.dao.mobile;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.cache.CacheManager;
+import org.springframework.stereotype.Service;
+import org.thingsboard.server.cache.CaffeineTbTransactionalCache;
+import org.thingsboard.server.common.data.CacheConstants;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.mobile.MobileAppSettings;
+
+@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true)
+@Service("MobileAppCache")
+public class MobileAppSettingsCaffeineCache extends CaffeineTbTransactionalCache {
+
+ public MobileAppSettingsCaffeineCache(CacheManager cacheManager) {
+ super(cacheManager, CacheConstants.MOBILE_APP_SETTINGS_CACHE);
+ }
+
+}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java
new file mode 100644
index 0000000000..094ead1a5b
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2024 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.dao.mobile;
+
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.mobile.MobileAppSettings;
+import org.thingsboard.server.dao.Dao;
+
+
+public interface MobileAppSettingsDao extends Dao {
+
+ MobileAppSettings findByTenantId(TenantId tenantId);
+
+ void removeByTenantId(TenantId tenantId);
+}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsEvictEvent.java
new file mode 100644
index 0000000000..459e61ef6d
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsEvictEvent.java
@@ -0,0 +1,24 @@
+/**
+ * Copyright © 2016-2024 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.dao.mobile;
+
+import lombok.Data;
+import org.thingsboard.server.common.data.id.TenantId;
+
+@Data
+public class MobileAppSettingsEvictEvent {
+ private final TenantId tenantId;
+}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsRedisCache.java
new file mode 100644
index 0000000000..aac802c1ec
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsRedisCache.java
@@ -0,0 +1,36 @@
+/**
+ * Copyright © 2016-2024 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.dao.mobile;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.stereotype.Service;
+import org.thingsboard.server.cache.CacheSpecsMap;
+import org.thingsboard.server.cache.RedisTbTransactionalCache;
+import org.thingsboard.server.cache.TBRedisCacheConfiguration;
+import org.thingsboard.server.cache.TbJsonRedisSerializer;
+import org.thingsboard.server.common.data.CacheConstants;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.mobile.MobileAppSettings;
+
+@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis")
+@Service("MobileAppCache")
+public class MobileAppSettingsRedisCache extends RedisTbTransactionalCache {
+
+ public MobileAppSettingsRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) {
+ super(CacheConstants.MOBILE_APP_SETTINGS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(MobileAppSettings.class));
+ }
+}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsService.java
new file mode 100644
index 0000000000..4645947382
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsService.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright © 2016-2024 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.dao.mobile;
+
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.mobile.MobileAppSettings;
+
+public interface MobileAppSettingsService {
+
+ MobileAppSettings saveMobileAppSettings(TenantId tenantId, MobileAppSettings settings);
+
+ MobileAppSettings getMobileAppSettings(TenantId tenantId);
+
+ void deleteByTenantId(TenantId tenantId);
+
+}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
index 8f0502efbf..fb77ad6987 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
@@ -660,6 +660,15 @@ public class ModelConstants {
public static final String NOTIFICATION_TEMPLATE_NOTIFICATION_TYPE_PROPERTY = "notification_type";
public static final String NOTIFICATION_TEMPLATE_CONFIGURATION_PROPERTY = "configuration";
+ /**
+ * Mobile application settings constants.
+ */
+ public static final String MOBILE_APP_SETTINGS_TABLE_NAME = "mobile_app_settings";
+ public static final String MOBILE_APP_SETTINGS_USE_DEFAULT_APP_PROPERTY = "use_default_app";
+ public static final String MOBILE_APP_SETTINGS_ANDROID_CONFIG_PROPERTY = "android_config";
+ public static final String MOBILE_APP_SETTINGS_IOS_CONFIG_PROPERTY = "ios_config";
+ public static final String MOBILE_APP_SETTINGS_QR_CODE_CONFIG_PROPERTY = "qr_code_config";
+
protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN};
protected static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN), count(JSON_VALUE_COLUMN), max(TS_COLUMN)};
diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java
new file mode 100644
index 0000000000..cd38edd9cb
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java
@@ -0,0 +1,85 @@
+/**
+ * Copyright © 2016-2024 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.dao.model.sql;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.persistence.Column;
+import jakarta.persistence.Convert;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.thingsboard.server.common.data.id.MobileAppSettingsId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.mobile.AndroidConfig;
+import org.thingsboard.server.common.data.mobile.IosConfig;
+import org.thingsboard.server.common.data.mobile.MobileAppSettings;
+import org.thingsboard.server.common.data.mobile.QRCodeConfig;
+import org.thingsboard.server.dao.model.BaseSqlEntity;
+import org.thingsboard.server.dao.model.ModelConstants;
+import org.thingsboard.server.dao.util.mapping.JsonConverter;
+
+import java.util.UUID;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@NoArgsConstructor
+@Entity
+@Table(name = ModelConstants.MOBILE_APP_SETTINGS_TABLE_NAME)
+public class MobileAppSettingsEntity extends BaseSqlEntity {
+
+ @Column(name = ModelConstants.TENANT_ID_COLUMN, columnDefinition = "uuid")
+ protected UUID tenantId;
+
+ @Column(name = ModelConstants.MOBILE_APP_SETTINGS_USE_DEFAULT_APP_PROPERTY)
+ private boolean useDefaultApp;
+
+ @Convert(converter = JsonConverter.class)
+ @Column(name = ModelConstants.MOBILE_APP_SETTINGS_ANDROID_CONFIG_PROPERTY)
+ private JsonNode androidConfig;
+
+ @Convert(converter = JsonConverter.class)
+ @Column(name = ModelConstants.MOBILE_APP_SETTINGS_IOS_CONFIG_PROPERTY)
+ private JsonNode iosConfig;
+
+ @Convert(converter = JsonConverter.class)
+ @Column(name = ModelConstants.MOBILE_APP_SETTINGS_QR_CODE_CONFIG_PROPERTY)
+ private JsonNode qrCodeConfig;
+
+ public MobileAppSettingsEntity(MobileAppSettings mobileAppSettings) {
+ this.setId(mobileAppSettings.getUuidId());
+ this.setCreatedTime(mobileAppSettings.getCreatedTime());
+ this.tenantId = mobileAppSettings.getTenantId().getId();
+ this.useDefaultApp = mobileAppSettings.isUseDefaultApp();
+ this.androidConfig = toJson(mobileAppSettings.getAndroidConfig());
+ this.iosConfig = toJson(mobileAppSettings.getIosConfig());
+ this.qrCodeConfig = toJson(mobileAppSettings.getQrCodeConfig());
+ }
+
+ @Override
+ public MobileAppSettings toData() {
+ MobileAppSettings mobileAppSettings = new MobileAppSettings(new MobileAppSettingsId(getUuid()));
+ mobileAppSettings.setCreatedTime(createdTime);
+ mobileAppSettings.setTenantId(TenantId.fromUUID(tenantId));
+ mobileAppSettings.setUseDefaultApp(useDefaultApp);
+ mobileAppSettings.setAndroidConfig(fromJson(androidConfig, AndroidConfig.class));
+ mobileAppSettings.setIosConfig(fromJson(iosConfig, IosConfig.class));
+ mobileAppSettings.setQrCodeConfig(fromJson(qrCodeConfig, QRCodeConfig.class));
+ return mobileAppSettings;
+ }
+
+}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java
new file mode 100644
index 0000000000..c867b08cc4
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java
@@ -0,0 +1,51 @@
+/**
+ * Copyright © 2016-2024 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.dao.service.validator;
+
+import lombok.AllArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.mobile.AndroidConfig;
+import org.thingsboard.server.common.data.mobile.IosConfig;
+import org.thingsboard.server.common.data.mobile.MobileAppSettings;
+import org.thingsboard.server.common.data.mobile.QRCodeConfig;
+import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.dao.service.DataValidator;
+
+@Component
+@AllArgsConstructor
+public class MobileAppSettingsDataValidator extends DataValidator {
+
+ @Override
+ protected void validateDataImpl(TenantId tenantId, MobileAppSettings mobileAppSettings) {
+ AndroidConfig androidConfig = mobileAppSettings.getAndroidConfig();
+ IosConfig iosConfig = mobileAppSettings.getIosConfig();
+ QRCodeConfig qrCodeConfig = mobileAppSettings.getQrCodeConfig();
+ if (!mobileAppSettings.isUseDefaultApp() && (androidConfig == null || iosConfig == null)) {
+ throw new DataValidationException("Android/ios settings are required to use custom application!");
+ }
+ if (qrCodeConfig == null) {
+ throw new DataValidationException("Qr code configuration is required!");
+ }
+ if (androidConfig != null && androidConfig.isEnabled() && !mobileAppSettings.isUseDefaultApp() &&
+ (androidConfig.getAppPackage() == null || androidConfig.getSha256CertFingerprints() == null)) {
+ throw new DataValidationException("Application package and sha256 cert fingerprints are required for custom android application!");
+ }
+ if (iosConfig != null && iosConfig.isEnabled() && !mobileAppSettings.isUseDefaultApp() && iosConfig.getAppId() == null) {
+ throw new DataValidationException("Application id is required for custom ios application!");
+ }
+ }
+}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppSettingsDao.java
new file mode 100644
index 0000000000..e4286bb949
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppSettingsDao.java
@@ -0,0 +1,61 @@
+/**
+ * Copyright © 2016-2024 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.dao.sql.mobile;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Component;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.mobile.MobileAppSettings;
+import org.thingsboard.server.dao.DaoUtil;
+import org.thingsboard.server.dao.mobile.MobileAppSettingsDao;
+import org.thingsboard.server.dao.model.sql.MobileAppSettingsEntity;
+import org.thingsboard.server.dao.sql.JpaAbstractDao;
+import org.thingsboard.server.dao.util.SqlDao;
+
+import java.util.UUID;
+
+
+@Component
+@Slf4j
+@SqlDao
+public class JpaMobileAppSettingsDao extends JpaAbstractDao implements MobileAppSettingsDao {
+
+ @Autowired
+ private MobileAppSettingsRepository mobileAppSettingsRepository;
+
+
+ @Override
+ public MobileAppSettings findByTenantId(TenantId tenantId) {
+ return DaoUtil.getData(mobileAppSettingsRepository.findByTenantId(tenantId.getId()));
+ }
+
+ @Override
+ public void removeByTenantId(TenantId tenantId) {
+ mobileAppSettingsRepository.deleteByTenantId(tenantId.getId());
+ }
+
+ @Override
+ protected Class getEntityClass() {
+ return MobileAppSettingsEntity.class;
+ }
+
+ @Override
+ protected JpaRepository getRepository() {
+ return mobileAppSettingsRepository;
+ }
+}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java
new file mode 100644
index 0000000000..bb6e62f3fe
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java
@@ -0,0 +1,36 @@
+/**
+ * Copyright © 2016-2024 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.dao.sql.mobile;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.transaction.annotation.Transactional;
+import org.thingsboard.server.dao.model.sql.MobileAppSettingsEntity;
+
+import java.util.UUID;
+
+
+public interface MobileAppSettingsRepository extends JpaRepository {
+
+ MobileAppSettingsEntity findByTenantId(@Param("tenantId") UUID tenantId);
+
+ @Transactional
+ @Modifying
+ @Query("DELETE FROM MobileAppSettingsEntity r WHERE r.tenantId = :tenantId")
+ void deleteByTenantId(@Param("tenantId") UUID tenantId);
+}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java
index 41680f76dd..b17cef53db 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java
@@ -38,6 +38,9 @@ import org.thingsboard.server.dao.device.DeviceProfileService;
import org.thingsboard.server.dao.entity.AbstractCachedEntityService;
import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
+import org.thingsboard.server.dao.mobile.MobileAppSettingsService;
+import org.thingsboard.server.dao.notification.NotificationRequestService;
+import org.thingsboard.server.dao.notification.NotificationRuleService;
import org.thingsboard.server.dao.notification.NotificationSettingsService;
import org.thingsboard.server.dao.service.PaginatedRemover;
import org.thingsboard.server.dao.service.Validator;
@@ -78,6 +81,8 @@ public class TenantServiceImpl extends AbstractCachedEntityService existsTenantCache;
@@ -161,6 +166,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService {
+ return this.http.get(`/api/mobile/app/settings`, defaultHttpOptionsFromConfig(config));
+ }
+
+ public saveMobileAppSettings(mobileAppSettings: MobileAppQRCodeSettings, config?: RequestConfig): Observable {
+ return this.http.post(`/api/mobile/app/settings`, mobileAppSettings, defaultHttpOptionsFromConfig(config));
+ }
+
+ public getMobileAppDeepLink( config?: RequestConfig): Observable {
+ return this.http.get(`/api/mobile/deepLink`, defaultHttpOptionsFromConfig(config));
+ }
+
+}
diff --git a/ui-ngx/src/app/core/services/menu.service.ts b/ui-ngx/src/app/core/services/menu.service.ts
index 1f2d76a8b8..ffb5b5f0a6 100644
--- a/ui-ngx/src/app/core/services/menu.service.ts
+++ b/ui-ngx/src/app/core/services/menu.service.ts
@@ -245,6 +245,14 @@ export class MenuService {
path: '/settings/queues',
icon: 'swap_calls'
},
+ {
+ id: 'mobile_app_settings',
+ name: 'admin.mobile-app.mobile-app',
+ fullName: 'admin.mobile-app.mobile-app',
+ type: 'link',
+ path: '/settings/mobile-app',
+ icon: 'smartphone'
+ }
]
},
{
diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts
index 70b04ee4eb..d7238c450d 100644
--- a/ui-ngx/src/app/core/services/utils.service.ts
+++ b/ui-ngx/src/app/core/services/utils.service.ts
@@ -30,7 +30,7 @@ import {
guid,
hashCode,
isDefined,
- isDefinedAndNotNull,
+ isDefinedAndNotNull, isNotEmptyStr,
isString,
isUndefined,
objToBase64,
@@ -386,8 +386,7 @@ export class UtilsService {
this.window.performance.now() : Date.now();
}
- public getQueryParam(name: string): string {
- const url = this.window.location.href;
+ public getQueryParam(name: string, url = this.window.location.href): string {
name = name.replace(/[\[\]]/g, '\\$&');
const regex = new RegExp('[?&]' + name + '(=([^]*)|&|#|$)');
const results = regex.exec(url);
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widget.models.ts
new file mode 100644
index 0000000000..4027e0445b
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widget.models.ts
@@ -0,0 +1,50 @@
+///
+/// Copyright © 2016-2024 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.
+///
+
+import {
+ AndroidConfig,
+ BadgePosition,
+ BadgeStyle,
+ IosConfig,
+ QRCodeConfig
+} from '@shared/models/mobile-app.models';
+
+export type MobileAppQrCodeWidgetSettings = {
+ useDefaultApp: boolean;
+ androidConfig: AndroidConfig;
+ iosConfig: IosConfig;
+ qrCodeConfig: Omit;
+}
+
+export const mobileAppQrCodeWidgetDefaultSettings: MobileAppQrCodeWidgetSettings = {
+ useDefaultApp: true,
+ androidConfig: {
+ enabled: true,
+ appPackage: '',
+ sha256CertFingerprints: ''
+ },
+ iosConfig: {
+ enabled: true,
+ appId: ''
+ },
+ qrCodeConfig: {
+ badgeEnabled: true,
+ badgeStyle: BadgeStyle.ORIGINAL,
+ badgePosition: BadgePosition.RIGHT,
+ qrCodeLabelEnabled: true,
+ qrCodeLabel: 'Scan to connect or download mobile app'
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html
new file mode 100644
index 0000000000..35092807dc
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html
@@ -0,0 +1,41 @@
+
+admin.mobile-app.connect-mobile-app
+
+
+
+ {{ mobileAppSettings.qrCodeConfig.qrCodeLabel }}
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss
new file mode 100644
index 0000000000..6d229abb0a
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss
@@ -0,0 +1,57 @@
+/**
+ * Copyright © 2016-2024 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.
+ */
+
+@import '../../../../../../scss/constants';
+
+:host {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background-color: transparent;
+}
+
+.tb-title {
+ padding-bottom: 12px;
+ align-self: start;
+ font-weight: 600;
+ font-size: 20px;
+ line-height: 24px;
+ letter-spacing: 0.1px;
+ color: rgba(0, 0, 0, 0.76);
+
+ @media #{$mat-md-lg} {
+ font-weight: 500;
+ font-size: 14px;
+ line-height: 20px;
+ letter-spacing: 0.25px;
+ }
+}
+
+.tb-qrcode-label {
+ font-size: 14px;
+ color: rgba(0, 0, 0, 0.54);
+}
+
+.tb-qrcode {
+ box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.08);
+ border-radius: 6px;
+}
+
+.tb-badge-container {
+ @media #{$mat-md} {
+ display: none;
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts
new file mode 100644
index 0000000000..c7557c75c2
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts
@@ -0,0 +1,114 @@
+///
+/// Copyright © 2016-2024 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.
+///
+
+import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
+import { PageComponent } from '@shared/components/page.component';
+import { AppState } from '@core/core.state';
+import { Store } from '@ngrx/store';
+import { BadgePosition, BadgeStyle, badgeStyleURLMap, MobileAppQRCodeSettings } from '@shared/models/mobile-app.models';
+import { MobileAppService } from '@core/http/mobile-app.service';
+import { WidgetContext } from '@home/models/widget-component.models';
+import { UtilsService } from '@core/services/utils.service';
+import { interval, mergeMap, Observable, Subject, takeUntil } from 'rxjs';
+import { MINUTE } from '@shared/models/time/time.models';
+import { coerceBoolean } from '@shared/decorators/coercion';
+import { MobileAppQrCodeWidgetSettings } from '@home/components/widget/lib/cards/mobile-app-qr-code-widget.models';
+
+@Component({
+ selector: 'tb-mobile-app-qrcode-widget',
+ templateUrl: './mobile-app-qrcode-widget.component.html',
+ styleUrls: ['./mobile-app-qrcode-widget.component.scss']
+})
+export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnInit, AfterViewInit, OnDestroy {
+
+ @Input()
+ ctx: WidgetContext;
+
+ @Input()
+ @coerceBoolean()
+ previewMode: boolean;
+
+ @Input()
+ set mobileAppSettings(settings: MobileAppQRCodeSettings | MobileAppQrCodeWidgetSettings) {
+ if (settings) {
+ this.mobileAppSettingsValue = settings;
+ }
+ };
+
+ get mobileAppSettings() {
+ return this.mobileAppSettingsValue;
+ }
+
+ @ViewChild('canvas', {static: false}) canvasRef: ElementRef;
+
+ private readonly destroy$ = new Subject();
+
+ badgeStyle = BadgeStyle;
+ badgePosition = BadgePosition;
+ badgeStyleURLMap = badgeStyleURLMap;
+
+ private mobileAppSettingsValue: MobileAppQRCodeSettings | MobileAppQrCodeWidgetSettings;
+ private deepLinkTTL: number;
+
+ constructor(protected store: Store,
+ protected cd: ChangeDetectorRef,
+ private mobileAppService: MobileAppService,
+ private utilsService: UtilsService) {
+ super(store);
+ }
+
+ ngOnInit(): void {
+ if (this.ctx) {
+ this.mobileAppSettings = this.ctx.settings;
+ } else {
+ this.mobileAppService.getMobileAppSettings().subscribe((settings => {
+ this.mobileAppSettings = settings;
+ this.cd.detectChanges();
+ }));
+ }
+ }
+
+ ngAfterViewInit(): void {
+ this.getMobileAppDeepLink().subscribe(link => {
+ this.deepLinkTTL = Number(this.utilsService.getQueryParam('ttl', link)) * MINUTE;
+ this.updateQRCode(link);
+ interval(this.deepLinkTTL).pipe(
+ takeUntil(this.destroy$),
+ mergeMap(() => this.getMobileAppDeepLink())
+ ).subscribe(link => this.updateQRCode(link));
+ });
+ }
+
+ ngOnDestroy() {
+ super.ngOnDestroy();
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ getMobileAppDeepLink(): Observable {
+ return this.mobileAppService.getMobileAppDeepLink();
+ }
+
+ updateQRCode(link: string) {
+ import('qrcode').then((QRCode) => {
+ QRCode.toCanvas(this.canvasRef.nativeElement, link, { width: 100 });
+ // QRCode.toCanvas(this.canvasRef.nativeElement, link);
+ // this.canvasRef.nativeElement.style.width = '4.6vw';
+ // this.canvasRef.nativeElement.style.height = '4.6vw';
+ });
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html
new file mode 100644
index 0000000000..cdef956141
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html
@@ -0,0 +1,130 @@
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.scss
new file mode 100644
index 0000000000..d0a2c9ac16
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.scss
@@ -0,0 +1,19 @@
+/**
+ * Copyright © 2016-2024 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.
+ */
+
+.tb-fixed-width {
+ min-width: 230px;
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts
new file mode 100644
index 0000000000..662a9d3dd3
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts
@@ -0,0 +1,156 @@
+///
+/// Copyright © 2016-2024 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.
+///
+
+import { Component } from "@angular/core";
+import { UntypedFormBuilder, UntypedFormGroup, Validators } from "@angular/forms";
+import { WidgetSettings, WidgetSettingsComponent } from "@shared/models/widget.models";
+import { AppState } from '@core/core.state';
+import { Store } from "@ngrx/store";
+import { badgePositionTranslationsMap, badgeStyleTranslationsMap } from '@shared/models/mobile-app.models';
+import { mobileAppQrCodeWidgetDefaultSettings } from '@home/components/widget/lib/cards/mobile-app-qr-code-widget.models';
+
+@Component({
+ selector: 'tb-mobile-app-qr-code-widget-settings',
+ templateUrl: './mobile-app-qr-code-widget-settings.component.html',
+ styleUrls: ['/mobile-app-qr-code-widget-settings.component.scss', './../widget-settings.scss']
+})
+export class MobileAppQrCodeWidgetSettingsComponent extends WidgetSettingsComponent {
+
+ mobileAppQRCodeWidgetSettingsForm: UntypedFormGroup;
+
+ badgePositionTranslationsMap = badgePositionTranslationsMap;
+ badgeStyleTranslationsMap = badgeStyleTranslationsMap;
+
+ constructor(protected store: Store,
+ private fb: UntypedFormBuilder) {
+ super(store);
+ }
+
+ protected settingsForm(): UntypedFormGroup {
+ return this.mobileAppQRCodeWidgetSettingsForm;
+ }
+
+ protected defaultSettings(): WidgetSettings {
+ return {...mobileAppQrCodeWidgetDefaultSettings};
+ }
+
+ protected onSettingsSet(settings: WidgetSettings) {
+ this.mobileAppQRCodeWidgetSettingsForm = this.fb.group({
+ useDefaultApp: [settings.useDefaultApp, []],
+ androidConfig: this.fb.group({
+ enabled: [settings.androidConfig.enabled, []],
+ appPackage: [settings.androidConfig.appPackage, []],
+ sha256CertFingerprints: [settings.androidConfig.sha256CertFingerprints, []]
+ }),
+ iosConfig: this.fb.group({
+ enabled: [settings.iosConfig.enabled, []],
+ appId: [settings.iosConfig.appId, []]
+ }),
+ qrCodeConfig: this.fb.group({
+ badgeEnabled: [settings.qrCodeConfig.badgeEnabled, []],
+ badgeStyle: [{value: settings.qrCodeConfig.badgeStyle, disabled: true}, []],
+ badgePosition: [{value: settings.qrCodeConfig.badgePosition, disabled: true}, []],
+ qrCodeLabelEnabled: [settings.qrCodeConfig.qrCodeLabelEnabled, []],
+ qrCodeLabel: [settings.qrCodeConfig.qrCodeLabel, []]
+ })
+ });
+ }
+
+ protected validatorTriggers(): string[] {
+ return ['useDefaultApp', 'androidConfig.enabled', 'iosConfig.enabled', 'qrCodeConfig.badgeEnabled', 'qrCodeConfig.qrCodeLabelEnabled'];
+ }
+
+ protected updateValidators(emitEvent: boolean) {
+ const useDefaultApp = this.mobileAppQRCodeWidgetSettingsForm.get('useDefaultApp').value;
+ const androidEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.enabled').value;
+ const iosEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.enabled').value;
+ const badgeEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').value;
+ const qrCodeLabelEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabelEnabled').value;
+
+ if (useDefaultApp) {
+ this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').clearValidators();
+ this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').clearValidators();
+ this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').clearValidators();
+ } else {
+ this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').setValidators([Validators.required]);
+ this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').setValidators([Validators.required]);
+ this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').setValidators([Validators.required]);
+ }
+
+ if (androidEnabled) {
+ if (!useDefaultApp) {
+ this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').setValidators([Validators.required]);
+ this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').setValidators([Validators.required]);
+ }
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').enable({emitEvent: false});
+ if (badgeEnabled) {
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').enable();
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').enable();
+ }
+ } else {
+ if (!useDefaultApp) {
+ this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').clearValidators();
+ this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').clearValidators();
+ }
+ if (!iosEnabled) {
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').disable({emitEvent: false});
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').disable();
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').disable();
+ }
+ }
+
+ if (iosEnabled) {
+ if (!useDefaultApp) {
+ this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').setValidators([Validators.required]);
+ }
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').enable({emitEvent: false});
+ if (badgeEnabled) {
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').enable();
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').enable();
+ }
+ } else {
+ if (!useDefaultApp) {
+ this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').clearValidators();
+ }
+ if (!androidEnabled) {
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').disable({emitEvent: false});
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').disable();
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').disable();
+ }
+ }
+
+ if (badgeEnabled) {
+ if (androidEnabled || iosEnabled) {
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').enable();
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').enable();
+ }
+ } else {
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').disable();
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').disable();
+ }
+
+ if (qrCodeLabelEnabled) {
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabel').enable();
+ } else {
+ this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabel').disable();
+ }
+
+ this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').updateValueAndValidity({emitEvent});
+ this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').updateValueAndValidity({emitEvent});
+ this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').updateValueAndValidity({emitEvent});
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
index c71f0fd658..f093c4c568 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
@@ -354,10 +354,14 @@ import {
import {
RadarChartWidgetSettingsComponent
} from '@home/components/widget/lib/settings/chart/radar-chart-widget-settings.component';
+import {
+ MobileAppQrCodeWidgetSettingsComponent
+} from '@home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component';
@NgModule({
declarations: [
QrCodeWidgetSettingsComponent,
+ MobileAppQrCodeWidgetSettingsComponent,
TimeseriesTableWidgetSettingsComponent,
TimeseriesTableKeySettingsComponent,
TimeseriesTableLatestKeySettingsComponent,
@@ -490,6 +494,7 @@ import {
],
exports: [
QrCodeWidgetSettingsComponent,
+ MobileAppQrCodeWidgetSettingsComponent,
TimeseriesTableWidgetSettingsComponent,
TimeseriesTableKeySettingsComponent,
TimeseriesTableLatestKeySettingsComponent,
@@ -620,6 +625,7 @@ export class WidgetSettingsModule {
export const widgetSettingsComponentsMap: {[key: string]: Type} = {
'tb-qrcode-widget-settings': QrCodeWidgetSettingsComponent,
+ 'tb-mobile-app-qr-code-widget-settings': MobileAppQrCodeWidgetSettingsComponent,
'tb-timeseries-table-widget-settings': TimeseriesTableWidgetSettingsComponent,
'tb-timeseries-table-key-settings': TimeseriesTableKeySettingsComponent,
'tb-timeseries-table-latest-key-settings': TimeseriesTableLatestKeySettingsComponent,
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
index 69185b6dd2..e25f0d3925 100644
--- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
@@ -92,6 +92,7 @@ import { PieChartWidgetComponent } from '@home/components/widget/lib/chart/pie-c
import { BarChartWidgetComponent } from '@home/components/widget/lib/chart/bar-chart-widget.component';
import { PolarAreaWidgetComponent } from '@home/components/widget/lib/chart/polar-area-widget.component';
import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/radar-chart-widget.component';
+import { MobileAppQrcodeWidgetComponent } from '@home/components/widget/lib/mobile-app-qrcode-widget.component';
@NgModule({
declarations:
@@ -112,6 +113,7 @@ import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/rad
NavigationCardsWidgetComponent,
NavigationCardWidgetComponent,
QrCodeWidgetComponent,
+ MobileAppQrcodeWidgetComponent,
MarkdownWidgetComponent,
SelectEntityDialogComponent,
LegendComponent,
@@ -176,6 +178,7 @@ import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/rad
NavigationCardsWidgetComponent,
NavigationCardWidgetComponent,
QrCodeWidgetComponent,
+ MobileAppQrcodeWidgetComponent,
MarkdownWidgetComponent,
LegendComponent,
FlotWidgetComponent,
diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
index 63fb9a14de..f05fc05a25 100644
--- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
+++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
@@ -40,6 +40,7 @@ import { widgetsLibraryRoutes } from '@home/pages/widget/widget-library-routing.
import { RouterTabsComponent } from '@home/components/router-tabs.component';
import { auditLogsRoutes } from '@home/pages/audit-log/audit-log-routing.module';
import { ImageGalleryComponent } from '@shared/components/image/image-gallery.component';
+import { MobileAppSettingsComponent } from '@home/pages/admin/mobile-app-settings.component';
@Injectable()
export class OAuth2LoginProcessingUrlResolver implements Resolve {
@@ -270,6 +271,19 @@ const routes: Routes = [
}
}
},
+ {
+ path: 'mobile-app',
+ component: MobileAppSettingsComponent,
+ canDeactivate: [ConfirmOnExitGuard],
+ data: {
+ auth: [Authority.SYS_ADMIN],
+ title: 'admin.mobile-app.mobile-app',
+ breadcrumb: {
+ label: 'admin.mobile-app.mobile-app',
+ icon: 'smartphone'
+ }
+ }
+ },
{
path: 'security-settings',
redirectTo: '/security-settings/general'
diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts
index 1ab60687a3..f44782e814 100644
--- a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts
+++ b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts
@@ -33,6 +33,8 @@ import { QueueComponent } from '@home/pages/admin/queue/queue.component';
import { RepositoryAdminSettingsComponent } from '@home/pages/admin/repository-admin-settings.component';
import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-admin-settings.component';
import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component';
+import { MobileAppSettingsComponent } from '@home/pages/admin/mobile-app-settings.component';
+import { WidgetComponentsModule } from '@home/components/widget/widget-components.module';
@NgModule({
declarations:
@@ -49,13 +51,15 @@ import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-aut
QueueComponent,
RepositoryAdminSettingsComponent,
AutoCommitAdminSettingsComponent,
- TwoFactorAuthSettingsComponent
+ TwoFactorAuthSettingsComponent,
+ MobileAppSettingsComponent
],
- imports: [
- CommonModule,
- SharedModule,
- HomeComponentsModule,
- AdminRoutingModule
- ]
+ imports: [
+ CommonModule,
+ SharedModule,
+ HomeComponentsModule,
+ AdminRoutingModule,
+ WidgetComponentsModule
+ ]
})
export class AdminModule { }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html
new file mode 100644
index 0000000000..5b1cb710e0
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html
@@ -0,0 +1,161 @@
+
+
+
+
+ admin.mobile-app.mobile-app-qr-code-widget-settings
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss
new file mode 100644
index 0000000000..5f04c04e8f
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2024 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.
+ */
+
+.tb-fixed-width {
+ min-width: 230px;
+}
+
+.tb-qrcode-preview {
+ background-color: rgba(0, 0, 0, 0.04);
+ box-shadow: none;
+
+ .tb-form-panel-title {
+ color: rgba(0, 0, 0, 0.38);
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts
new file mode 100644
index 0000000000..7959b1b800
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts
@@ -0,0 +1,198 @@
+///
+/// Copyright © 2016-2024 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.
+///
+
+import { Component, OnDestroy } from '@angular/core';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { PageComponent } from '@shared/components/page.component';
+import { FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
+import { Subject, takeUntil } from 'rxjs';
+import { MobileAppService } from '@core/http/mobile-app.service';
+import {
+ BadgePosition,
+ badgePositionTranslationsMap,
+ BadgeStyle,
+ badgeStyleTranslationsMap,
+ MobileAppQRCodeSettings
+} from '@shared/models/mobile-app.models';
+import { AuthService } from '@core/auth/auth.service';
+
+@Component({
+ selector: 'tb-mobile-app-settings',
+ templateUrl: './mobile-app-settings.component.html',
+ styleUrls: ['mobile-app-settings.component.scss', './settings-card.scss']
+})
+export class MobileAppSettingsComponent extends PageComponent implements HasConfirmForm, OnDestroy {
+
+ mobileAppSettingsForm: FormGroup;
+
+ mobileAppSettings: MobileAppQRCodeSettings;
+
+ private readonly destroy$ = new Subject();
+
+ badgePositionTranslationsMap = badgePositionTranslationsMap;
+ badgeStyleTranslationsMap = badgeStyleTranslationsMap;
+
+ constructor(protected store: Store,
+ private mobileAppService: MobileAppService,
+ private fb: FormBuilder) {
+ super(store);
+ this.buildMobileAppSettingsForm();
+ this.mobileAppService.getMobileAppSettings()
+ .subscribe(settings => this.processMobileAppSettings(settings));
+ this.mobileAppSettingsForm.get('useDefaultApp').valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(value => {
+ if (value) {
+ this.mobileAppSettingsForm.get('androidConfig.appPackage').clearValidators();
+ this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').clearValidators();
+ this.mobileAppSettingsForm.get('iosConfig.appId').clearValidators();
+ } else {
+ this.mobileAppSettingsForm.get('androidConfig.appPackage').setValidators([Validators.required]);
+ this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').setValidators([Validators.required]);
+ this.mobileAppSettingsForm.get('iosConfig.appId').setValidators([Validators.required]);
+ }
+ this.mobileAppSettingsForm.get('androidConfig.appPackage').updateValueAndValidity();
+ this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').updateValueAndValidity();
+ this.mobileAppSettingsForm.get('iosConfig.appId').updateValueAndValidity();
+ });
+ this.mobileAppSettingsForm.get('androidConfig.enabled').valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(value => {
+ if (!this.mobileAppSettingsForm.get('useDefaultApp').value) {
+ if (value) {
+ this.mobileAppSettingsForm.get('androidConfig.appPackage').setValidators([Validators.required]);
+ this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').setValidators([Validators.required]);
+ } else {
+ this.mobileAppSettingsForm.get('androidConfig.appPackage').clearValidators();
+ this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').clearValidators();
+ }
+ this.mobileAppSettingsForm.get('androidConfig.appPackage').updateValueAndValidity();
+ this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').updateValueAndValidity();
+ }
+ if (this.mobileAppSettingsForm.get('qrCodeConfig.showOnHomePage').value) {
+ if (value) {
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').enable();
+ if (this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').value) {
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable();
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable();
+ }
+ } else {
+ if (!this.mobileAppSettingsForm.get('iosConfig.enabled').value) {
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').disable();
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable();
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable();
+ }
+ }
+ }
+ });
+ this.mobileAppSettingsForm.get('iosConfig.enabled').valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(value => {
+ if (!this.mobileAppSettingsForm.get('useDefaultApp').value) {
+ if (value) {
+ this.mobileAppSettingsForm.get('iosConfig.appId').setValidators([Validators.required]);
+ } else {
+ this.mobileAppSettingsForm.get('iosConfig.appId').clearValidators();
+ }
+ this.mobileAppSettingsForm.get('iosConfig.appId').updateValueAndValidity();
+ }
+ if (this.mobileAppSettingsForm.get('qrCodeConfig.showOnHomePage').value) {
+ if (value) {
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').enable();
+ if (this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').value) {
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable();
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable();
+ }
+ } else {
+ if (!this.mobileAppSettingsForm.get('androidConfig.enabled').value) {
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').disable();
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable();
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable();
+ }
+ }
+ }
+ });
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(value => {
+ if (value) {
+ if (this.mobileAppSettingsForm.get('androidConfig.enabled').value || this.mobileAppSettingsForm.get('iosConfig.enabled').value) {
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable();
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable();
+ }
+ } else {
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable();
+ this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable();
+ }
+ });
+ this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabelEnabled').valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(value => {
+ if (value) {
+ this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').enable();
+ } else {
+ this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').disable();
+ }
+ });
+ }
+
+ ngOnDestroy() {
+ super.ngOnDestroy();
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private buildMobileAppSettingsForm() {
+ this.mobileAppSettingsForm = this.fb.group({
+ useDefaultApp: [true, []],
+ androidConfig: this.fb.group({
+ enabled: [true, []],
+ appPackage: ['', []],
+ sha256CertFingerprints: ['', []]
+ }),
+ iosConfig: this.fb.group({
+ enabled: [true, []],
+ appId: ['', []]
+ }),
+ qrCodeConfig: this.fb.group({
+ showOnHomePage: [true, []],
+ badgeEnabled: [true, []],
+ badgeStyle: [{value: BadgeStyle.ORIGINAL, disabled: true}, []],
+ badgePosition: [{value: BadgePosition.RIGHT, disabled: true}, []],
+ qrCodeLabelEnabled: [true, []],
+ qrCodeLabel: ['', []]
+ })
+ });
+ }
+
+ private processMobileAppSettings(mobileAppSettings: MobileAppQRCodeSettings): void {
+ this.mobileAppSettings = {...mobileAppSettings};
+ this.mobileAppSettingsForm.reset(this.mobileAppSettings);
+ }
+
+ save(): void {
+ this.mobileAppSettings = {...this.mobileAppSettings, ...this.mobileAppSettingsForm.getRawValue()};
+ this.mobileAppService.saveMobileAppSettings(this.mobileAppSettings)
+ .subscribe((settings) => this.processMobileAppSettings(settings));
+ }
+
+ confirmForm(): FormGroup {
+ return this.mobileAppSettingsForm;
+ }
+
+}
diff --git a/ui-ngx/src/app/shared/models/mobile-app.models.ts b/ui-ngx/src/app/shared/models/mobile-app.models.ts
new file mode 100644
index 0000000000..02139bd913
--- /dev/null
+++ b/ui-ngx/src/app/shared/models/mobile-app.models.ts
@@ -0,0 +1,81 @@
+///
+/// Copyright © 2016-2024 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.
+///
+
+import { TenantId } from '@shared/models/id/tenant-id';
+
+export interface MobileAppQRCodeSettings {
+ tenantId: TenantId;
+ useDefaultApp: boolean;
+ androidConfig: AndroidConfig;
+ iosConfig: IosConfig;
+ qrCodeConfig: QRCodeConfig;
+}
+
+export interface AndroidConfig {
+ enabled: boolean;
+ appPackage: string;
+ sha256CertFingerprints: string
+}
+
+export interface IosConfig {
+ enabled: boolean;
+ appId: string;
+}
+
+export interface QRCodeConfig {
+ showOnHomePage: boolean;
+ badgeEnabled: boolean;
+ badgePosition: BadgePosition;
+ badgeStyle: BadgeStyle;
+ qrCodeLabelEnabled: boolean;
+ qrCodeLabel: string;
+}
+
+export interface MobileOSBadgeURL {
+ iOS: string;
+ android: string;
+}
+
+export enum BadgePosition {
+ RIGHT = 'RIGHT',
+ LEFT = 'LEFT'
+}
+
+export const badgePositionTranslationsMap = new Map([
+ [BadgePosition.RIGHT, 'admin.mobile-app.right'],
+ [BadgePosition.LEFT, 'admin.mobile-app.left']
+]);
+
+export enum BadgeStyle {
+ ORIGINAL = 'ORIGINAL',
+ WHITE = 'WHITE'
+}
+
+export const badgeStyleTranslationsMap = new Map([
+ [BadgeStyle.ORIGINAL, 'admin.mobile-app.original'],
+ [BadgeStyle.WHITE, 'admin.mobile-app.white']
+]);
+
+export const badgeStyleURLMap = new Map([
+ [BadgeStyle.ORIGINAL, {
+ iOS: 'assets/android-ios-stores-badges/ios_store_en_black_badge.svg',
+ android: 'assets/android-ios-stores-badges/android_store_en_black_badge.svg'
+ }],
+ [BadgeStyle.WHITE, {
+ iOS: 'assets/android-ios-stores-badges/ios_store_en_white_badge.svg',
+ android: 'assets/android-ios-stores-badges/android_store_en_black_badge.svg'
+ }]
+]);
diff --git a/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg b/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg
new file mode 100644
index 0000000000..2ef3c8fc10
--- /dev/null
+++ b/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg
@@ -0,0 +1,35 @@
+
+
diff --git a/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_black_badge.svg b/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_black_badge.svg
new file mode 100755
index 0000000000..072b425a1a
--- /dev/null
+++ b/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_black_badge.svg
@@ -0,0 +1,46 @@
+
diff --git a/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_white_badge.svg b/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_white_badge.svg
new file mode 100755
index 0000000000..16c0496ca7
--- /dev/null
+++ b/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_white_badge.svg
@@ -0,0 +1,46 @@
+
diff --git a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json
index 85669b8bf4..95e33e211b 100644
--- a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json
+++ b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json
@@ -1143,6 +1143,47 @@
"row": 0,
"col": 0,
"id": "8e71a398-caf5-540d-cec5-6e5dc264343e"
+ },
+ "b9d704a2-f579-dbff-f123-f953d92081fd": {
+ "typeFullFqn": "system.home_page_widgets.home_mobile_app_qr_code",
+ "type": "static",
+ "sizeX": 6,
+ "sizeY": 3,
+ "config": {
+ "datasources": [
+ {
+ "type": "static",
+ "name": "function",
+ "dataKeys": [
+ {
+ "name": "f(x)",
+ "type": "function",
+ "label": "Random",
+ "color": "#2196f3",
+ "settings": {},
+ "_hash": 0.15479322438769105,
+ "funcBody": "var value = prevValue + Math.random() * 100 - 50;\nvar multiplier = Math.pow(10, 2 || 0);\nvar value = Math.round(value * multiplier) / multiplier;\nif (value < -1000) {\n\tvalue = -1000;\n} else if (value > 1000) {\n\tvalue = 1000;\n}\nreturn value;"
+ }
+ ]
+ }
+ ],
+ "timewindow": {
+ "realtime": {
+ "timewindowMs": 60000
+ }
+ },
+ "showTitle": false,
+ "backgroundColor": "rgb(255, 255, 255)",
+ "color": "rgba(0, 0, 0, 0.87)",
+ "padding": "8px",
+ "settings": {},
+ "title": "Mobile app QR code",
+ "dropShadow": false,
+ "enableFullscreen": false
+ },
+ "row": 0,
+ "col": 0,
+ "id": "b9d704a2-f579-dbff-f123-f953d92081fd"
}
},
"states": {
@@ -1169,7 +1210,7 @@
},
"a23185ad-dc46-806c-0e50-5b21fb080ace": {
"sizeX": 35,
- "sizeY": 58,
+ "sizeY": 42,
"row": 0,
"col": 85,
"mobileHide": true
@@ -1212,6 +1253,14 @@
"row": 0,
"col": 43,
"mobileOrder": 0
+ },
+ "b9d704a2-f579-dbff-f123-f953d92081fd": {
+ "sizeX": 35,
+ "sizeY": 16,
+ "row": 42,
+ "col": 85,
+ "mobileOrder": 5,
+ "mobileHeight": 7
}
},
"gridSettings": {
@@ -1416,4 +1465,4 @@
}
},
"name": "Tenant Administrator Home Page"
-}
\ No newline at end of file
+}
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json
index e16dcee50b..df182dc90f 100644
--- a/ui-ngx/src/assets/locale/locale.constant-en_US.json
+++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json
@@ -419,6 +419,33 @@
"no-auto-commit-entities-prompt": "No entities configured for auto-commit",
"delete-auto-commit-settings-title": "Are you sure you want to delete auto-commit settings?",
"delete-auto-commit-settings-text": "Be careful, after the confirmation the auto-commit settings will be removed and auto-commit will be disabled for all entities.",
+ "mobile-app": {
+ "mobile-app": "Mobile app",
+ "mobile-app-qr-code-widget-settings": "Mobile app QR code widget settings",
+ "applications": "Applications",
+ "default": "Default",
+ "custom": "Custom",
+ "android": "Android",
+ "app-package-name": "App package name",
+ "app-package-name-required": "App package name is required",
+ "sha256-certificate-fingerprints": "SHA256 certificate fingerprints",
+ "sha256-certificate-fingerprints-required": "SHA256 certificate fingerprints is required",
+ "ios": "iOS",
+ "app-id": "App ID",
+ "app-id-required": "App ID is required",
+ "appearance-on-home-page": "Appearance on Home page",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "badges": "Badges",
+ "original": "Original",
+ "white": "White",
+ "label": "Label",
+ "right": "Right",
+ "left": "Left",
+ "set": "Set",
+ "preview": "Preview",
+ "connect-mobile-app": "Connect mobile app"
+ },
"2fa": {
"2fa": "Two-factor authentication",
"available-providers": "Available providers",
diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss
index 1f9ca93c88..fa1eab0ece 100644
--- a/ui-ngx/src/form.scss
+++ b/ui-ngx/src/form.scss
@@ -257,9 +257,15 @@
&.row {
flex-direction: row;
}
+ &.row-reverse {
+ flex-direction: row-reverse;
+ }
&.column {
flex-direction: column;
}
+ &.center {
+ justify-content: center;
+ }
&.flex-start {
justify-content: flex-start;
}
@@ -278,6 +284,12 @@
&.fill-width {
width: 100%;
}
+ &.fill-height {
+ height: 100%;
+ }
+ &.shrink {
+ flex: 0;
+ }
}
.tb-form-panel, .tb-form-row {