diff --git a/.gitmodules b/.gitmodules index e72cd9aa8c0ca3eb10eecfdfc32b083664677211..46aa7d0b5dc94c67edfc41d6a3f27bd2427527b2 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 0000000000000000000000000000000000000000..a920817b6fe0508cc4aca9003003c2812a78e935 --- /dev/null +++ b/3rdparty/zxing-cpp @@ -0,0 +1 @@ +Subproject commit a920817b6fe0508cc4aca9003003c2812a78e935 diff --git a/CMakeLists.txt b/CMakeLists.txt index 46db8e9718385538db3a19d985a7f02b2c4aca2a..64eee5ffb9e250523bae5ff2cc27fda19c1a5f2e 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 0000000000000000000000000000000000000000..0cc3f2c3b579bf9f06dc9ccd8650b17ec742b067 --- /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 <http://www.gnu.org/licenses/>. + */ + +#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<int>(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<DeviceAuthState>(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<int>(DeviceAuthState::CONNECTING)); +} + +void +LinkDeviceModel::handleAuthenticatingSignal(const QVariantMap& details) +{ + QString peerAddress = details.value("peer_address").toString(); + set_ipAddress(peerAddress); + set_deviceAuthState(static_cast<int>(DeviceAuthState::AUTHENTICATING)); +} + +void +LinkDeviceModel::handleInProgressSignal() +{ + set_deviceAuthState(static_cast<int>(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<int>(DeviceAuthState::DONE)); + } else { + set_deviceAuthState(static_cast<int>(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<int>(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 0000000000000000000000000000000000000000..bf27d445d4c93462906114fc0e0bce898fc6302d --- /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 <http://www.gnu.org/licenses/>. + */ + +#pragma once + +#include "api/account.h" + +#include "qmladapterbase.h" +#include "qtutils.h" + +#include <QObject> +#include <QVariant> +#include <QMap> + +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 d848e849d4a74e297a8947434178217904338017..328f1bb36a882bd908a7c76e3f98fba8928b291a 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 de94474ed619e03b61f2f7f96e7f58f1d3eaa831..eacff9e22c8e28ebd632fe4c9b0cedd9924a9a44 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<WizardViewStepModel>(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<LinkDeviceModel>(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 0000000000000000000000000000000000000000..b1159fb0d2e60c8ac8483f1da3693dbbd13a812c --- /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 <http://www.gnu.org/licenses/>. + */ + +#include "qrcodescannermodel.h" + +#include <Barcode.h> +#include <MultiFormatReader.h> +#include <ReadBarcode.h> + +#include <QDebug> + +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 0000000000000000000000000000000000000000..39c95961e77e68e00720d083b393fb4e843e0004 --- /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 <http://www.gnu.org/licenses/>. + */ + +#pragma once + +#include <QObject> +#include <QString> +#include <QImage> + +#include <QQmlEngine> // 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 7148d502014273046889e23c97a0a32d1640d608..ca49e5c2684207fb6363e9046a68b43c9115fd6d 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 setExportPage(status, pin) { - if (status === NameDirectory.ExportOnRingStatus.SUCCESS) { - infoLabel.success = true; - pinRectangle.visible = true - exportedPIN.text = pin; - } 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; - } + // Function to check if dialog can be closed directly + function canCloseDirectly() { + return LinkDeviceModel.deviceAuthState === DeviceAuthStateEnum.INIT || + LinkDeviceModel.deviceAuthState === DeviceAuthStateEnum.DONE + } - onVisibleChanged: { - if (visible) { - if (CurrentAccount.hasArchivePassword) { - stackedWidget.currentIndex = enterPasswordPage.pageIndex; - } else { - setGeneratingPage(); - } - } + // Close button. Use custom close button to show a confirmation dialog. + JamiPushButton { + anchors { + top: parent.top + right: parent.right + topMargin: 5 + rightMargin: 5 } - // Index = 0 - Item { - id: enterPasswordPage + Layout.preferredHeight: 20 + Layout.preferredWidth: 20 - readonly property int pageIndex: 0 + imageColor: hovered ? JamiTheme.textColor : JamiTheme.buttonTintedGreyHovered + normalColor: "transparent" - Component.onCompleted: passwordEdit.forceActiveFocus() - - onHeightChanged: { - stackedWidget.height = passwordLayout.implicitHeight + source: JamiResources.round_close_24dp_svg + onClicked: { + if (canCloseDirectly()) { + root.close(); + } else { + confirmCloseDialog.open(); } + } + } - ColumnLayout { - id: passwordLayout - spacing: JamiTheme.preferredMarginSize - anchors.centerIn: parent - - Label { - Layout.alignment: Qt.AlignCenter - Layout.maximumWidth: root.width - 4 * JamiTheme.preferredMarginSize - wrapMode: Text.Wrap - - text: JamiStrings.enterPasswordPinCode - color: JamiTheme.textColor - font.pointSize: JamiTheme.textFontSize - font.kerning: true - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - - RowLayout { - Layout.topMargin: 10 - Layout.leftMargin: JamiTheme.cornerIconSize - Layout.rightMargin: JamiTheme.cornerIconSize - spacing: JamiTheme.preferredMarginSize - Layout.bottomMargin: JamiTheme.preferredMarginSize - - PasswordTextEdit { - id: passwordEdit - - firstEntry: true - placeholderText: JamiStrings.password + MessageDialog { + id: confirmCloseDialog - Layout.alignment: Qt.AlignLeft - Layout.fillWidth: true + text: JamiStrings.linkDeviceCloseWarningTitle + informativeText: JamiStrings.linkDeviceCloseWarningMessage + buttons: MessageDialog.Ok | MessageDialog.Cancel - KeyNavigation.up: btnConfirm - KeyNavigation.down: KeyNavigation.up + onOkClicked: function(button) { + root.close(); + } + } - onDynamicTextChanged: { - btnConfirm.enabled = dynamicText.length > 0; - btnConfirm.hoverEnabled = dynamicText.length > 0; + popupContent: Item { + id: content + width: 400 + height: 450 + + // Scrollable container for StackLayout + ScrollView { + id: scrollView + + anchors.fill: parent + + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.bottomMargin: 20 + clip: true + + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AsNeeded + contentHeight: stackLayout.implicitHeight + + StackLayout { + id: stackLayout + width: Math.min(scrollView.width, scrollView.availableWidth) + + currentIndex: scanAndEnterCodeView.index + + Connections { + target: LinkDeviceModel + + 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 + // Common base component for stack layout items + component StackViewBase: Item { + id: baseItem - onClicked: stackedWidget.setGeneratingPage() + required property string title + default property alias content: contentLayout.data + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + implicitHeight: contentLayout.implicitHeight + + ColumnLayout { + id: contentLayout + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + Layout.preferredWidth: scrollView.width + spacing: 20 } } - } - } - - // Index = 1 - Item { - id: exportingSpinnerPage - - readonly property int pageIndex: 1 - - onHeightChanged: { - stackedWidget.height = spinnerLayout.implicitHeight - } - onWidthChanged: stackedWidget.width = exportingLayout.implicitWidth - ColumnLayout { - id: spinnerLayout - - spacing: JamiTheme.preferredMarginSize - anchors.centerIn: parent - - Label { - Layout.alignment: Qt.AlignCenter + StackViewBase { + id: deviceLinkErrorView + property int index: 0 + title: "Error" + + 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 + } - text: JamiStrings.linkDevice - color: JamiTheme.textColor - font.pointSize: JamiTheme.headerFontSize - font.kerning: true - horizontalAlignment: Text.AlignLeft - verticalAlignment: Text.AlignVCenter + MaterialButton { + Layout.alignment: Qt.AlignHCenter + text: JamiStrings.close + toolTipText: JamiStrings.optionTryAgain + primary: true + onClicked: { + root.close(); + } + } } - AnimatedImage { - id: spinnerMovie - - Layout.alignment: Qt.AlignCenter - - Layout.preferredWidth: 30 - Layout.preferredHeight: 30 + StackViewBase { + id: deviceLinkSuccessView + property int index: 1 + title: "Success" + + 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 + } - source: JamiResources.jami_rolling_spinner_gif - playing: visible - fillMode: Image.PreserveAspectFit - mipmap: true + MaterialButton { + Layout.alignment: Qt.AlignHCenter + text: JamiStrings.close + toolTipText: JamiStrings.optionTryAgain + primary: true + onClicked: { + root.close(); + } + } } - } - } - - // 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 - - } + 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 + } - RowLayout { - spacing: 10 - Layout.maximumWidth: Math.min(root.maximumPopupWidth, root.width) - 2 * root.popupMargins - - Rectangle { - Layout.alignment: Qt.AlignCenter - - radius: 5 - color: JamiTheme.backgroundRectangleColor - width: 100 - height: 100 - - 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 + 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 - } + 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 } - } - Rectangle { - radius: 5 - color: JamiTheme.infoRectangleColor - Layout.fillWidth: true - Layout.preferredHeight: infoLabels.height + 38 + 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 - - 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 - } - - 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 + Layout.alignment: Qt.AlignHCenter + spacing: 16 + + MaterialButton { + id: confirm + primary: true + Layout.alignment: Qt.AlignCenter + text: JamiStrings.optionConfirm + toolTipText: JamiStrings.optionConfirm + onClicked: { + LinkDeviceModel.confirmAddDevice() } + } - 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" + + Component.onDestruction: { + if (qrScanner) { + qrScanner.stopScanner() + } + } - visible: false + 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 + } - 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" + QRCodeScanner { + id: qrScanner + Layout.alignment: Qt.AlignHCenter + width: 250 + height: width * aspectRatio + visible: VideoDevices.listSize !== 0 - Layout.maximumWidth: JamiTheme.preferredDialogWidth - Layout.margins: JamiTheme.preferredMarginSize + onQrCodeDetected: function(code) { + console.log("QR code detected:", code) + LinkDeviceModel.addDevice(code) + } + } - Layout.alignment: Qt.AlignCenter + ColumnLayout { + id: manualEntry + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: scrollView.width + spacing: 10 - color: success ? JamiTheme.successLabelColor : JamiTheme.redColor - padding: success ? 8 : 0 + 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 + } - wrapMode: Text.Wrap - font.pointSize: success ? JamiTheme.textFontSize : JamiTheme.textFontSize + 3 - font.kerning: true - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter + ModalTextEdit { + id: codeInput + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: scrollView.width + Layout.preferredHeight: JamiTheme.preferredFieldHeight + placeholderText: JamiStrings.linkDeviceEnterCodePlaceholder + } - background: Rectangle { - id: infoLabelBackground + 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 + } + } - border.width: infoLabel.borderWidth - border.color: infoLabel.borderColor - radius: infoLabel.borderRadius - color: JamiTheme.secondaryBackgroundColor + 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 0000000000000000000000000000000000000000..1b15196359b5865541244d4c8bc71cfb8d8c2a13 --- /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 <http://www.gnu.org/licenses/>. + */ + +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 b308ddf47337beb04ee261a427976bfe904dc4b9..3ef8fe46444b4d2934b0504369980fc446b19f0f 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 3d0fdd511dbaa7f4ab90f92d7893d2ce8af474c9..a059a095f56b9ab84632bcf4329e86c631fff414 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 308e234e01cb3f7d11779441d785522701d0a91c..d9f670f2687e1315a79998580c3fba5632bd2374 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 428cf54ce6e20199c4ccac7ead6dd845922eb25d..136980929f0c1cb2862dc6d632f336e975f00af8 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