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..5465f750e5 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.mobile_app_qr_code"
]
}
\ 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..2e3600d05b
--- /dev/null
+++ b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json
@@ -0,0 +1,21 @@
+{
+ "fqn": "home_page_widgets.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 self.ctx.$scope.mobileAppQrcodeWidget.ngOnInit();\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.mobileAppQrcodeWidget.ngOnDestroy();\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\":\"HTML Card\",\"dropShadow\":true}"
+ },
+ "tags": null
+}
\ No newline at end of file
diff --git a/ui-ngx/src/app/core/http/mobile-app.service.ts b/ui-ngx/src/app/core/http/mobile-app.service.ts
new file mode 100644
index 0000000000..75d5f337bc
--- /dev/null
+++ b/ui-ngx/src/app/core/http/mobile-app.service.ts
@@ -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.
+///
+
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
+import { Observable } from 'rxjs';
+import { MobileAppSettings } from '@shared/models/mobile-app.models';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class MobileAppService {
+
+ constructor(
+ private http: HttpClient
+ ) {
+ }
+
+ public getMobileAppSettings(config?: RequestConfig): Observable {
+ return this.http.get(`/api/mobile/app/settings`, defaultHttpOptionsFromConfig(config));
+ }
+
+ public saveMobileAppSettings(mobileAppSettings: MobileAppSettings, 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/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..53f95cb57e
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html
@@ -0,0 +1,40 @@
+
+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..1d088ec482
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss
@@ -0,0 +1,56 @@
+/**
+ * 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 {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background-color: transparent;
+}
+
+.tb-title {
+ 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-lt-lg} {
+ 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..c9c8541613
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts
@@ -0,0 +1,111 @@
+///
+/// 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, MobileAppSettings } 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';
+
+@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: MobileAppSettings) {
+ 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: MobileAppSettings;
+ private deepLinkTTL: number;
+
+ constructor(protected store: Store,
+ protected cd: ChangeDetectorRef,
+ private mobileAppService: MobileAppService,
+ private utilsService: UtilsService) {
+ super(store);
+ }
+
+ ngOnInit(): void {
+ if (this.ctx) {
+ this.ctx.$scope.mobileAppQrcodeWidget = this;
+ }
+ if (!this.mobileAppSettings) {
+ 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: 125});
+ });
+ }
+
+}
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..c342b68d1f
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts
@@ -0,0 +1,197 @@
+///
+/// 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,
+ MobileAppSettings
+} from '@shared/models/mobile-app.models';
+
+@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: MobileAppSettings;
+
+ private readonly destroy$ = new Subject();
+
+ badgePositionTranslationsMap = badgePositionTranslationsMap;
+ badgeStyleTranslationsMap = badgeStyleTranslationsMap;
+
+ constructor(protected store: Store,
+ private mobileAppService: MobileAppService,
+ public 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: MobileAppSettings): 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..0787f17747
--- /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 MobileAppSettings {
+ 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..02b88dab6f
--- /dev/null
+++ b/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg
@@ -0,0 +1,22 @@
+
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..d850e640b7 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,49 @@
"row": 0,
"col": 0,
"id": "8e71a398-caf5-540d-cec5-6e5dc264343e"
+ },
+ "b9d704a2-f579-dbff-f123-f953d92081fd": {
+ "typeFullFqn": "system.home_page_widgets.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": {
+ "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": "HTML Card",
+ "dropShadow": false
+ },
+ "row": 0,
+ "col": 0,
+ "id": "b9d704a2-f579-dbff-f123-f953d92081fd"
}
},
"states": {
@@ -1169,7 +1212,7 @@
},
"a23185ad-dc46-806c-0e50-5b21fb080ace": {
"sizeX": 35,
- "sizeY": 58,
+ "sizeY": 42,
"row": 0,
"col": 85,
"mobileHide": true
@@ -1212,6 +1255,12 @@
"row": 0,
"col": 43,
"mobileOrder": 0
+ },
+ "b9d704a2-f579-dbff-f123-f953d92081fd": {
+ "sizeX": 35,
+ "sizeY": 16,
+ "row": 42,
+ "col": 85
}
},
"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 b13d169cdc..1a50f9f62b 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..a692994144 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,15 @@
&.fill-width {
width: 100%;
}
+ &.fill-height {
+ height: 100%;
+ }
+ &.shrink {
+ flex: 0;
+ }
+ &.no-gap {
+ gap: 0;
+ }
}
.tb-form-panel, .tb-form-row {