Implementing support to request and receive original attachment file from the remote device.
This commit is contained in:
parent
cfc29fafa6
commit
e368dd4ab5
10 changed files with 422 additions and 3 deletions
|
@ -94,6 +94,11 @@ void ConversationsDbusInterface::requestConversation(const qint64& conversationI
|
|||
worker->work();
|
||||
}
|
||||
|
||||
void ConversationsDbusInterface::requestAttachmentFile(const qint64& partID, const QString& uniqueIdentifier)
|
||||
{
|
||||
m_smsInterface.getAttachment(partID, uniqueIdentifier);
|
||||
}
|
||||
|
||||
void ConversationsDbusInterface::addMessages(const QList<ConversationMessage> &messages)
|
||||
{
|
||||
QSet<qint64> updatedConversationIDs;
|
||||
|
@ -204,3 +209,7 @@ QString ConversationsDbusInterface::newId()
|
|||
{
|
||||
return QString::number(++m_lastId);
|
||||
}
|
||||
|
||||
void ConversationsDbusInterface::attachmentDownloaded(const QString& filePath, const QString& fileName) {
|
||||
Q_EMIT attachmentReceived(filePath, fileName);
|
||||
}
|
||||
|
|
|
@ -49,6 +49,12 @@ public:
|
|||
*/
|
||||
void updateConversation(const qint64& conversationID);
|
||||
|
||||
/**
|
||||
* Gets the path of the succesfully downloaded attachment file and send
|
||||
* update to the conversationModel
|
||||
*/
|
||||
void attachmentDownloaded(const QString &filePath, const QString &fileName);
|
||||
|
||||
public Q_SLOTS:
|
||||
/**
|
||||
* Return a list of the first message in every conversation
|
||||
|
@ -83,6 +89,11 @@ public Q_SLOTS:
|
|||
*/
|
||||
void requestAllConversationThreads();
|
||||
|
||||
/**
|
||||
* Send the request to SMS plugin to fetch original attachment file path
|
||||
*/
|
||||
void requestAttachmentFile(const qint64& partID, const QString& uniqueIdentifier);
|
||||
|
||||
Q_SIGNALS:
|
||||
/**
|
||||
* Emitted whenever a conversation with no cached messages is added, either because the cache
|
||||
|
@ -107,6 +118,11 @@ Q_SIGNALS:
|
|||
*/
|
||||
Q_SCRIPTABLE void conversationLoaded(qint64 conversationID, quint64 messageCount);
|
||||
|
||||
/**
|
||||
* Emitted whenever we have succesfully download a requested attachment file from the phone
|
||||
*/
|
||||
Q_SCRIPTABLE void attachmentReceived(QString filePath, QString fileName);
|
||||
|
||||
private /*methods*/:
|
||||
QString newId(); //Generates successive identifiers to use as public ids
|
||||
|
||||
|
|
|
@ -113,9 +113,11 @@
|
|||
"X-KdeConnect-OutgoingPacketType": [
|
||||
"kdeconnect.sms.request",
|
||||
"kdeconnect.sms.request_conversations",
|
||||
"kdeconnect.sms.request_conversation"
|
||||
"kdeconnect.sms.request_conversation",
|
||||
"kdeconnect.sms.request_attachment"
|
||||
],
|
||||
"X-KdeConnect-SupportedPacketType": [
|
||||
"kdeconnect.sms.messages"
|
||||
"kdeconnect.sms.messages",
|
||||
"kdeconnect.sms.attachment_file"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
#include <core/device.h>
|
||||
#include <core/daemon.h>
|
||||
#include <core/filetransferjob.h>
|
||||
|
||||
#include "plugin_sms_debug.h"
|
||||
|
||||
|
@ -39,6 +40,10 @@ bool SmsPlugin::receivePacket(const NetworkPacket& np)
|
|||
return handleBatchMessages(np);
|
||||
}
|
||||
|
||||
if (np.type() == PACKET_TYPE_SMS_ATTACHMENT_FILE && np.hasPayload()) {
|
||||
return handleSmsAttachmentFile(np);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -78,6 +83,18 @@ void SmsPlugin::requestConversation (const qint64& conversationID) const
|
|||
sendPacket(np);
|
||||
}
|
||||
|
||||
void SmsPlugin::requestAttachment(const qint64& partID, const QString& uniqueIdentifier)
|
||||
{
|
||||
const QVariantMap packetMap({
|
||||
{QStringLiteral("part_id"), partID},
|
||||
{QStringLiteral("unique_identifier"), uniqueIdentifier}
|
||||
});
|
||||
|
||||
NetworkPacket np(PACKET_TYPE_SMS_REQUEST_ATTACHMENT, packetMap);
|
||||
|
||||
sendPacket(np);
|
||||
}
|
||||
|
||||
void SmsPlugin::forwardToTelepathy(const ConversationMessage& message)
|
||||
{
|
||||
// If we don't have a valid Telepathy interface, bail out
|
||||
|
@ -110,6 +127,65 @@ bool SmsPlugin::handleBatchMessages(const NetworkPacket& np)
|
|||
return true;
|
||||
}
|
||||
|
||||
bool SmsPlugin::handleSmsAttachmentFile(const NetworkPacket& np) {
|
||||
const QString fileName = np.get<QString>(QStringLiteral("filename"));
|
||||
|
||||
QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
|
||||
cacheDir.append(QStringLiteral("/") + device()->name() + QStringLiteral("/"));
|
||||
QDir attachmentsCacheDir(cacheDir);
|
||||
|
||||
if (!attachmentsCacheDir.exists()) {
|
||||
qDebug() << attachmentsCacheDir.absolutePath() << " directory doesn't exist.";
|
||||
return false;
|
||||
}
|
||||
|
||||
QUrl fileUrl = QUrl::fromLocalFile(attachmentsCacheDir.absolutePath());
|
||||
fileUrl = fileUrl.adjusted(QUrl::StripTrailingSlash);
|
||||
fileUrl.setPath(fileUrl.path() + QStringLiteral("/") + fileName, QUrl::DecodedMode);
|
||||
|
||||
|
||||
FileTransferJob* job = np.createPayloadTransferJob(fileUrl);
|
||||
connect(job, &FileTransferJob::result, this, [this, fileName] (KJob* job) -> void {
|
||||
FileTransferJob* ftjob = qobject_cast<FileTransferJob*>(job);
|
||||
if (ftjob && !job->error()) {
|
||||
// Notify SMS app about the newly downloaded attachment
|
||||
m_conversationInterface->attachmentDownloaded(ftjob->destination().path(), fileName);
|
||||
} else {
|
||||
qCDebug(KDECONNECT_PLUGIN_SMS) << ftjob->errorString() << (ftjob ? ftjob->destination() : QUrl());
|
||||
}
|
||||
});
|
||||
job->start();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void SmsPlugin::getAttachment(const qint64& partID, const QString& uniqueIdentifier)
|
||||
{
|
||||
QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
|
||||
cacheDir.append(QStringLiteral("/") + device()->name() + QStringLiteral("/"));
|
||||
QDir fileDirectory(cacheDir);
|
||||
|
||||
bool fileFound = false;
|
||||
if (fileDirectory.exists()) {
|
||||
// Search for the attachment file locally before sending request to remote device
|
||||
fileFound = fileDirectory.exists(uniqueIdentifier);
|
||||
} else {
|
||||
bool ret = fileDirectory.mkpath(QStringLiteral("."));
|
||||
if (!ret) {
|
||||
qWarning() << "couldn't create directorty " << fileDirectory.absolutePath();
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileFound) {
|
||||
// If the file is not present in the local dir request the remote device for the file
|
||||
requestAttachment(partID, uniqueIdentifier);
|
||||
} else {
|
||||
const QString fileDestination = fileDirectory.absoluteFilePath(uniqueIdentifier);
|
||||
m_conversationInterface->attachmentDownloaded(fileDestination, uniqueIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
QString SmsPlugin::dbusPath() const
|
||||
{
|
||||
return QStringLiteral("/modules/kdeconnect/devices/") + device()->id() + QStringLiteral("/sms");
|
||||
|
|
|
@ -103,6 +103,24 @@
|
|||
*/
|
||||
#define PACKET_TYPE_SMS_REQUEST_CONVERSATION QStringLiteral("kdeconnect.sms.request_conversation")
|
||||
|
||||
/**
|
||||
* Packet sent to request an attachment file in a particular message of a conversation
|
||||
*
|
||||
* The body should look like so:
|
||||
* "part_id": <long> // Part id of the attachment
|
||||
* "unique_identifier": <String> // It can be any hash code or unique name of the file
|
||||
*/
|
||||
#define PACKET_TYPE_SMS_REQUEST_ATTACHMENT QStringLiteral("kdeconnect.sms.request_attachment")
|
||||
|
||||
/**
|
||||
* Packet used to send original attachment file from mms database to desktop
|
||||
* <p>
|
||||
* The following fields are available:
|
||||
* "thread_id": <long> // Thread to which the attachment belongs
|
||||
* "filename": <String> // Name of the attachment file in the database
|
||||
*/
|
||||
#define PACKET_TYPE_SMS_ATTACHMENT_FILE QStringLiteral("kdeconnect.sms.attachment_file")
|
||||
|
||||
Q_DECLARE_LOGGING_CATEGORY(KDECONNECT_PLUGIN_SMS)
|
||||
|
||||
class Q_DECL_EXPORT SmsPlugin
|
||||
|
@ -137,6 +155,17 @@ public Q_SLOTS:
|
|||
|
||||
Q_SCRIPTABLE void launchApp();
|
||||
|
||||
/**
|
||||
* Send a request to the remote device for a particulr attachment file
|
||||
*/
|
||||
Q_SCRIPTABLE void requestAttachment(const qint64& partID, const QString& uniqueIdentifier);
|
||||
|
||||
/**
|
||||
* Searches the requested file in the application's cache directory,
|
||||
* if not found then sends the request to remote device
|
||||
*/
|
||||
Q_SCRIPTABLE void getAttachment(const qint64& partID, const QString& uniqueIdentifier);
|
||||
|
||||
private:
|
||||
|
||||
/**
|
||||
|
@ -149,6 +178,11 @@ private:
|
|||
*/
|
||||
bool handleBatchMessages(const NetworkPacket& np);
|
||||
|
||||
/**
|
||||
* Handle a packet of type PACKET_TYPE_SMS_ATTACHMENT_FILE which contains an attachment file
|
||||
*/
|
||||
bool handleSmsAttachmentFile(const NetworkPacket& np);
|
||||
|
||||
QDBusInterface m_telepathyInterface;
|
||||
ConversationsDbusInterface* m_conversationInterface;
|
||||
};
|
||||
|
|
|
@ -73,6 +73,8 @@ void ConversationModel::setDeviceId(const QString& deviceId)
|
|||
connect(m_conversationsInterface, SIGNAL(conversationLoaded(qint64, quint64)), this, SLOT(handleConversationLoaded(qint64, quint64)));
|
||||
connect(m_conversationsInterface, SIGNAL(conversationCreated(QDBusVariant)), this, SLOT(handleConversationCreated(QDBusVariant)));
|
||||
|
||||
connect(m_conversationsInterface, SIGNAL(attachmentReceived(QString, QString)), this, SIGNAL(filePathReceived(QString, QString)));
|
||||
|
||||
QQmlApplicationEngine* engine = qobject_cast<QQmlApplicationEngine*>(QQmlEngine::contextForObject(this)->engine());
|
||||
m_thumbnailsProvider = dynamic_cast<ThumbnailsProvider*>(engine->imageProvider(QStringLiteral("thumbnailsProvider")));
|
||||
|
||||
|
@ -214,3 +216,8 @@ QString ConversationModel::getCharCountInfo(const QString& message) const
|
|||
return QString();
|
||||
}
|
||||
}
|
||||
|
||||
void ConversationModel::requestAttachmentPath(const qint64& partID, const QString& uniqueIdentifier)
|
||||
{
|
||||
m_conversationsInterface->requestAttachmentFile(partID, uniqueIdentifier);
|
||||
}
|
||||
|
|
|
@ -54,8 +54,11 @@ public:
|
|||
|
||||
Q_INVOKABLE QString getCharCountInfo(const QString& message) const;
|
||||
|
||||
Q_INVOKABLE void requestAttachmentPath(const qint64& partID, const QString& UniqueIdentifier);
|
||||
|
||||
Q_SIGNALS:
|
||||
void loadingFinished();
|
||||
void filePathReceived(QString filePath, QString fileName);
|
||||
|
||||
private Q_SLOTS:
|
||||
void handleConversationUpdate(const QDBusVariant &message);
|
||||
|
|
198
smsapp/qml/AttachmentViewer.qml
Normal file
198
smsapp/qml/AttachmentViewer.qml
Normal file
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* Copyright (C) 2020 Aniket Kumar <anikketkumar786@gmail.com>
|
||||
*
|
||||
* 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 2 of
|
||||
* the License or (at your option) version 3 or any later version
|
||||
* accepted by the membership of KDE e.V. (or its successor approved
|
||||
* by the membership of KDE e.V.), which shall act as a proxy
|
||||
* defined in Section 14 of version 3 of the license.
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import QtQuick 2.12
|
||||
import QtQuick.Layouts 1.12
|
||||
import QtQuick.Controls 2.12
|
||||
import org.kde.kirigami 2.13 as Kirigami
|
||||
import QtMultimedia 5.12
|
||||
|
||||
Kirigami.Page {
|
||||
id: root
|
||||
property string filePath
|
||||
property string mimeType
|
||||
|
||||
contextualActions: [
|
||||
Kirigami.Action {
|
||||
text: i18nd("kdeconnect-sms", "Open with default")
|
||||
icon.name: "window-new"
|
||||
onTriggered: {
|
||||
Qt.openUrlExternally(filePath);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
contentItem: Rectangle {
|
||||
anchors.fill: parent
|
||||
|
||||
Rectangle {
|
||||
id: imageViewer
|
||||
visible: mimeType.match("image")
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: image.width
|
||||
height: parent.height - y
|
||||
y: root.implicitHeaderHeight
|
||||
color: parent.color
|
||||
|
||||
Image {
|
||||
id: image
|
||||
source: parent.visible ? filePath : ""
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: sourceSize.width
|
||||
height: parent.height
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
||||
}
|
||||
|
||||
MediaPlayer {
|
||||
id: mediaPlayer
|
||||
source: filePath
|
||||
|
||||
onPositionChanged: {
|
||||
if (mediaPlayer.position > 1000 && mediaPlayer.duration - mediaPlayer.position < 1000) {
|
||||
playAndPauseButton.icon.name = "media-playback-start"
|
||||
mediaPlayer.pause()
|
||||
mediaPlayer.seek(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - mediaControls.height
|
||||
anchors.topMargin: root.implicitHeaderHeight
|
||||
|
||||
VideoOutput {
|
||||
anchors.fill: parent
|
||||
source: mediaPlayer
|
||||
fillMode: VideoOutput.PreserveAspectFit
|
||||
|
||||
// By default QML's videoOutput element rotates the vdeeo files by 90 degrees in clockwise direction
|
||||
orientation: -90
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: mediaControls
|
||||
visible: mimeType.match("video")
|
||||
width: parent.width
|
||||
height: 50
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
color: Kirigami.Theme.backgroundColor
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: "lightGray"
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: parent.width
|
||||
|
||||
Rectangle {
|
||||
id: progressBar
|
||||
Layout.fillWidth: parent
|
||||
Layout.leftMargin: Kirigami.Units.largeSpacing
|
||||
Layout.rightMargin: Kirigami.Units.largeSpacing
|
||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
||||
radius: 5
|
||||
height: 5
|
||||
|
||||
color: "gray"
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
radius: 5
|
||||
|
||||
width: mediaPlayer.duration > 0 ? parent.width*mediaPlayer.position/mediaPlayer.duration : 0
|
||||
|
||||
color: {
|
||||
Kirigami.Theme.colorSet = Kirigami.Theme.View
|
||||
var accentColor = Kirigami.Theme.highlightColor
|
||||
return Qt.tint(Kirigami.Theme.backgroundColor, Qt.rgba(accentColor.r, accentColor.g, accentColor.b, 1))
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
|
||||
onClicked: {
|
||||
if (mediaPlayer.seekable) {
|
||||
mediaPlayer.seek(mediaPlayer.duration * mouse.x/width);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
Button {
|
||||
id: backwardButton
|
||||
icon.name: "media-seek-backward"
|
||||
|
||||
onClicked: {
|
||||
if (mediaPlayer.seekable) {
|
||||
mediaPlayer.seek(mediaPlayer.position - 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: playAndPauseButton
|
||||
icon.name: "media-playback-pause"
|
||||
|
||||
onClicked: {
|
||||
if (icon.name == "media-playback-start") {
|
||||
mediaPlayer.play()
|
||||
icon.name = "media-playback-pause"
|
||||
} else {
|
||||
mediaPlayer.pause()
|
||||
icon.name = "media-playback-start"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: forwardButton
|
||||
icon.name: "media-seek-forward"
|
||||
|
||||
onClicked: {
|
||||
if (mediaPlayer.seekable) {
|
||||
mediaPlayer.seek(mediaPlayer.position + 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
mediaPlayer.play()
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@ Item {
|
|||
property int partID
|
||||
property string mimeType
|
||||
property string uniqueIdentifier
|
||||
property string sourcePath
|
||||
property string sourcePath: ""
|
||||
|
||||
readonly property int elementWidth: 100
|
||||
readonly property int elementHeight: 100
|
||||
|
@ -24,6 +24,16 @@ Item {
|
|||
width: thumbnailElement.visible ? thumbnailElement.width : elementWidth
|
||||
height: thumbnailElement.visible ? thumbnailElement.height : elementHeight
|
||||
|
||||
Component {
|
||||
id: attachmentViewer
|
||||
|
||||
AttachmentViewer {
|
||||
filePath: root.sourcePath
|
||||
mimeType: root.mimeType
|
||||
title: uniqueIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
Image {
|
||||
id: thumbnailElement
|
||||
visible: mimeType.match("image") || mimeType.match("video")
|
||||
|
@ -48,11 +58,29 @@ Item {
|
|||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: {
|
||||
if (root.sourcePath == "") {
|
||||
conversationModel.requestAttachmentPath(root.partID, root.uniqueIdentifier)
|
||||
} else {
|
||||
openMedia();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
icon.name: "media-playback-start"
|
||||
visible: root.mimeType.match("video")
|
||||
anchors.horizontalCenter: thumbnailElement.horizontalCenter
|
||||
anchors.verticalCenter: thumbnailElement.verticalCenter
|
||||
onClicked: {
|
||||
if (root.sourcePath == "") {
|
||||
conversationModel.requestAttachmentPath(root.partID, root.uniqueIdentifier)
|
||||
} else {
|
||||
openMedia();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -63,6 +91,19 @@ Item {
|
|||
radius: messageBox.radius
|
||||
color: "lightgrey"
|
||||
|
||||
Audio {
|
||||
id: audioPlayer
|
||||
source: root.sourcePath
|
||||
|
||||
onStopped: {
|
||||
audioPlayButton.icon.name = "media-playback-start"
|
||||
}
|
||||
|
||||
onPlaying: {
|
||||
audioPlayButton.icon.name = "media-playback-stop"
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
@ -72,6 +113,18 @@ Item {
|
|||
id : audioPlayButton
|
||||
icon.name: "media-playback-start"
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
|
||||
onClicked: {
|
||||
if (root.sourcePath != "") {
|
||||
if (icon.name == "media-playback-start") {
|
||||
audioPlayer.play()
|
||||
} else {
|
||||
audioPlayer.stop()
|
||||
}
|
||||
} else {
|
||||
conversationModel.requestAttachmentPath(root.partID, root.uniqueIdentifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
|
@ -79,4 +132,24 @@ Item {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: conversationModel
|
||||
onFilePathReceived: {
|
||||
if (root.uniqueIdentifier == fileName && root.sourcePath == "") {
|
||||
root.sourcePath = "file:" + filePath
|
||||
|
||||
if (root.mimeType.match("audio")) {
|
||||
audioPlayer.source = root.sourcePath
|
||||
audioPlayer.play()
|
||||
} else if (root.mimeType.match("image") || root.mimeType.match("video")) {
|
||||
openMedia();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openMedia() {
|
||||
applicationWindow().pageStack.layers.push(attachmentViewer)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,5 +5,6 @@
|
|||
<file>qml/ConversationDisplay.qml</file>
|
||||
<file>qml/ChatMessage.qml</file>
|
||||
<file>qml/MessageAttachments.qml</file>
|
||||
<file>qml/AttachmentViewer.qml</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
|
|
Loading…
Reference in a new issue