1
0
Fork 0
mirror of https://git.jami.net/savoirfairelinux/jami-client-qt.git synced 2025-07-02 06:35:29 +02:00

account: implement export-from-device using new API

- Implements new APIs
- Implements export-from-device mechanism
(link device in account settings)

Gitlab: #1695
Change-Id: I3d3486380e695ea44c199dbe0a0448f724b4d2db
This commit is contained in:
Kateryna Kostiuk 2025-03-07 10:26:10 -05:00
parent 33da15daba
commit d3c76eac8d
15 changed files with 857 additions and 314 deletions

4
.gitmodules vendored
View file

@ -27,3 +27,7 @@
path = 3rdparty/tidy-html5 path = 3rdparty/tidy-html5
url = https://github.com/htacg/tidy-html5.git url = https://github.com/htacg/tidy-html5.git
ignore = dirty ignore = dirty
[submodule "3rdparty/zxing-cpp"]
path = 3rdparty/zxing-cpp
url = https://github.com/nu-book/zxing-cpp.git
ignore = dirty

1
3rdparty/zxing-cpp vendored Submodule

@ -0,0 +1 @@
Subproject commit a920817b6fe0508cc4aca9003003c2812a78e935

View file

@ -364,6 +364,8 @@ set(COMMON_SOURCES
${APP_SRC_DIR}/pluginversionmanager.cpp ${APP_SRC_DIR}/pluginversionmanager.cpp
${APP_SRC_DIR}/connectioninfolistmodel.cpp ${APP_SRC_DIR}/connectioninfolistmodel.cpp
${APP_SRC_DIR}/pluginversionmanager.cpp ${APP_SRC_DIR}/pluginversionmanager.cpp
${APP_SRC_DIR}/linkdevicemodel.cpp
${APP_SRC_DIR}/qrcodescannermodel.cpp
) )
set(COMMON_HEADERS set(COMMON_HEADERS
@ -436,6 +438,8 @@ set(COMMON_HEADERS
${APP_SRC_DIR}/pttlistener.h ${APP_SRC_DIR}/pttlistener.h
${APP_SRC_DIR}/crashreportclient.h ${APP_SRC_DIR}/crashreportclient.h
${APP_SRC_DIR}/crashreporter.h ${APP_SRC_DIR}/crashreporter.h
${APP_SRC_DIR}/linkdevicemodel.h
${APP_SRC_DIR}/qrcodescannermodel.h
) )
# For libavutil/avframe. # 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_INCLUDE_DIRS ${tidy_SOURCE_DIR}/include)
list(APPEND CLIENT_LIBS tidy-static) 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 # common executable sources
qt_add_executable( qt_add_executable(
${PROJECT_NAME} ${PROJECT_NAME}

140
src/app/linkdevicemodel.cpp Normal file
View file

@ -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("");
}

57
src/app/linkdevicemodel.h Normal file
View file

@ -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_;
};

View file

@ -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 scanToImportAccount: qsTr("Scan this QR code on your other device to proceed with importing your account.")
property string waitingForToken: qsTr("Please wait…") property string waitingForToken: qsTr("Please wait…")
property string scanQRCode: qsTr("Scan QR code") 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 confirmAccountImport: qsTr("Authenticating device")
property string transferringAccount: qsTr("Transferring account…") 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.") 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 enterAccountPassword: qsTr("Enter account password")
property string enterPasswordPinCode: qsTr("This account is password encrypted, enter the password to generate a PIN code.") 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 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 linkNewDevice: qsTr("Link new device")
property string linkingInstructions: qsTr("In Jami, scan the QR code or manually enter the PIN code.") property string linkDeviceConnecting: qsTr("Connecting to your new device…")
property string pinValidity: qsTr("The PIN code will expire in: ") 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 // PasswordDialog
property string enterPassword: qsTr("Enter password") property string enterPassword: qsTr("Enter password")

View file

@ -62,6 +62,8 @@
#include "pluginlistpreferencemodel.h" #include "pluginlistpreferencemodel.h"
#include "preferenceitemlistmodel.h" #include "preferenceitemlistmodel.h"
#include "wizardviewstepmodel.h" #include "wizardviewstepmodel.h"
#include "linkdevicemodel.h"
#include "qrcodescannermodel.h"
#include "api/peerdiscoverymodel.h" #include "api/peerdiscoverymodel.h"
#include "api/codecmodel.h" #include "api/codecmodel.h"
@ -185,6 +187,12 @@ registerTypes(QQmlEngine* engine,
QQmlEngine::setObjectOwnership(wizardViewStepModel, QQmlEngine::CppOwnership); QQmlEngine::setObjectOwnership(wizardViewStepModel, QQmlEngine::CppOwnership);
REG_QML_SINGLETON<WizardViewStepModel>(REG_MODEL, "WizardViewStepModel", CREATE(wizardViewStepModel)); 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. // 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 // These MUST be set prior to loading the initial QML file, in order to
// be available to the QML adapter class factory creation methods. // be available to the QML adapter class factory creation methods.
@ -195,6 +203,7 @@ registerTypes(QQmlEngine* engine,
qApp->setProperty("PreviewEngine", QVariant::fromValue(previewEngine)); qApp->setProperty("PreviewEngine", QVariant::fromValue(previewEngine));
// qml adapter registration // qml adapter registration
QML_REGISTERSINGLETON_TYPE(NS_HELPERS, QRCodeScannerModel);
QML_REGISTERSINGLETON_TYPE(NS_HELPERS, AvatarRegistry); QML_REGISTERSINGLETON_TYPE(NS_HELPERS, AvatarRegistry);
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, AccountAdapter); QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, AccountAdapter);
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, CallAdapter); QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, CallAdapter);

