1
0
Fork 0
mirror of https://git.jami.net/savoirfairelinux/jami-client-qt.git synced 2025-07-01 14:15:24 +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
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

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

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 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")

View file

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

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.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()
}
}

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

View file

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

View file

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

View file

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