diff --git a/.gitmodules b/.gitmodules
index e72cd9aa..46aa7d0b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -27,3 +27,7 @@
path = 3rdparty/tidy-html5
url = https://github.com/htacg/tidy-html5.git
ignore = dirty
+[submodule "3rdparty/zxing-cpp"]
+ path = 3rdparty/zxing-cpp
+ url = https://github.com/nu-book/zxing-cpp.git
+ ignore = dirty
diff --git a/3rdparty/zxing-cpp b/3rdparty/zxing-cpp
new file mode 160000
index 00000000..a920817b
--- /dev/null
+++ b/3rdparty/zxing-cpp
@@ -0,0 +1 @@
+Subproject commit a920817b6fe0508cc4aca9003003c2812a78e935
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 46db8e97..64eee5ff 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -364,6 +364,8 @@ set(COMMON_SOURCES
${APP_SRC_DIR}/pluginversionmanager.cpp
${APP_SRC_DIR}/connectioninfolistmodel.cpp
${APP_SRC_DIR}/pluginversionmanager.cpp
+ ${APP_SRC_DIR}/linkdevicemodel.cpp
+ ${APP_SRC_DIR}/qrcodescannermodel.cpp
)
set(COMMON_HEADERS
@@ -436,6 +438,8 @@ set(COMMON_HEADERS
${APP_SRC_DIR}/pttlistener.h
${APP_SRC_DIR}/crashreportclient.h
${APP_SRC_DIR}/crashreporter.h
+ ${APP_SRC_DIR}/linkdevicemodel.h
+ ${APP_SRC_DIR}/qrcodescannermodel.h
)
# For libavutil/avframe.
@@ -678,6 +682,15 @@ list(APPEND CLIENT_LINK_DIRS ${tidy_BINARY_DIR}/Release)
list(APPEND CLIENT_INCLUDE_DIRS ${tidy_SOURCE_DIR}/include)
list(APPEND CLIENT_LIBS tidy-static)
+# ZXing-cpp configuration
+set(BUILD_EXAMPLES OFF CACHE BOOL "")
+set(BUILD_BLACKBOX_TESTS OFF CACHE BOOL "")
+add_subdirectory(3rdparty/zxing-cpp)
+
+# Add ZXing-cpp to includes and libraries
+list(APPEND CLIENT_INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/3rdparty/zxing-cpp/core/src)
+list(APPEND CLIENT_LIBS ZXing)
+
# common executable sources
qt_add_executable(
${PROJECT_NAME}
diff --git a/src/app/linkdevicemodel.cpp b/src/app/linkdevicemodel.cpp
new file mode 100644
index 00000000..0cc3f2c3
--- /dev/null
+++ b/src/app/linkdevicemodel.cpp
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2025-2025 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "linkdevicemodel.h"
+#include "lrcinstance.h"
+#include "api/accountmodel.h"
+
+#include "api/account.h"
+
+using namespace lrc::api::account;
+
+LinkDeviceModel::LinkDeviceModel(LRCInstance* lrcInstance, QObject* parent)
+ : QObject(parent)
+ , lrcInstance_(lrcInstance)
+{
+ set_deviceAuthState(static_cast(DeviceAuthState::INIT));
+ connect(&lrcInstance_->accountModel(),
+ &lrc::api::AccountModel::addDeviceStateChanged,
+ this,
+ [this](const QString& accountId,
+ uint32_t operationId,
+ int state,
+ const MapStringString& details) {
+ if (operationId != operationId_)
+ return;
+
+ auto deviceState = static_cast(state);
+
+ switch (deviceState) {
+ case DeviceAuthState::CONNECTING:
+ handleConnectingSignal();
+ break;
+ case DeviceAuthState::AUTHENTICATING:
+ handleAuthenticatingSignal(Utils::mapStringStringToVariantMap(details));
+ break;
+ case DeviceAuthState::IN_PROGRESS:
+ handleInProgressSignal();
+ break;
+ case DeviceAuthState::DONE:
+ handleDoneSignal(Utils::mapStringStringToVariantMap(details));
+ break;
+ default:
+ break;
+ }
+ });
+}
+
+void
+LinkDeviceModel::addDevice(const QString& token)
+{
+ set_tokenErrorMessage("");
+ auto errorMessage = QObject::tr(
+ "New device identifier is not recognized.\nPlease follow above instruction.");
+
+ if (!token.startsWith("jami-auth://") || (token.length() != 59)) {
+ set_tokenErrorMessage(errorMessage);
+ return;
+ }
+
+ int32_t result = lrcInstance_->accountModel().addDevice(lrcInstance_->getCurrentAccountInfo().id,
+ token);
+ if (result > 0) {
+ operationId_ = result;
+ } else {
+ set_tokenErrorMessage(errorMessage);
+ }
+}
+
+void
+LinkDeviceModel::handleConnectingSignal()
+{
+ set_deviceAuthState(static_cast(DeviceAuthState::CONNECTING));
+}
+
+void
+LinkDeviceModel::handleAuthenticatingSignal(const QVariantMap& details)
+{
+ QString peerAddress = details.value("peer_address").toString();
+ set_ipAddress(peerAddress);
+ set_deviceAuthState(static_cast(DeviceAuthState::AUTHENTICATING));
+}
+
+void
+LinkDeviceModel::handleInProgressSignal()
+{
+ set_deviceAuthState(static_cast(DeviceAuthState::IN_PROGRESS));
+}
+
+void
+LinkDeviceModel::handleDoneSignal(const QVariantMap& details)
+{
+ QString errorString = details.value("error").toString();
+ if (!errorString.isEmpty() && errorString != "none") {
+ auto error = mapLinkDeviceError(errorString.toStdString());
+ set_linkDeviceError(getLinkDeviceString(error));
+ set_deviceAuthState(static_cast(DeviceAuthState::DONE));
+ } else {
+ set_deviceAuthState(static_cast(DeviceAuthState::DONE));
+ }
+}
+
+void
+LinkDeviceModel::confirmAddDevice()
+{
+ handleInProgressSignal();
+ lrcInstance_->accountModel().confirmAddDevice(lrcInstance_->getCurrentAccountInfo().id,
+ operationId_);
+}
+
+void
+LinkDeviceModel::cancelAddDevice()
+{
+ handleInProgressSignal();
+ lrcInstance_->accountModel().cancelAddDevice(lrcInstance_->getCurrentAccountInfo().id,
+ operationId_);
+}
+
+void
+LinkDeviceModel::reset()
+{
+ set_deviceAuthState(static_cast(DeviceAuthState::INIT));
+
+ set_linkDeviceError("");
+ set_ipAddress("");
+ set_tokenErrorMessage("");
+}
diff --git a/src/app/linkdevicemodel.h b/src/app/linkdevicemodel.h
new file mode 100644
index 00000000..bf27d445
--- /dev/null
+++ b/src/app/linkdevicemodel.h
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2025-2025 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include "api/account.h"
+
+#include "qmladapterbase.h"
+#include "qtutils.h"
+
+#include
+#include
+#include
+
+class LRCInstance;
+
+class LinkDeviceModel : public QObject
+{
+ Q_OBJECT
+ QML_PROPERTY(QString, tokenErrorMessage);
+ QML_PROPERTY(QString, linkDeviceError);
+ QML_PROPERTY(int, deviceAuthState);
+ QML_PROPERTY(QString, ipAddress);
+
+public:
+ explicit LinkDeviceModel(LRCInstance* lrcInstance, QObject* parent = nullptr);
+
+ Q_INVOKABLE void addDevice(const QString& token);
+
+ Q_INVOKABLE void confirmAddDevice();
+ Q_INVOKABLE void cancelAddDevice();
+ Q_INVOKABLE void reset();
+
+private:
+ bool checkNewStateValidity(lrc::api::account::DeviceAuthState newState) const;
+ void handleConnectingSignal();
+ void handleAuthenticatingSignal(const QVariantMap& details);
+ void handleInProgressSignal();
+ void handleDoneSignal(const QVariantMap& details);
+
+ LRCInstance* lrcInstance_ = nullptr;
+ uint32_t operationId_;
+};
diff --git a/src/app/net/jami/Constants/JamiStrings.qml b/src/app/net/jami/Constants/JamiStrings.qml
index d848e849..328f1bb3 100644
--- a/src/app/net/jami/Constants/JamiStrings.qml
+++ b/src/app/net/jami/Constants/JamiStrings.qml
@@ -74,7 +74,7 @@ Item {
property string scanToImportAccount: qsTr("Scan this QR code on your other device to proceed with importing your account.")
property string waitingForToken: qsTr("Please wait…")
property string scanQRCode: qsTr("Scan QR code")
- property string connectingToDevice: qsTr("Action required.\nPlease confirm account on your old device.")
+ property string connectingToDevice: qsTr("Action required.\nPlease confirm account on the source device.")
property string confirmAccountImport: qsTr("Authenticating device")
property string transferringAccount: qsTr("Transferring account…")
property string cantScanQRCode: qsTr("If you are unable to scan the QR code, enter this token on your other device to proceed.")
@@ -600,12 +600,17 @@ Item {
property string enterAccountPassword: qsTr("Enter account password")
property string enterPasswordPinCode: qsTr("This account is password encrypted, enter the password to generate a PIN code.")
property string addDevice: qsTr("Add Device")
- property string pinExpired: qsTr("PIN code has expired.")
- property string onAnotherDevice: qsTr("On another device")
- property string onAnotherDeviceInstruction: qsTr("Install and launch Jami, select “Import from another device” and scan the QR code.")
property string linkNewDevice: qsTr("Link new device")
- property string linkingInstructions: qsTr("In Jami, scan the QR code or manually enter the PIN code.")
- property string pinValidity: qsTr("The PIN code will expire in: ")
+ property string linkDeviceConnecting: qsTr("Connecting to your new device…")
+ property string linkDeviceInProgress: qsTr("The export account operation to the new device is in progress.\nPlease confirm the import on the new device.")
+ property string linkDeviceScanQR: qsTr("On the new device, initiate a new account.\nSelect Add account -> Connect from another device.\nWhen ready, scan the QR code.")
+ property string linkDeviceEnterManually: qsTr("Alternatively you could enter the authentication code manually.")
+ property string linkDeviceEnterCodePlaceholder: qsTr("Enter authentication code")
+ property string linkDeviceAllSet: qsTr("You are all set!\nYour account is successfully imported on the new device!")
+ property string linkDeviceFoundAddress: qsTr("New device found at address below. Is that you?\nClicking on confirm will continue transfering account.")
+ property string linkDeviceNewDeviceIP: qsTr("New device IP address: %1")
+ property string linkDeviceCloseWarningTitle: qsTr("Do you want to exit?")
+ property string linkDeviceCloseWarningMessage: qsTr("Exiting will cancel the import account operation.")
// PasswordDialog
property string enterPassword: qsTr("Enter password")
diff --git a/src/app/qmlregister.cpp b/src/app/qmlregister.cpp
index de94474e..eacff9e2 100644
--- a/src/app/qmlregister.cpp
+++ b/src/app/qmlregister.cpp
@@ -62,6 +62,8 @@
#include "pluginlistpreferencemodel.h"
#include "preferenceitemlistmodel.h"
#include "wizardviewstepmodel.h"
+#include "linkdevicemodel.h"
+#include "qrcodescannermodel.h"
#include "api/peerdiscoverymodel.h"
#include "api/codecmodel.h"
@@ -185,6 +187,12 @@ registerTypes(QQmlEngine* engine,
QQmlEngine::setObjectOwnership(wizardViewStepModel, QQmlEngine::CppOwnership);
REG_QML_SINGLETON(REG_MODEL, "WizardViewStepModel", CREATE(wizardViewStepModel));
+ // LinkDeviceModel
+ auto linkdevicemodel = new LinkDeviceModel(lrcInstance);
+ qApp->setProperty("LinkDeviceModel", QVariant::fromValue(linkdevicemodel));
+ QQmlEngine::setObjectOwnership(linkdevicemodel, QQmlEngine::CppOwnership);
+ REG_QML_SINGLETON(REG_MODEL, "LinkDeviceModel", CREATE(linkdevicemodel));
+
// Register app-level objects that are used by QML created objects.
// These MUST be set prior to loading the initial QML file, in order to
// be available to the QML adapter class factory creation methods.
@@ -195,6 +203,7 @@ registerTypes(QQmlEngine* engine,
qApp->setProperty("PreviewEngine", QVariant::fromValue(previewEngine));
// qml adapter registration
+ QML_REGISTERSINGLETON_TYPE(NS_HELPERS, QRCodeScannerModel);
QML_REGISTERSINGLETON_TYPE(NS_HELPERS, AvatarRegistry);
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, AccountAdapter);
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, CallAdapter);
diff --git a/src/app/qrcodescannermodel.cpp b/src/app/qrcodescannermodel.cpp
new file mode 100644
index 00000000..b1159fb0
--- /dev/null
+++ b/src/app/qrcodescannermodel.cpp
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2025-2025 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "qrcodescannermodel.h"
+
+#include
+#include
+#include
+
+#include
+
+QRCodeScannerModel::QRCodeScannerModel(QObject* parent)
+ : QObject(parent)
+{}
+
+QString
+QRCodeScannerModel::scanImage(const QImage& image)
+{
+ if (image.isNull())
+ return QString();
+
+ // Convert QImage to grayscale and get raw data
+ QImage grayImage = image.convertToFormat(QImage::Format_Grayscale8);
+ int width = grayImage.width();
+ int height = grayImage.height();
+
+ try {
+ // Create ZXing image
+ ZXing::ImageView imageView(grayImage.bits(), width, height, ZXing::ImageFormat::Lum);
+
+ // Configure reader
+ ZXing::ReaderOptions options;
+ options.setTryHarder(true);
+ options.setTryRotate(true);
+ options.setFormats(ZXing::BarcodeFormat::QRCode);
+
+ // Try to detect QR code
+ auto result = ZXing::ReadBarcode(imageView, options);
+
+ if (result.isValid()) {
+ QString text = QString::fromStdString(result.text());
+ return text;
+ }
+ } catch (const std::exception& e) {
+ qWarning() << "QR code scanning error:" << e.what();
+ }
+
+ return QString();
+}
diff --git a/src/app/qrcodescannermodel.h b/src/app/qrcodescannermodel.h
new file mode 100644
index 00000000..39c95961
--- /dev/null
+++ b/src/app/qrcodescannermodel.h
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2025-2025 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include
+#include
+#include
+
+#include // QML registration
+
+class QRCodeScannerModel : public QObject
+{
+ Q_OBJECT
+
+public:
+ static QRCodeScannerModel* create(QQmlEngine*, QJSEngine*)
+ {
+ return new QRCodeScannerModel();
+ }
+
+ explicit QRCodeScannerModel(QObject* parent = nullptr);
+
+ Q_INVOKABLE QString scanImage(const QImage& image);
+};
diff --git a/src/app/settingsview/components/LinkDeviceDialog.qml b/src/app/settingsview/components/LinkDeviceDialog.qml
index 7148d502..ca49e5c2 100644
--- a/src/app/settingsview/components/LinkDeviceDialog.qml
+++ b/src/app/settingsview/components/LinkDeviceDialog.qml
@@ -20,6 +20,8 @@ import QtQuick.Layouts
import net.jami.Models 1.1
import net.jami.Adapters 1.1
import net.jami.Constants 1.1
+import net.jami.Enums 1.1
+import Qt.labs.platform
import "../../commoncomponents"
import "../../mainview/components"
@@ -32,368 +34,381 @@ BaseModalDialog {
property bool darkTheme: UtilsAdapter.useApplicationTheme()
- popupContent: StackLayout {
- id: stackedWidget
+ autoClose: false
+ closeButtonVisible: false
- function setGeneratingPage() {
- if (passwordEdit.length === 0 && CurrentAccount.hasArchivePassword) {
- setExportPage(NameDirectory.ExportOnRingStatus.WRONG_PASSWORD, "");
- return;
- }
- stackedWidget.currentIndex = exportingSpinnerPage.pageIndex;
- spinnerMovie.playing = true;
+ // Function to check if dialog can be closed directly
+ function canCloseDirectly() {
+ return LinkDeviceModel.deviceAuthState === DeviceAuthStateEnum.INIT ||
+ LinkDeviceModel.deviceAuthState === DeviceAuthStateEnum.DONE
+ }
+
+ // Close button. Use custom close button to show a confirmation dialog.
+ JamiPushButton {
+ anchors {
+ top: parent.top
+ right: parent.right
+ topMargin: 5
+ rightMargin: 5
}
- function setExportPage(status, pin) {
- if (status === NameDirectory.ExportOnRingStatus.SUCCESS) {
- infoLabel.success = true;
- pinRectangle.visible = true
- exportedPIN.text = pin;
+ Layout.preferredHeight: 20
+ Layout.preferredWidth: 20
+
+ imageColor: hovered ? JamiTheme.textColor : JamiTheme.buttonTintedGreyHovered
+ normalColor: "transparent"
+
+ source: JamiResources.round_close_24dp_svg
+ onClicked: {
+ if (canCloseDirectly()) {
+ root.close();
} else {
- infoLabel.success = false;
- infoLabel.visible = true;
- switch (status) {
- case NameDirectory.ExportOnRingStatus.WRONG_PASSWORD:
- infoLabel.text = JamiStrings.incorrectPassword;
- break;
- case NameDirectory.ExportOnRingStatus.NETWORK_ERROR:
- infoLabel.text = JamiStrings.linkDeviceNetWorkError;
- break;
- case NameDirectory.ExportOnRingStatus.INVALID:
- infoLabel.text = JamiStrings.somethingWentWrong;
- break;
- }
- }
- stackedWidget.currentIndex = exportingInfoPage.pageIndex;
- stackedWidget.height = exportingLayout.implicitHeight;
- }
-
- onVisibleChanged: {
- if (visible) {
- if (CurrentAccount.hasArchivePassword) {
- stackedWidget.currentIndex = enterPasswordPage.pageIndex;
- } else {
- setGeneratingPage();
- }
+ confirmCloseDialog.open();
}
}
+ }
- // Index = 0
- Item {
- id: enterPasswordPage
+ MessageDialog {
+ id: confirmCloseDialog
- readonly property int pageIndex: 0
+ text: JamiStrings.linkDeviceCloseWarningTitle
+ informativeText: JamiStrings.linkDeviceCloseWarningMessage
+ buttons: MessageDialog.Ok | MessageDialog.Cancel
- Component.onCompleted: passwordEdit.forceActiveFocus()
+ onOkClicked: function(button) {
+ root.close();
+ }
+ }
- onHeightChanged: {
- stackedWidget.height = passwordLayout.implicitHeight
- }
+ popupContent: Item {
+ id: content
+ width: 400
+ height: 450
- ColumnLayout {
- id: passwordLayout
- spacing: JamiTheme.preferredMarginSize
- anchors.centerIn: parent
+ // Scrollable container for StackLayout
+ ScrollView {
+ id: scrollView
- Label {
- Layout.alignment: Qt.AlignCenter
- Layout.maximumWidth: root.width - 4 * JamiTheme.preferredMarginSize
- wrapMode: Text.Wrap
+ anchors.fill: parent
- text: JamiStrings.enterPasswordPinCode
- color: JamiTheme.textColor
- font.pointSize: JamiTheme.textFontSize
- font.kerning: true
- horizontalAlignment: Text.AlignHCenter
- verticalAlignment: Text.AlignVCenter
- }
+ anchors.leftMargin: 20
+ anchors.rightMargin: 20
+ anchors.bottomMargin: 20
+ clip: true
- RowLayout {
- Layout.topMargin: 10
- Layout.leftMargin: JamiTheme.cornerIconSize
- Layout.rightMargin: JamiTheme.cornerIconSize
- spacing: JamiTheme.preferredMarginSize
- Layout.bottomMargin: JamiTheme.preferredMarginSize
+ ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
+ ScrollBar.vertical.policy: ScrollBar.AsNeeded
+ contentHeight: stackLayout.implicitHeight
- PasswordTextEdit {
- id: passwordEdit
+ StackLayout {
+ id: stackLayout
+ width: Math.min(scrollView.width, scrollView.availableWidth)
- firstEntry: true
- placeholderText: JamiStrings.password
+ currentIndex: scanAndEnterCodeView.index
- Layout.alignment: Qt.AlignLeft
- Layout.fillWidth: true
+ Connections {
+ target: LinkDeviceModel
- KeyNavigation.up: btnConfirm
- KeyNavigation.down: KeyNavigation.up
-
- onDynamicTextChanged: {
- btnConfirm.enabled = dynamicText.length > 0;
- btnConfirm.hoverEnabled = dynamicText.length > 0;
+ function onDeviceAuthStateChanged() {
+ switch (LinkDeviceModel.deviceAuthState) {
+ case DeviceAuthStateEnum.INIT:
+ stackLayout.currentIndex = scanAndEnterCodeView.index
+ break
+ case DeviceAuthStateEnum.CONNECTING:
+ stackLayout.currentIndex = deviceLinkLoadingView.index
+ deviceLinkLoadingView.loadingText = JamiStrings.linkDeviceConnecting
+ break
+ case DeviceAuthStateEnum.AUTHENTICATING:
+ stackLayout.currentIndex = deviceConfirmationView.index
+ break
+ case DeviceAuthStateEnum.IN_PROGRESS:
+ stackLayout.currentIndex = deviceLinkLoadingView.index
+ deviceLinkLoadingView.loadingText = JamiStrings.linkDeviceInProgress
+ break
+ case DeviceAuthStateEnum.DONE:
+ if (LinkDeviceModel.linkDeviceError.length > 0) {
+ stackLayout.currentIndex = deviceLinkErrorView.index
+ } else {
+ stackLayout.currentIndex = deviceLinkSuccessView.index
+ }
+ break
+ default:
+ break
}
- onAccepted: btnConfirm.clicked()
- }
-
- JamiPushButton {
- id: btnConfirm
-
- Layout.alignment: Qt.AlignCenter
- height: 36
- width: 36
-
- hoverEnabled: false
- enabled: false
-
- imageColor: JamiTheme.secondaryBackgroundColor
- hoveredColor: JamiTheme.buttonTintedBlueHovered
- source: JamiResources.check_black_24dp_svg
- normalColor: JamiTheme.tintedBlue
-
- onClicked: stackedWidget.setGeneratingPage()
-
}
}
- }
- }
- // Index = 1
- Item {
- id: exportingSpinnerPage
+ // Common base component for stack layout items
+ component StackViewBase: Item {
+ id: baseItem
- readonly property int pageIndex: 1
+ required property string title
+ default property alias content: contentLayout.data
- onHeightChanged: {
- stackedWidget.height = spinnerLayout.implicitHeight
- }
- onWidthChanged: stackedWidget.width = exportingLayout.implicitWidth
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignHCenter
+ implicitHeight: contentLayout.implicitHeight
- ColumnLayout {
- id: spinnerLayout
-
- spacing: JamiTheme.preferredMarginSize
- anchors.centerIn: parent
-
- Label {
- Layout.alignment: Qt.AlignCenter
-
- text: JamiStrings.linkDevice
- color: JamiTheme.textColor
- font.pointSize: JamiTheme.headerFontSize
- font.kerning: true
- horizontalAlignment: Text.AlignLeft
- verticalAlignment: Text.AlignVCenter
+ ColumnLayout {
+ id: contentLayout
+ anchors {
+ left: parent.left
+ right: parent.right
+ verticalCenter: parent.verticalCenter
+ }
+ Layout.preferredWidth: scrollView.width
+ spacing: 20
+ }
}
- AnimatedImage {
- id: spinnerMovie
+ StackViewBase {
+ id: deviceLinkErrorView
+ property int index: 0
+ title: "Error"
- Layout.alignment: Qt.AlignCenter
-
- Layout.preferredWidth: 30
- Layout.preferredHeight: 30
-
- source: JamiResources.jami_rolling_spinner_gif
- playing: visible
- fillMode: Image.PreserveAspectFit
- mipmap: true
- }
- }
- }
-
- // Index = 2
- Item {
- id: exportingInfoPage
-
- readonly property int pageIndex: 2
-
- width: childrenRect.width
- height: childrenRect.height
-
- onHeightChanged: {
- stackedWidget.height = exportingLayout.implicitHeight
- }
- onWidthChanged: stackedWidget.width = exportingLayout.implicitWidth
-
- ColumnLayout {
- id: exportingLayout
-
- spacing: JamiTheme.preferredMarginSize
-
- Label {
- id: instructionLabel
-
- Layout.maximumWidth: Math.min(root.maximumPopupWidth, root.width) - 2 * root.popupMargins
- Layout.alignment: Qt.AlignLeft
-
- color: JamiTheme.textColor
-
- wrapMode: Text.Wrap
- text: JamiStrings.linkingInstructions
- font.pointSize: JamiTheme.textFontSize
- font.kerning: true
- verticalAlignment: Text.AlignVCenter
+ Text {
+ Layout.alignment: Qt.AlignHCenter
+ text: LinkDeviceModel.linkDeviceError
+ Layout.preferredWidth: scrollView.width
+ color: JamiTheme.textColor
+ font.pixelSize: JamiTheme.wizardViewDescriptionFontPixelSize
+ lineHeight: JamiTheme.wizardViewTextLineHeight
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ }
+ MaterialButton {
+ Layout.alignment: Qt.AlignHCenter
+ text: JamiStrings.close
+ toolTipText: JamiStrings.optionTryAgain
+ primary: true
+ onClicked: {
+ root.close();
+ }
+ }
}
- RowLayout {
- spacing: 10
- Layout.maximumWidth: Math.min(root.maximumPopupWidth, root.width) - 2 * root.popupMargins
+ StackViewBase {
+ id: deviceLinkSuccessView
+ property int index: 1
+ title: "Success"
- Rectangle {
- Layout.alignment: Qt.AlignCenter
+ Text {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: scrollView.width
+ text: JamiStrings.linkDeviceAllSet
+ color: JamiTheme.textColor
+ font.pixelSize: JamiTheme.wizardViewDescriptionFontPixelSize
+ lineHeight: JamiTheme.wizardViewTextLineHeight
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ }
- radius: 5
- color: JamiTheme.backgroundRectangleColor
- width: 100
- height: 100
+ MaterialButton {
+ Layout.alignment: Qt.AlignHCenter
+ text: JamiStrings.close
+ toolTipText: JamiStrings.optionTryAgain
+ primary: true
+ onClicked: {
+ root.close();
+ }
+ }
+ }
- Rectangle {
- width: qrImage.width + 4
- height: qrImage.height + 4
- anchors.centerIn: parent
- radius: 5
- color: JamiTheme.whiteColor
- Image {
- id: qrImage
- anchors.centerIn: parent
- mipmap: false
- smooth: false
- source: "image://qrImage/raw_" + exportedPIN.text
- sourceSize.width: 80
- sourceSize.height: 80
+ StackViewBase {
+ id: deviceLinkLoadingView
+ property int index: 2
+ title: "Loading"
+ property string loadingText: ""
+
+ BusyIndicator {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: 50
+ Layout.preferredHeight: 50
+ running: true
+ }
+
+ Text {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: scrollView.width
+ text: deviceLinkLoadingView.loadingText
+ color: JamiTheme.textColor
+ font.pixelSize: JamiTheme.wizardViewDescriptionFontPixelSize
+ lineHeight: JamiTheme.wizardViewTextLineHeight
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ opacity: 1
+
+ SequentialAnimation on opacity {
+ running: true
+ loops: Animation.Infinite
+ NumberAnimation {
+ from: 1
+ to: 0.3
+ duration: 1000
+ easing.type: Easing.InOutQuad
+ }
+ NumberAnimation {
+ from: 0.3
+ to: 1
+ duration: 1000
+ easing.type: Easing.InOutQuad
}
}
-
- }
-
- Rectangle {
- id: pinRectangle
-
- radius: 5
- color: JamiTheme.backgroundRectangleColor
- Layout.fillWidth: true
- height: 100
- Layout.minimumWidth: exportedPIN.width + 20
-
- Layout.alignment: Qt.AlignCenter
-
- MaterialLineEdit {
- id: exportedPIN
-
- padding: 10
- anchors.centerIn: parent
-
- text: JamiStrings.pin
- wrapMode: Text.NoWrap
-
- backgroundColor: JamiTheme.backgroundRectangleColor
-
- color: darkTheme ? JamiTheme.editLineColor : JamiTheme.darkTintedBlue
- selectByMouse: true
- readOnly: true
- font.pointSize: JamiTheme.tinyCreditsTextSize
- font.kerning: true
- horizontalAlignment: Text.AlignHCenter
- verticalAlignment: Text.AlignVCenter
- }
}
}
- Rectangle {
- radius: 5
- color: JamiTheme.infoRectangleColor
- Layout.fillWidth: true
- Layout.preferredHeight: infoLabels.height + 38
+ StackViewBase {
+ id: deviceConfirmationView
+ property int index: 3
+ title: "Confirmation"
+
+ Text {
+ id: explanationConnect
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: scrollView.width
+ text: JamiStrings.linkDeviceFoundAddress
+ font.pixelSize: JamiTheme.wizardViewDescriptionFontPixelSize
+ lineHeight: JamiTheme.wizardViewTextLineHeight
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ color: JamiTheme.textColor
+ }
+
+ Text {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: scrollView.width
+ text: JamiStrings.linkDeviceNewDeviceIP.arg(LinkDeviceModel.ipAddress)
+ font.pixelSize: JamiTheme.wizardViewDescriptionFontPixelSize
+ lineHeight: JamiTheme.wizardViewTextLineHeight
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ color: JamiTheme.textColor
+ font.weight: Font.Bold
+ }
RowLayout {
- id: infoLayout
+ Layout.alignment: Qt.AlignHCenter
+ spacing: 16
- anchors.centerIn: parent
- anchors.fill: parent
- anchors.margins: 14
- spacing: 10
-
- ResponsiveImage{
- Layout.fillWidth: true
-
- source: JamiResources.outline_info_24dp_svg
- fillMode: Image.PreserveAspectFit
-
- color: darkTheme ? JamiTheme.editLineColor : JamiTheme.darkTintedBlue
- Layout.fillHeight: true
+ MaterialButton {
+ id: confirm
+ primary: true
+ Layout.alignment: Qt.AlignCenter
+ text: JamiStrings.optionConfirm
+ toolTipText: JamiStrings.optionConfirm
+ onClicked: {
+ LinkDeviceModel.confirmAddDevice()
+ }
}
- ColumnLayout{
- id: infoLabels
-
- Layout.fillHeight: true
- Layout.fillWidth: true
-
- Label {
- id: otherDeviceLabel
-
- Layout.alignment: Qt.AlignLeft
- color: JamiTheme.textColor
- text: JamiStrings.onAnotherDevice
-
- font.pointSize: JamiTheme.smallFontSize
- font.kerning: true
- font.bold: true
- }
-
- Label {
- id: otherInstructionLabel
-
- Layout.fillWidth: true
- Layout.alignment: Qt.AlignLeft
-
- wrapMode: Text.Wrap
- color: JamiTheme.textColor
- text: JamiStrings.onAnotherDeviceInstruction
-
- font.pointSize: JamiTheme.smallFontSize
- font.kerning: true
+ MaterialButton {
+ id: cancel
+ Layout.alignment: Qt.AlignCenter
+ secondary: true
+ toolTipText: JamiStrings.cancel
+ textLeftPadding: JamiTheme.buttontextWizzardPadding / 2
+ textRightPadding: JamiTheme.buttontextWizzardPadding / 2
+ text: JamiStrings.cancel
+ onClicked: {
+ LinkDeviceModel.cancelAddDevice()
}
}
}
}
- // Displays error messages
- Label {
- id: infoLabel
+ StackViewBase {
+ id: scanAndEnterCodeView
+ property int index: 4
+ title: "Scan"
- visible: false
+ Component.onDestruction: {
+ if (qrScanner) {
+ qrScanner.stopScanner()
+ }
+ }
- property bool success: false
- property int borderWidth: success ? 1 : 0
- property int borderRadius: success ? 15 : 0
- property string backgroundColor: success ? "whitesmoke" : "transparent"
- property string borderColor: success ? "lightgray" : "transparent"
+ Text {
+ id: explanationScan
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: scrollView.width
+ text: JamiStrings.linkDeviceScanQR
+ font.pixelSize: JamiTheme.wizardViewDescriptionFontPixelSize
+ lineHeight: JamiTheme.wizardViewTextLineHeight
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ color: JamiTheme.textColor
+ }
- Layout.maximumWidth: JamiTheme.preferredDialogWidth
- Layout.margins: JamiTheme.preferredMarginSize
+ QRCodeScanner {
+ id: qrScanner
+ Layout.alignment: Qt.AlignHCenter
+ width: 250
+ height: width * aspectRatio
+ visible: VideoDevices.listSize !== 0
- Layout.alignment: Qt.AlignCenter
+ onQrCodeDetected: function(code) {
+ console.log("QR code detected:", code)
+ LinkDeviceModel.addDevice(code)
+ }
+ }
- color: success ? JamiTheme.successLabelColor : JamiTheme.redColor
- padding: success ? 8 : 0
+ ColumnLayout {
+ id: manualEntry
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: scrollView.width
+ spacing: 10
- wrapMode: Text.Wrap
- font.pointSize: success ? JamiTheme.textFontSize : JamiTheme.textFontSize + 3
- font.kerning: true
- horizontalAlignment: Text.AlignHCenter
- verticalAlignment: Text.AlignVCenter
+ Text {
+ id: explanation
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: scrollView.width
+ text: JamiStrings.linkDeviceEnterManually
+ font.pixelSize: JamiTheme.wizardViewDescriptionFontPixelSize
+ lineHeight: JamiTheme.wizardViewTextLineHeight
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ color: JamiTheme.textColor
+ }
- background: Rectangle {
- id: infoLabelBackground
+ ModalTextEdit {
+ id: codeInput
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: scrollView.width
+ Layout.preferredHeight: JamiTheme.preferredFieldHeight
+ placeholderText: JamiStrings.linkDeviceEnterCodePlaceholder
+ }
- border.width: infoLabel.borderWidth
- border.color: infoLabel.borderColor
- radius: infoLabel.borderRadius
- color: JamiTheme.secondaryBackgroundColor
+ Text {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.maximumWidth: parent.width - 40
+ visible: LinkDeviceModel.tokenErrorMessage.length > 0
+ text: LinkDeviceModel.tokenErrorMessage
+ font.pointSize: JamiTheme.tinyFontSize
+ color: JamiTheme.redColor
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ }
+ }
+
+ MaterialButton {
+ id: connect
+ Layout.alignment: Qt.AlignHCenter
+ primary: true
+ text: JamiStrings.connect
+ toolTipText: JamiStrings.connect
+ enabled: codeInput.dynamicText.length > 0
+ onClicked: {
+ LinkDeviceModel.addDevice(codeInput.text)
+ }
}
}
}
}
}
+
+ //Reset everything when dialog is closed
+ onClosed: {
+ LinkDeviceModel.reset()
+ }
}
diff --git a/src/app/settingsview/components/QRCodeScanner.qml b/src/app/settingsview/components/QRCodeScanner.qml
new file mode 100644
index 00000000..1b151963
--- /dev/null
+++ b/src/app/settingsview/components/QRCodeScanner.qml
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2025-2025 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import QtQuick
+import net.jami.Constants 1.1
+import net.jami.Adapters 1.1
+import net.jami.Helpers 1.1
+import "../../commoncomponents"
+
+Item {
+ id: root
+
+ property bool isScanning: false
+ property real aspectRatio: 0.5625
+
+ onVisibleChanged: {
+ if (visible) {
+ startScanner()
+ } else {
+ stopScanner()
+ }
+ }
+
+ Component.onDestruction: {
+ stopScanner()
+ }
+
+ Rectangle {
+ id: cameraContainer
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width
+ height: parent.height
+ color: JamiTheme.primaryForegroundColor
+ clip: true
+
+ LocalVideo {
+ id: previewWidget
+ anchors.fill: parent
+ flip: true
+
+ // Camera not available
+ underlayItems: Text {
+ id: noCameraText
+ anchors.centerIn: parent
+ font.pointSize: 18
+ font.capitalization: Font.AllUppercase
+ color: "white"
+ text: JamiStrings.noCamera
+ visible: false // Start hidden
+
+ // Delay "No Camera" message to avoid flashing it when camera is starting up.
+ // If camera starts successfully within 5 seconds, user won't see this message.
+ // If there's a camera issue, message will be shown after the delay.
+ Timer {
+ id: visibilityTimer
+ interval: 5000
+ running: true
+ repeat: false
+ onTriggered: {
+ noCameraText.visible = true
+ destroy() // Remove the timer after it's done
+ }
+ }
+ }
+ }
+
+ // Scanning line animation
+ Rectangle {
+ id: scanLine
+ width: parent.width
+ height: 2
+ color: JamiTheme.whiteColor
+ opacity: 0.8
+ visible: root.isScanning && previewWidget.isRendering
+
+ SequentialAnimation on y {
+ running: root.isScanning
+ loops: Animation.Infinite
+ NumberAnimation {
+ from: 0
+ to: cameraContainer.height
+ duration: 2500
+ easing.type: Easing.InOutQuad
+ }
+ NumberAnimation {
+ from: cameraContainer.height
+ to: 0
+ duration: 2500
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+ }
+
+ Timer {
+ id: scanTimer
+ interval: 500
+ repeat: true
+ running: root.isScanning && previewWidget.isRendering
+ onTriggered: {
+ var result = QRCodeScannerModel.scanImage(videoProvider.captureRawVideoFrame(VideoDevices.getDefaultDevice()));
+ if (result !== "") {
+ root.isScanning = false
+ root.qrCodeDetected(result)
+ }
+ }
+ }
+
+ signal qrCodeDetected(string code)
+
+ function startScanner() {
+ previewWidget.startWithId(VideoDevices.getDefaultDevice())
+ root.isScanning = true
+ }
+
+ function stopScanner() {
+ previewWidget.startWithId("")
+ root.isScanning = true
+ }
+}
diff --git a/src/app/wizardview/components/ImportFromDevicePage.qml b/src/app/wizardview/components/ImportFromDevicePage.qml
index b308ddf4..3ef8fe46 100644
--- a/src/app/wizardview/components/ImportFromDevicePage.qml
+++ b/src/app/wizardview/components/ImportFromDevicePage.qml
@@ -17,11 +17,11 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
-import QtQuick.Dialogs
import net.jami.Adapters 1.1
import net.jami.Models 1.1
import net.jami.Constants 1.1
import net.jami.Enums 1.1
+import Qt.labs.platform
import "../../commoncomponents"
import "../../mainview/components"
@@ -77,11 +77,9 @@ Rectangle {
informativeText: JamiStrings.linkDeviceCloseWarningMessage
buttons: MessageDialog.Ok | MessageDialog.Cancel
- onButtonClicked: function(button) {
- if (button === MessageDialog.Ok) {
- AccountAdapter.cancelImportAccount();
- WizardViewStepModel.previousStep();
- }
+ onOkClicked: function(button) {
+ AccountAdapter.cancelImportAccount();
+ WizardViewStepModel.previousStep();
}
}
diff --git a/src/libclient/accountmodel.cpp b/src/libclient/accountmodel.cpp
index 3d0fdd51..a059a095 100644
--- a/src/libclient/accountmodel.cpp
+++ b/src/libclient/accountmodel.cpp
@@ -126,6 +126,18 @@ public Q_SLOTS:
int state,
const MapStringString& details);
+ /**
+ * Emit addDeviceStateChanged.
+ * @param accountId
+ * @param operationId
+ * @param state
+ * @param details
+ */
+ void slotAddDeviceStateChanged(const QString& accountID,
+ uint32_t operationId,
+ int state,
+ const MapStringString& details);
+
/**
* @param accountId
* @param details
@@ -430,6 +442,10 @@ AccountModelPimpl::AccountModelPimpl(AccountModel& linked,
&CallbacksHandler::deviceAuthStateChanged,
&linked,
&AccountModel::deviceAuthStateChanged);
+ connect(&callbacksHandler,
+ &CallbacksHandler::addDeviceStateChanged,
+ &linked,
+ &AccountModel::addDeviceStateChanged);
connect(&callbacksHandler,
&CallbacksHandler::nameRegistrationEnded,
this,
@@ -627,6 +643,15 @@ AccountModelPimpl::slotDeviceAuthStateChanged(const QString& accountId,
Q_EMIT linked.deviceAuthStateChanged(accountId, state, details);
}
+void
+AccountModelPimpl::slotAddDeviceStateChanged(const QString& accountId,
+ uint32_t operationId,
+ int state,
+ const MapStringString& details)
+{
+ Q_EMIT linked.addDeviceStateChanged(accountId, operationId, state, details);
+}
+
void
AccountModelPimpl::slotNameRegistrationEnded(const QString& accountId,
int status,
diff --git a/src/libclient/callbackshandler.cpp b/src/libclient/callbackshandler.cpp
index 308e234e..d9f670f2 100644
--- a/src/libclient/callbackshandler.cpp
+++ b/src/libclient/callbackshandler.cpp
@@ -247,6 +247,12 @@ CallbacksHandler::CallbacksHandler(const Lrc& parent)
&CallbacksHandler::slotDeviceAuthStateChanged,
Qt::QueuedConnection);
+ connect(&ConfigurationManager::instance(),
+ &ConfigurationManagerInterface::addDeviceStateChanged,
+ this,
+ &CallbacksHandler::slotAddDeviceStateChanged,
+ Qt::QueuedConnection);
+
connect(&ConfigurationManager::instance(),
&ConfigurationManagerInterface::nameRegistrationEnded,
this,
@@ -687,6 +693,15 @@ CallbacksHandler::slotDeviceAuthStateChanged(const QString& accountId,
Q_EMIT deviceAuthStateChanged(accountId, state, details);
}
+void
+CallbacksHandler::slotAddDeviceStateChanged(const QString& accountId,
+ uint32_t operationId,
+ int state,
+ const MapStringString& details)
+{
+ Q_EMIT addDeviceStateChanged(accountId, operationId, state, details);
+}
+
void
CallbacksHandler::slotNameRegistrationEnded(const QString& accountId,
int status,
diff --git a/src/libclient/callbackshandler.h b/src/libclient/callbackshandler.h
index 428cf54c..13698092 100644
--- a/src/libclient/callbackshandler.h
+++ b/src/libclient/callbackshandler.h
@@ -244,6 +244,19 @@ Q_SIGNALS:
*/
void deviceAuthStateChanged(const QString& accountId, int state, const MapStringString& details);
+ /**
+ * Add device state has changed
+ * @param accountId
+ * @param operationId
+ * @param state
+ * @param details map
+ */
+
+ void addDeviceStateChanged(const QString& accountId,
+ uint32_t operationId,
+ int state,
+ const MapStringString& details);
+
/**
* Name registration has ended
* @param accountId
@@ -587,6 +600,18 @@ private Q_SLOTS:
int state,
const MapStringString& details);
+ /**
+ * Add device state has changed
+ * @param accountId
+ * @param operationId
+ * @param state
+ * @param details map
+ */
+ void slotAddDeviceStateChanged(const QString& accountId,
+ uint32_t operationId,
+ int state,
+ const MapStringString& details);
+
/**
* Emit nameRegistrationEnded
* @param accountId