View file

@ -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();
}

View file

@ -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);
};

View file

@ -20,6 +20,8 @@ import QtQuick.Layouts
import net.jami.Models 1.1 import net.jami.Models 1.1
import net.jami.Adapters 1.1 import net.jami.Adapters 1.1
import net.jami.Constants 1.1 import net.jami.Constants 1.1
import net.jami.Enums 1.1
import Qt.labs.platform
import "../../commoncomponents" import "../../commoncomponents"
import "../../mainview/components" import "../../mainview/components"
@ -32,368 +34,381 @@ BaseModalDialog {
property bool darkTheme: UtilsAdapter.useApplicationTheme() property bool darkTheme: UtilsAdapter.useApplicationTheme()
popupContent: StackLayout { autoClose: false
id: stackedWidget closeButtonVisible: false
function setGeneratingPage() { // Function to check if dialog can be closed directly
if (passwordEdit.length === 0 && CurrentAccount.hasArchivePassword) { function canCloseDirectly() {
setExportPage(NameDirectory.ExportOnRingStatus.WRONG_PASSWORD, ""); return LinkDeviceModel.deviceAuthState === DeviceAuthStateEnum.INIT ||
return; LinkDeviceModel.deviceAuthState === DeviceAuthStateEnum.DONE
}
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;
}
onVisibleChanged: {
if (visible) {
if (CurrentAccount.hasArchivePassword) {
stackedWidget.currentIndex = enterPasswordPage.pageIndex;
} else {
setGeneratingPage();
}
}
}
// Index = 0
Item {
id: enterPasswordPage
readonly property int pageIndex: 0
Component.onCompleted: passwordEdit.forceActiveFocus()
onHeightChanged: {
stackedWidget.height = passwordLayout.implicitHeight
}
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
Layout.alignment: Qt.AlignLeft
Layout.fillWidth: true
KeyNavigation.up: btnConfirm
KeyNavigation.down: KeyNavigation.up
onDynamicTextChanged: {
btnConfirm.enabled = dynamicText.length > 0;
btnConfirm.hoverEnabled = dynamicText.length > 0;
}
onAccepted: btnConfirm.clicked()
} }
// Close button. Use custom close button to show a confirmation dialog.
JamiPushButton { JamiPushButton {
id: btnConfirm anchors {
top: parent.top
Layout.alignment: Qt.AlignCenter right: parent.right
height: 36 topMargin: 5
width: 36 rightMargin: 5
hoverEnabled: false
enabled: false
imageColor: JamiTheme.secondaryBackgroundColor
hoveredColor: JamiTheme.buttonTintedBlueHovered
source: JamiResources.check_black_24dp_svg
normalColor: JamiTheme.tintedBlue
onClicked: stackedWidget.setGeneratingPage()
}
}
}
} }
// Index = 1 Layout.preferredHeight: 20
Item { Layout.preferredWidth: 20
id: exportingSpinnerPage
readonly property int pageIndex: 1 imageColor: hovered ? JamiTheme.textColor : JamiTheme.buttonTintedGreyHovered
normalColor: "transparent"
onHeightChanged: { source: JamiResources.round_close_24dp_svg
stackedWidget.height = spinnerLayout.implicitHeight onClicked: {
} if (canCloseDirectly()) {
onWidthChanged: stackedWidget.width = exportingLayout.implicitWidth root.close();
} else {
ColumnLayout { confirmCloseDialog.open();
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
}
AnimatedImage {
id: spinnerMovie
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 MessageDialog {
Item { id: confirmCloseDialog
id: exportingInfoPage
readonly property int pageIndex: 2 text: JamiStrings.linkDeviceCloseWarningTitle
informativeText: JamiStrings.linkDeviceCloseWarningMessage
buttons: MessageDialog.Ok | MessageDialog.Cancel
width: childrenRect.width onOkClicked: function(button) {
height: childrenRect.height root.close();
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
}
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
} }
} }
} popupContent: Item {
id: content
width: 400
height: 450
Rectangle { // Scrollable container for StackLayout
id: pinRectangle ScrollView {
id: scrollView
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
RowLayout {
id: infoLayout
anchors.centerIn: parent
anchors.fill: parent anchors.fill: parent
anchors.margins: 14
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
}
}
}
// Common base component for stack layout items
component StackViewBase: Item {
id: baseItem
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
}
}
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
}
MaterialButton {
Layout.alignment: Qt.AlignHCenter
text: JamiStrings.close
toolTipText: JamiStrings.optionTryAgain
primary: true
onClicked: {
root.close();
}
}
}
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
}
MaterialButton {
Layout.alignment: Qt.AlignHCenter
text: JamiStrings.close
toolTipText: JamiStrings.optionTryAgain
primary: true
onClicked: {
root.close();
}
}
}
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
}
}
}
}
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 {
Layout.alignment: Qt.AlignHCenter
spacing: 16
MaterialButton {
id: confirm
primary: true
Layout.alignment: Qt.AlignCenter
text: JamiStrings.optionConfirm
toolTipText: JamiStrings.optionConfirm
onClicked: {
LinkDeviceModel.confirmAddDevice()
}
}
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()
}
}
}
}
StackViewBase {
id: scanAndEnterCodeView
property int index: 4
title: "Scan"
Component.onDestruction: {
if (qrScanner) {
qrScanner.stopScanner()
}
}
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
}
QRCodeScanner {
id: qrScanner
Layout.alignment: Qt.AlignHCenter
width: 250
height: width * aspectRatio
visible: VideoDevices.listSize !== 0
onQrCodeDetected: function(code) {
console.log("QR code detected:", code)
LinkDeviceModel.addDevice(code)
}
}
ColumnLayout {
id: manualEntry
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: scrollView.width
spacing: 10 spacing: 10
ResponsiveImage{ Text {
Layout.fillWidth: true id: explanation
Layout.alignment: Qt.AlignHCenter
source: JamiResources.outline_info_24dp_svg Layout.preferredWidth: scrollView.width
fillMode: Image.PreserveAspectFit text: JamiStrings.linkDeviceEnterManually
font.pixelSize: JamiTheme.wizardViewDescriptionFontPixelSize
color: darkTheme ? JamiTheme.editLineColor : JamiTheme.darkTintedBlue lineHeight: JamiTheme.wizardViewTextLineHeight
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
}
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
}
}
}
}
// Displays error messages
Label {
id: infoLabel
visible: false
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"
Layout.maximumWidth: JamiTheme.preferredDialogWidth
Layout.margins: JamiTheme.preferredMarginSize
Layout.alignment: Qt.AlignCenter
color: success ? JamiTheme.successLabelColor : JamiTheme.redColor
padding: success ? 8 : 0
wrapMode: Text.Wrap
font.pointSize: success ? JamiTheme.textFontSize : JamiTheme.textFontSize + 3
font.kerning: true
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter wrapMode: Text.WrapAtWordBoundaryOrAnywhere
color: JamiTheme.textColor
}
background: Rectangle { ModalTextEdit {
id: infoLabelBackground id: codeInput
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: scrollView.width
Layout.preferredHeight: JamiTheme.preferredFieldHeight
placeholderText: JamiStrings.linkDeviceEnterCodePlaceholder
}
border.width: infoLabel.borderWidth Text {
border.color: infoLabel.borderColor Layout.alignment: Qt.AlignHCenter
radius: infoLabel.borderRadius Layout.maximumWidth: parent.width - 40
color: JamiTheme.secondaryBackgroundColor 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()
}
} }

View file

@ -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
}
}

View file

@ -17,11 +17,11 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Dialogs
import net.jami.Adapters 1.1 import net.jami.Adapters 1.1
import net.jami.Models 1.1 import net.jami.Models 1.1
import net.jami.Constants 1.1 import net.jami.Constants 1.1
import net.jami.Enums 1.1 import net.jami.Enums 1.1
import Qt.labs.platform
import "../../commoncomponents" import "../../commoncomponents"
import "../../mainview/components" import "../../mainview/components"
@ -77,13 +77,11 @@ Rectangle {
informativeText: JamiStrings.linkDeviceCloseWarningMessage informativeText: JamiStrings.linkDeviceCloseWarningMessage
buttons: MessageDialog.Ok | MessageDialog.Cancel buttons: MessageDialog.Ok | MessageDialog.Cancel
onButtonClicked: function(button) { onOkClicked: function(button) {
if (button === MessageDialog.Ok) {
AccountAdapter.cancelImportAccount(); AccountAdapter.cancelImportAccount();
WizardViewStepModel.previousStep(); WizardViewStepModel.previousStep();
} }
} }
}
Connections { Connections {
target: WizardViewStepModel target: WizardViewStepModel

View file

@ -126,6 +126,18 @@ public Q_SLOTS:
int state, int state,
const MapStringString& details); 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 accountId
* @param details * @param details
@ -430,6 +442,10 @@ AccountModelPimpl::AccountModelPimpl(AccountModel& linked,
&CallbacksHandler::deviceAuthStateChanged, &CallbacksHandler::deviceAuthStateChanged,
&linked, &linked,
&AccountModel::deviceAuthStateChanged); &AccountModel::deviceAuthStateChanged);
connect(&callbacksHandler,
&CallbacksHandler::addDeviceStateChanged,
&linked,
&AccountModel::addDeviceStateChanged);
connect(&callbacksHandler, connect(&callbacksHandler,
&CallbacksHandler::nameRegistrationEnded, &CallbacksHandler::nameRegistrationEnded,
this, this,
@ -627,6 +643,15 @@ AccountModelPimpl::slotDeviceAuthStateChanged(const QString& accountId,
Q_EMIT linked.deviceAuthStateChanged(accountId, state, details); 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 void
AccountModelPimpl::slotNameRegistrationEnded(const QString& accountId, AccountModelPimpl::slotNameRegistrationEnded(const QString& accountId,
int status, int status,

View file

@ -247,6 +247,12 @@ CallbacksHandler::CallbacksHandler(const Lrc& parent)
&CallbacksHandler::slotDeviceAuthStateChanged, &CallbacksHandler::slotDeviceAuthStateChanged,
Qt::QueuedConnection); Qt::QueuedConnection);
connect(&ConfigurationManager::instance(),
&ConfigurationManagerInterface::addDeviceStateChanged,
this,
&CallbacksHandler::slotAddDeviceStateChanged,
Qt::QueuedConnection);
connect(&ConfigurationManager::instance(), connect(&ConfigurationManager::instance(),
&ConfigurationManagerInterface::nameRegistrationEnded, &ConfigurationManagerInterface::nameRegistrationEnded,
this, this,
@ -687,6 +693,15 @@ CallbacksHandler::slotDeviceAuthStateChanged(const QString& accountId,
Q_EMIT deviceAuthStateChanged(accountId, state, details); 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 void
CallbacksHandler::slotNameRegistrationEnded(const QString& accountId, CallbacksHandler::slotNameRegistrationEnded(const QString& accountId,
int status, int status,

View file

@ -244,6 +244,19 @@ Q_SIGNALS:
*/ */
void deviceAuthStateChanged(const QString& accountId, int state, const MapStringString& details); 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 * Name registration has ended
* @param accountId * @param accountId
@ -587,6 +600,18 @@ private Q_SLOTS:
int state, int state,
const MapStringString& details); 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 * Emit nameRegistrationEnded
* @param accountId * @param accountId