From fdd1d0339f519b92753fc9d169218825fbf4eddf Mon Sep 17 00:00:00 2001 From: Alexey Edelev Date: Fri, 20 Jan 2023 17:43:29 +0100 Subject: [PATCH] Backport the simple chat example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport the simple chat example. The application implements a simple messenger using Qt GRPC. It shows how to utilize the server-side streaming and use the call-based user/password credentials. Task-number: QTBUG-109598 Pick-to: 6.5 Change-Id: I4ca3695780a9cc9991c92c4423e3af9d8e0eaf35 Reviewed-by: Leena Miettinen Reviewed-by: MÃ¥rten Nordheim --- examples/grpc/CMakeLists.txt | 1 + examples/grpc/chat/CMakeLists.txt | 5 + examples/grpc/chat/client/CMakeLists.txt | 61 +++++ examples/grpc/chat/client/ChatInputField.qml | 23 ++ examples/grpc/chat/client/ChatView.qml | 153 +++++++++++++ examples/grpc/chat/client/Main.qml | 151 +++++++++++++ .../grpc/chat/client/chatmessagemodel.cpp | 90 ++++++++ examples/grpc/chat/client/chatmessagemodel.h | 32 +++ examples/grpc/chat/client/main.cpp | 26 +++ .../chat/client/qt_logo_green_64x64px.png | Bin 0 -> 2318 bytes .../grpc/chat/client/simplechatengine.cpp | 172 ++++++++++++++ examples/grpc/chat/client/simplechatengine.h | 75 ++++++ examples/grpc/chat/proto/simplechat.proto | 41 ++++ examples/grpc/chat/server/CMakeLists.txt | 58 +++++ examples/grpc/chat/server/main.cpp | 213 ++++++++++++++++++ .../grpc/doc/images/chatconversation.webp | Bin 0 -> 8734 bytes examples/grpc/doc/images/chatlogin.webp | Bin 0 -> 8880 bytes examples/grpc/doc/src/chat.qdoc | 78 +++++++ 18 files changed, 1179 insertions(+) create mode 100644 examples/grpc/chat/CMakeLists.txt create mode 100644 examples/grpc/chat/client/CMakeLists.txt create mode 100644 examples/grpc/chat/client/ChatInputField.qml create mode 100644 examples/grpc/chat/client/ChatView.qml create mode 100644 examples/grpc/chat/client/Main.qml create mode 100644 examples/grpc/chat/client/chatmessagemodel.cpp create mode 100644 examples/grpc/chat/client/chatmessagemodel.h create mode 100644 examples/grpc/chat/client/main.cpp create mode 100644 examples/grpc/chat/client/qt_logo_green_64x64px.png create mode 100644 examples/grpc/chat/client/simplechatengine.cpp create mode 100644 examples/grpc/chat/client/simplechatengine.h create mode 100644 examples/grpc/chat/proto/simplechat.proto create mode 100644 examples/grpc/chat/server/CMakeLists.txt create mode 100644 examples/grpc/chat/server/main.cpp create mode 100644 examples/grpc/doc/images/chatconversation.webp create mode 100644 examples/grpc/doc/images/chatlogin.webp create mode 100644 examples/grpc/doc/src/chat.qdoc diff --git a/examples/grpc/CMakeLists.txt b/examples/grpc/CMakeLists.txt index 729bf62e..8b444d12 100644 --- a/examples/grpc/CMakeLists.txt +++ b/examples/grpc/CMakeLists.txt @@ -6,4 +6,5 @@ if(TARGET Qt6::Quick AND TARGET Qt6::qtprotobufgen AND TARGET Qt6::qtgrpcgen) qt_internal_add_example(magic8ball) + qt_internal_add_example(chat) endif() diff --git a/examples/grpc/chat/CMakeLists.txt b/examples/grpc/chat/CMakeLists.txt new file mode 100644 index 00000000..ba4c6f15 --- /dev/null +++ b/examples/grpc/chat/CMakeLists.txt @@ -0,0 +1,5 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +add_subdirectory(client) +add_subdirectory(server) diff --git a/examples/grpc/chat/client/CMakeLists.txt b/examples/grpc/chat/client/CMakeLists.txt new file mode 100644 index 00000000..d567656c --- /dev/null +++ b/examples/grpc/chat/client/CMakeLists.txt @@ -0,0 +1,61 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +cmake_minimum_required(VERSION 3.16) +project(GrpcChatClient LANGUAGES CXX) + +if(NOT DEFINED INSTALL_EXAMPLESDIR) + set(INSTALL_EXAMPLESDIR "examples") +endif() + +set(INSTALL_EXAMPLEDIR "${INSTALL_EXAMPLESDIR}/grpc/chat") + +find_package(Qt6 REQUIRED COMPONENTS Core Protobuf Grpc Quick) + +qt_standard_project_setup() + +qt_add_executable(grpcchatclient + main.cpp + simplechatengine.cpp simplechatengine.h + chatmessagemodel.cpp chatmessagemodel.h +) + +qt_add_qml_module(grpcchatclient + URI qtgrpc.examples.chat + VERSION 1.0 + AUTO_RESOURCE_PREFIX + QML_FILES + Main.qml + ChatInputField.qml + ChatView.qml +) + +qt_add_resources(grpcchatclient + "assets" + PREFIX "/" + FILES + "qt_logo_green_64x64px.png" +) + +qt_add_protobuf(grpcchatclient + PROTO_FILES + ../proto/simplechat.proto +) + +qt_add_grpc(grpcchatclient CLIENT + PROTO_FILES + ../proto/simplechat.proto +) + +target_link_libraries(grpcchatclient + PRIVATE + Qt::Gui + Qt::Quick + Qt::Grpc +) + +install(TARGETS grpcchatclient + RUNTIME DESTINATION "${INSTALL_EXAMPLEDIR}" + BUNDLE DESTINATION "${INSTALL_EXAMPLEDIR}" + LIBRARY DESTINATION "${INSTALL_EXAMPLEDIR}" +) diff --git a/examples/grpc/chat/client/ChatInputField.qml b/examples/grpc/chat/client/ChatInputField.qml new file mode 100644 index 00000000..7b4a517e --- /dev/null +++ b/examples/grpc/chat/client/ChatInputField.qml @@ -0,0 +1,23 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +import QtQuick +import QtQuick.Controls + +TextField { + id: _inputField + width: 200 + color: "#cecfd5" + placeholderTextColor: "#9d9faa" + font.pointSize: 14 + padding: 10 + background: Rectangle { + radius: 5 + border { + width: 1 + color: _inputField.focus ? "#41cd52" : "#f3f3f4" + } + color: "#222840" + } +} diff --git a/examples/grpc/chat/client/ChatView.qml b/examples/grpc/chat/client/ChatView.qml new file mode 100644 index 00000000..7c2bc985 --- /dev/null +++ b/examples/grpc/chat/client/ChatView.qml @@ -0,0 +1,153 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +import QtQuick + +import qtgrpc.examples.chat + +Rectangle { + id: root + anchors.fill: parent + color: "#09102b" + onVisibleChanged: { + if (visible) { + _inputField.forceActiveFocus() + } + } + + ListView { + id: messageListView + anchors.top: parent.top + anchors.bottom: _inputField.top + anchors.left: parent.left + anchors.right: parent.right + model: SimpleChatEngine.messages + clip: true + delegate: Item { + height: _imageWrapper.height + 10 + width: root.width + Item { + id: _imageWrapper + height: _messageColumn.height + 20 + width: parent.width/2 - 20 + property bool ownMessage: SimpleChatEngine.userName === model.from + anchors{ + right: _imageWrapper.ownMessage ? parent.right : undefined + left: _imageWrapper.ownMessage ? undefined : parent.left + rightMargin: _imageWrapper.ownMessage ? 10 : 0 + leftMargin: _imageWrapper.ownMessage ? 0 : 10 + verticalCenter: parent.verticalCenter + } + + Rectangle { + anchors.fill: parent + radius: 5 + color: _imageWrapper.ownMessage ? "#9d9faa" : "#53586b" + border.color: "#41cd52" + border.width: 1 + } + + Column { + id: _messageColumn + anchors { + left: parent.left + right: parent.right + leftMargin: 10 + rightMargin: 10 + verticalCenter: parent.verticalCenter + } + Text { + id: _userName + property string from: _imageWrapper.ownMessage ? qsTr("You") : model.from + anchors.left: parent.left + anchors.right: parent.right + font.pointSize: 12 + font.weight: Font.Bold + color: "#f3f3f4" + text: _userName.from + ": " + } + + Loader { + id: delegateLoader + anchors.left: parent.left + anchors.right: parent.right + height: item ? item.height : 0 + sourceComponent: model.type === SimpleChatEngine.Image ? imageDelegate : textDelegate + onItemChanged: { + if (item) { + item.content = model.content + } + } + } + } + } + } + onCountChanged: { + Qt.callLater( messageListView.positionViewAtEnd ) + } + } + + Component { + id: textDelegate + Item { + property alias content: innerText.text + height: childrenRect.height + Text { + id: innerText + anchors.left: parent.left + anchors.right: parent.right + height: implicitHeight + font.pointSize: 12 + color: "#f3f3f4" + wrapMode: Text.Wrap + } + } + } + Component { + id: imageDelegate + Item { + clip: true + property alias content: innerImage.source + height: childrenRect.height + Image { + id: innerImage + width: implicitWidth + height: implicitHeight + } + } + } + + ChatInputField { + id: _inputField + focus: true + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + margins: 20 + } + + placeholderText: qsTr("Start typing here or paste an image") + onAccepted: { + SimpleChatEngine.sendMessage(_inputField.text) + _inputField.text = "" + } + + Keys.onPressed: (event)=> { + if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) { + console.log("Ctrl + V") + switch (SimpleChatEngine.clipBoardContentType) { + case SimpleChatEngine.Text: + paste() + break + case SimpleChatEngine.Image: + SimpleChatEngine.sendImageFromClipboard() + break + } + + event.accepted = true + } + } + } +} diff --git a/examples/grpc/chat/client/Main.qml b/examples/grpc/chat/client/Main.qml new file mode 100644 index 00000000..012bfede --- /dev/null +++ b/examples/grpc/chat/client/Main.qml @@ -0,0 +1,151 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +import QtQuick +import QtQuick.Controls + +import qtgrpc.examples.chat + +ApplicationWindow { + id: mainWindow + visible: true + width: 640 + height: 480 + minimumWidth: 640 + minimumHeight: 480 + title: qsTr("Simple Chat") + Rectangle { + id: background + anchors.fill: parent + color: "#09102b" + } + + Row { + spacing: 10 + anchors { + top: parent.top + topMargin: 10 + left: parent.left + leftMargin: 20 + } + Image { + source: "qrc:/qt_logo_green_64x64px.png" + width: implicitWidth + height: implicitHeight + } + Text { + anchors.verticalCenter: parent.verticalCenter + color: "#f3f3f4" + font.pointSize: 20 + text: qsTr("Chat") + } + } + + Column { + id: loginControl + spacing: 5 + visible: SimpleChatEngine.state !== SimpleChatEngine.Connected + anchors.centerIn: parent + enabled: SimpleChatEngine.state === SimpleChatEngine.Disconnected + ChatInputField { + id: loginField + width: 200 + placeholderText: qsTr("Login") + onAccepted: { + SimpleChatEngine.login(loginField.text, passwordField.text) + } + onVisibleChanged: { + if (visible) { + loginField.forceActiveFocus() + } + } + Component.onCompleted: { + if (visible) { + loginField.forceActiveFocus() + } + } + } + ChatInputField { + id: passwordField + echoMode: TextInput.Password + placeholderText: qsTr("Password") + onAccepted: { + SimpleChatEngine.login(loginField.text, passwordField.text) + } + } + Button { + id: loginButton + anchors.horizontalCenter: parent.horizontalCenter + width: enterText.implicitWidth + 20 + height: 40 + background: Rectangle { + radius: 5 + border { + width: 1 + color: loginButton.pressed ? "#41cd52" : "#f3f3f4" + } + color:"#53586b" + Text { + id: enterText + text : qsTr("Enter") + color: "#f3f3f4" + anchors.centerIn: parent + font.pointSize: 14 + } + } + + onClicked: { + SimpleChatEngine.login(loginField.text, passwordField.text) + } + } + } + + ChatView { + id: chatView + visible: SimpleChatEngine.state === SimpleChatEngine.Connected + } + + Text { + id: connectingText + visible: SimpleChatEngine.state === SimpleChatEngine.Connecting + anchors.top: loginControl.bottom + anchors.topMargin: 10 + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Connecting...") + font.pointSize: 14 + color: "#f3f3f4" + } + + Text { + id: authFailedText + visible: false + anchors.top: loginControl.bottom + anchors.topMargin: 10 + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("This username with this password doesn't exists.") + font.pointSize: 14 + color: "#f3f3f4" + onVisibleChanged: { + if (authFailedText.visible) { + fadeOutTimer.restart() + } else { + fadeOutTimer.stop() + } + } + + Timer { + id: fadeOutTimer + onTriggered: { + authFailedText.visible = false + } + } + } + + Connections { + target: SimpleChatEngine + function onAuthFailed() { + authFailedText.visible = true; + } + } +} diff --git a/examples/grpc/chat/client/chatmessagemodel.cpp b/examples/grpc/chat/client/chatmessagemodel.cpp new file mode 100644 index 00000000..d00ce14d --- /dev/null +++ b/examples/grpc/chat/client/chatmessagemodel.cpp @@ -0,0 +1,90 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include +#include + +#include "chatmessagemodel.h" +#include "simplechat.qpb.h" + +namespace { +enum Role { + Content = Qt::UserRole + 1, + Type, + From, +}; + +QString getImage(const QByteArray &data) +{ + return QString("data:image/png;base64,") + data.toBase64(); +} + +QString getImageScaled(const QByteArray &data) +{ + QImage img = QImage::fromData(data, "PNG"); + img = img.scaled(300, 300, Qt::KeepAspectRatio); + QByteArray scaledData; + QBuffer buffer(&scaledData); + img.save(&buffer, "PNG"); + return getImage(scaledData); +} + +QString getText(const QByteArray &data) +{ + return QString::fromUtf8(data); +} +} // namespace + +ChatMessageModel::ChatMessageModel(QObject *parent) : QAbstractListModel(parent) { } + +QHash ChatMessageModel::roleNames() const +{ + static QHash s_roleNames; + if (s_roleNames.isEmpty()) { + s_roleNames.insert(Content, "content"); + s_roleNames.insert(Type, "type"); + s_roleNames.insert(From, "from"); + } + return s_roleNames; +} + +QVariant ChatMessageModel::data(const QModelIndex &index, int role) const +{ + int row = index.row(); + + if (row < 0 || row >= m_container.count() || !m_container.at(row)) + return QVariant(); + + auto dataPtr = m_container.at(row).get(); + + switch (role) { + case Content: + if (dataPtr->type() == qtgrpc::examples::chat::ChatMessage::Image) + return QVariant::fromValue(getImageScaled(dataPtr->content())); + else + return QVariant::fromValue(getText(dataPtr->content())); + case Type: + return QVariant::fromValue(dataPtr->type()); + case From: + return QVariant::fromValue(dataPtr->from()); + } + + return QVariant(); +} + +int ChatMessageModel::rowCount(const QModelIndex &) const +{ + return m_container.count(); +} + +void ChatMessageModel::append( + const QList> &messages) +{ + if (messages.size() > 0) { + beginInsertRows(QModelIndex(), m_container.size(), + m_container.size() + messages.size() - 1); + m_container.append(messages); + endInsertRows(); + } +} diff --git a/examples/grpc/chat/client/chatmessagemodel.h b/examples/grpc/chat/client/chatmessagemodel.h new file mode 100644 index 00000000..eb75bace --- /dev/null +++ b/examples/grpc/chat/client/chatmessagemodel.h @@ -0,0 +1,32 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#ifndef CHATMESSAGEMODEL_H +#define CHATMESSAGEMODEL_H + +#include + +#include + +namespace qtgrpc::examples::chat { +class ChatMessage; +} + +class ChatMessageModel : public QAbstractListModel +{ + Q_OBJECT +public: + explicit ChatMessageModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent) const override; + QHash roleNames() const override; + QVariant data(const QModelIndex &index, int role) const override; + + void append(const QList> &messages); + +private: + QList> m_container; +}; + +#endif // CHATMESSAGEMODEL_H diff --git a/examples/grpc/chat/client/main.cpp b/examples/grpc/chat/client/main.cpp new file mode 100644 index 00000000..748ff86c --- /dev/null +++ b/examples/grpc/chat/client/main.cpp @@ -0,0 +1,26 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include +#include +#include +#include +#include + +int main(int argc, char *argv[]) +{ + QGuiApplication app(argc, argv); + + QGuiApplication::setWindowIcon(QIcon(":/qt_logo_green_64x64px.png")); + + QQmlApplicationEngine engine; + + QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed, &app, [](){ + QCoreApplication::exit(-1); + }, Qt::QueuedConnection); + + engine.loadFromModule("qtgrpc.examples.chat", "Main"); + + return app.exec(); +} diff --git a/examples/grpc/chat/client/qt_logo_green_64x64px.png b/examples/grpc/chat/client/qt_logo_green_64x64px.png new file mode 100644 index 0000000000000000000000000000000000000000..9cb2e01d3895ed7d1a81e91ed5393b474d041671 GIT binary patch literal 2318 zcmaJ>dpuO@8eX|f#fT_jcUg^4VXlUm#Uy4%jcq0kNe5-jEKJO-F&8s*bBV6fFFVmG z9PLWG$k{e>E89s#x$LewDZA03AMNNOcV|sIwf{Js^{utO?|XmG^Stl(zVBLx{COKp zjBJbm05IWfWbx5?z3!QZLEk@#p_Rt9tZAR!{b3v?K~Mj}Jm0N~-NkqLw`uo8@bqr_4M?oG`N94Hnsa9gQd zB3H(Qqs1E&<#0eEFHo2mBczLPp6fsl4TK6vV5I=mNMfZ5NW;K=(uL5q&P>38pCHN@ z2JX+Gg1P=66OqFp74PaSBvHtq8y!!g(#bRzCy-1exe|yj1QOkuM1j!Tg$RDWaHu!A zC=%kcd_Ma^cMM##QYnK71hra?S5xqaJPPH~=>#H~Kqfn*2xmotR4LFnOBJ@W3M^P5 zl#69bF(L(Zih>A4rDWjHNdG*7MD|5is`#8HG+_jdKt>?piMk`r0=eA(50yy1pcP6! z{59VHDXa)gkii5#tUy$9AzHXdTb(Nz#FWDVB_a<*kl5KQ`bQ&5L=lb1Kqga{H7cG8 zuI37aVyVu&<`a+0g*Z}$QXmz=92NtID&fUq5k#ZV$aFG`NF_48NhG!_jn1NZ(_Dx& zsuzn+_HmizvJjz40!x*%T+vr9>6=_#OGsp>XBI3M$HO8YIU)f+9UT&XGZrTMn|hzQ zqHo5+^!X;2fCfX*75Fa&pEaRfq+5PzUv%^31AwJy*UQoNo-@?o0|17J9F|w0=E-2l zuGj#0k>{SukjjdImX=#jb9fiA!8xP#D;KV+J`Nu>OQj5(TBO=fr$O2k>3&m8;eD3S z??EYRxlOJ)^XAbc&}W{XnQcm`$w+L?KjUK_eJHPZ^roiz!pF|0y5h>PKN#Gyk-=S0 z-_<;)_o>^uMFw6;IqU_vCG&x=W8o3LeU{zSOW_iAQD^4%_fm`Q`DUrZoH=GkhOYnz zZ)-3>1SL-|$tojL9}LoRfSa)%xAo<4Ew(Ob;7uBJe4-#|s|oCD+rJ1pXufc0Ln_hw zx=CtsNXMHx?8x!50wB8e;ZS^QR_Wykcc*|;Q7(O^&v%rzPcNQ)Rg)cm#cP|9tvfln-kpP(&Lg`I5UunF!vwi zzYl43O%5vA200Woj)Kg}=Uv}pDwO2q^7`B^Mf0-1y?|mTp)c|cDFi(b?#yqlW@^?;lJE^Nd;rf%2bf%g@Y!PRS73JTteD%<|*~-6U zt+3xd;YT8NecBWA8EH`Ni~Os1P3s-BjNU#d%;cBTZUNd1Em3oB-C-#-XpRAjLt8r% zaEdc-x0WXJj>>$ojSWQer4G-J_#IzRpIMUsw#h(c6j}YYX%dAP^6h&pL$MvVRrIc$ zB&*gZ-vdd+g>Br$L15-8+782!%@}I-B|38DgyXg{=CAraQB@o33Ul7wN(}V9XJ=+? zxej=CF!SWnxWz{fLs1Kgzgvk*GX+A%fA@`$5d>8iaIdQk=hv@pX@BpYHjxzC%L{Wp zH?D24?|N|{9O&U&Qp#DE9tL?AS3p{-tJTA@`*XKu-8=&r@95|?1)TcoPFYo7EM8`P z*O}j6(EZjR!@`W?SY?=fEj;oc+7vz6Q`PD6OT&R|caAt~zO?XAp-=4Qq<9HrogH5-i>{WlCN4iVDk0Ig9Y`m%jui}QscgFW{Z zMXx0m69O77JB4ifaVRu}d%aTrt!PY`c#tfW?YB>OsAsTS;5n}@V8gv* zjjvs+TS*bd?KXCf-p3|(wa-kEcT>g~2Evs*#h%WDQNcLNM~Gm23SvsXla3xZacurg zy~&*?V)L+_j#dBpt-K?6x2f-uEuvIr^5nZ6Bec1e<<>#bYu6~o{fT?jy+YnZk1FOD z$L81+_YPXy(Ydc{#mY9IdH>{Nn`gG$!V3qj9~+hE%-|8~~{PIT4i$ZM( z(U#3+r8Wdh;DikU1MqD&k-c8cVAyzbV0J`g?=!Fh27~qQoMHx`e_;UNuz9Q!@9>oW E0+|Y_$p8QV literal 0 HcmV?d00001 diff --git a/examples/grpc/chat/client/simplechatengine.cpp b/examples/grpc/chat/client/simplechatengine.cpp new file mode 100644 index 00000000..a4e50fcd --- /dev/null +++ b/examples/grpc/chat/client/simplechatengine.cpp @@ -0,0 +1,172 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include "simplechatengine.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +SimpleChatEngine::SimpleChatEngine(QObject *parent) + : QObject(parent), + m_state(Disconnected), + m_client(new qtgrpc::examples::chat::SimpleChat::Client), + m_clipBoard(QGuiApplication::clipboard()) +{ + qRegisterProtobufTypes(); + if (m_clipBoard) + QObject::connect(m_clipBoard, &QClipboard::dataChanged, this, + &SimpleChatEngine::clipBoardContentTypeChanged); +} + +SimpleChatEngine::~SimpleChatEngine() +{ + delete m_client; +} + +void SimpleChatEngine::login(const QString &name, const QString &password) +{ + if (m_state != Disconnected) + return; + + setState(Connecting); + QUrl url("http://localhost:65002"); + + // ![0] + std::shared_ptr channel( + new QGrpcHttp2Channel(url, + QGrpcUserPasswordCredentials(name, password.toUtf8()) + | QGrpcInsecureChannelCredentials())); + // ![0] + + m_client->attachChannel(channel); + + // ![1] + auto stream = m_client->streamMessageList(qtgrpc::examples::chat::None()); + QObject::connect(stream.get(), &QGrpcStream::errorOccurred, this, + [this, stream](const QGrpcStatus &status) { + qCritical() + << "Stream error(" << status.code() << "):" << status.message(); + if (status.code() == QGrpcStatus::Unauthenticated) + emit authFailed(); + }); + + QObject::connect(stream.get(), &QGrpcStream::finished, this, + [this, stream]() { setState(Disconnected); }); + + QObject::connect(stream.get(), &QGrpcStream::messageReceived, this, [this, name, stream]() { + if (m_userName != name) { + m_userName = name; + emit userNameChanged(); + } + setState(Connected); + m_messages.append(stream->read().messages()); + }); + // ![1] +} + +void SimpleChatEngine::sendMessage(const QString &content) +{ + // ![2] + qtgrpc::examples::chat::ChatMessage msg; + msg.setContent(content.toUtf8()); + msg.setType(qtgrpc::examples::chat::ChatMessage::Text); + msg.setTimestamp(QDateTime::currentMSecsSinceEpoch()); + msg.setFrom(m_userName); + m_client->sendMessage(msg); + // ![2] +} + +SimpleChatEngine::ContentType SimpleChatEngine::clipBoardContentType() const +{ + if (m_clipBoard != nullptr) { + const QMimeData *mime = m_clipBoard->mimeData(); + if (mime != nullptr) { + if (mime->hasImage() || mime->hasUrls()) + return SimpleChatEngine::ContentType::Image; + else if (mime->hasText()) + return SimpleChatEngine::ContentType::Text; + } + } + return SimpleChatEngine::ContentType::Unknown; +} + +void SimpleChatEngine::sendImageFromClipboard() +{ + if (m_clipBoard == nullptr) + return; + + QByteArray imgData; + const QMimeData *mime = m_clipBoard->mimeData(); + if (mime != nullptr) { + if (mime->hasImage()) { + QImage img = mime->imageData().value(); + img = img.scaled(300, 300, Qt::KeepAspectRatio); + QBuffer buffer(&imgData); + buffer.open(QIODevice::WriteOnly); + img.save(&buffer, "PNG"); + buffer.close(); + } else if (mime->hasUrls()) { + QUrl imgUrl = mime->urls().at(0); + if (!imgUrl.isLocalFile()) { + qWarning() << "Only supports transfer of local images"; + return; + } + QImage img(imgUrl.toLocalFile()); + if (img.isNull()) { + qWarning() << "Invalid image format"; + return; + } + + QBuffer buffer(&imgData); + buffer.open(QIODevice::WriteOnly); + img.save(&buffer, "PNG"); + buffer.close(); + } + } + + if (imgData.isEmpty()) + return; + + qtgrpc::examples::chat::ChatMessage msg; + msg.setContent(imgData); + msg.setType(qtgrpc::examples::chat::ChatMessage::Image); + msg.setTimestamp(QDateTime::currentMSecsSinceEpoch()); + msg.setFrom(m_userName); + m_client->sendMessage(msg); +} + +ChatMessageModel *SimpleChatEngine::messages() +{ + return &m_messages; +} + +QString SimpleChatEngine::userName() const +{ + return m_userName; +} + +SimpleChatEngine::State SimpleChatEngine::state() const +{ + return m_state; +} + +void SimpleChatEngine::setState(SimpleChatEngine::State state) +{ + if (m_state != state) { + m_state = state; + emit stateChanged(); + } +} diff --git a/examples/grpc/chat/client/simplechatengine.h b/examples/grpc/chat/client/simplechatengine.h new file mode 100644 index 00000000..e223c5ab --- /dev/null +++ b/examples/grpc/chat/client/simplechatengine.h @@ -0,0 +1,75 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#ifndef SIMPLECHATENGINE_H +#define SIMPLECHATENGINE_H + +#include +#include + +#include "simplechat.qpb.h" +#include "simplechat_client.grpc.qpb.h" +#include "chatmessagemodel.h" + +QT_BEGIN_NAMESPACE +class QClipboard; +QT_END_NAMESPACE + +class SimpleChatEngine : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + Q_PROPERTY(QString userName READ userName NOTIFY userNameChanged) + Q_PROPERTY(ChatMessageModel *messages READ messages CONSTANT) + Q_PROPERTY(SimpleChatEngine::ContentType clipBoardContentType READ clipBoardContentType NOTIFY + clipBoardContentTypeChanged) + Q_PROPERTY(SimpleChatEngine::State state READ state NOTIFY stateChanged) +public: + enum ContentType { + Unknown = qtgrpc::examples::chat::ChatMessage::ContentType::Unknown, + Text = qtgrpc::examples::chat::ChatMessage::ContentType::Text, + Image = qtgrpc::examples::chat::ChatMessage::ContentType::Image, + }; + Q_ENUM(ContentType) + + enum State { + Disconnected = 0, + Connecting = 1, + Connected = 2, + }; + Q_ENUM(State) + + explicit SimpleChatEngine(QObject *parent = nullptr); + ~SimpleChatEngine() override; + Q_INVOKABLE void login(const QString &name, const QString &password); + Q_INVOKABLE void sendMessage(const QString &message); + + Q_INVOKABLE void sendImageFromClipboard(); + + QString userName() const; + ChatMessageModel *messages(); + SimpleChatEngine::ContentType clipBoardContentType() const; + + SimpleChatEngine::State state() const; + +Q_SIGNALS: + void authFailed(); + void clipBoardContentTypeChanged(); + void userNameChanged(); + void stateChanged(); + +private: + void setState(SimpleChatEngine::State state); + + SimpleChatEngine::State m_state; + ChatMessageModel m_messages; + qtgrpc::examples::chat::SimpleChat::Client *m_client; + QClipboard *m_clipBoard; + QString m_userName; +}; + +Q_DECLARE_METATYPE(ChatMessageModel *) + +#endif // SIMPLECHATENGINE_H diff --git a/examples/grpc/chat/proto/simplechat.proto b/examples/grpc/chat/proto/simplechat.proto new file mode 100644 index 00000000..4b11b744 --- /dev/null +++ b/examples/grpc/chat/proto/simplechat.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +//! [0] +package qtgrpc.examples.chat; + +message ChatMessage +{ + enum ContentType { + Unknown = 0; + Text = 1; + Image = 2; + }; + uint64 timestamp = 1; + bytes content = 2; + ContentType type = 3; + string from = 4; +} + +message ChatMessages +{ + repeated ChatMessage messages = 1; +} + +message User +{ + string name = 1; + string password = 2; +} + +message Users { + repeated User users = 1; +} + +message None { } + +service SimpleChat +{ + rpc messageList(None) returns (stream ChatMessages) { } + rpc sendMessage(ChatMessage) returns (None) { } +} +//! [0] diff --git a/examples/grpc/chat/server/CMakeLists.txt b/examples/grpc/chat/server/CMakeLists.txt new file mode 100644 index 00000000..1b538e56 --- /dev/null +++ b/examples/grpc/chat/server/CMakeLists.txt @@ -0,0 +1,58 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +cmake_minimum_required(VERSION 3.16) + +project(GrpcChatServer LANGUAGES CXX) + +# Qt6::Grpc module is not used directly in this project. But this allows to find Qt6::Grpc's +# dependencies without setting extra cmake module paths. +find_package(Qt6 COMPONENTS Grpc) +find_package(WrapgRPCPlugin) +find_package(WrapgRPC) + +if(NOT TARGET WrapgRPC::WrapgRPCPlugin OR NOT TARGET WrapProtoc::WrapProtoc + OR NOT TARGET WrapgRPC::WrapLibgRPC) + message(WARNING "Dependencies of QtGrpc test server not found. Skipping.") + return() +endif() + +set(proto_files "${CMAKE_CURRENT_LIST_DIR}/../proto/simplechat.proto") +set(out_dir "${CMAKE_CURRENT_BINARY_DIR}") + +set(generated_files + "${out_dir}/simplechat.pb.h" "${out_dir}/simplechat.pb.cc" + "${out_dir}/simplechat.grpc.pb.h" "${out_dir}/simplechat.grpc.pb.cc") + +add_custom_command( + OUTPUT ${generated_files} + COMMAND + $ + ARGS + --grpc_out "${out_dir}" + --cpp_out "${out_dir}" + "-I${CMAKE_CURRENT_LIST_DIR}/../proto/" + --plugin=protoc-gen-grpc=$ + "${proto_files}" + WORKING_DIRECTORY ${out_dir} + DEPENDS "${proto_files}" + COMMENT "Generating gRPC ${target} sources..." + COMMAND_EXPAND_LISTS + VERBATIM +) + +set_source_files_properties(${generated_files} PROPERTIES GENERATED TRUE) + +add_executable(grpcchatserver + ${generated_files} + main.cpp +) + +target_include_directories(grpcchatserver PRIVATE ${out_dir}) +target_link_libraries(grpcchatserver PRIVATE WrapgRPC::WrapLibgRPC) + +install(TARGETS grpcchatserver + RUNTIME DESTINATION "${INSTALL_EXAMPLEDIR}" + BUNDLE DESTINATION "${INSTALL_EXAMPLEDIR}" + LIBRARY DESTINATION "${INSTALL_EXAMPLEDIR}" +) diff --git a/examples/grpc/chat/server/main.cpp b/examples/grpc/chat/server/main.cpp new file mode 100644 index 00000000..bb3492ca --- /dev/null +++ b/examples/grpc/chat/server/main.cpp @@ -0,0 +1,213 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include +#include +#include +#include +#include + +#include +#include +#include +using namespace qtgrpc::examples::chat; + +class MessageListHandler; +class UserListHandler; + +constexpr std::string_view nameHeader("user-name"); +constexpr std::string_view passwordHeader("user-password"); +class SimpleChatService final : public SimpleChat::WithAsyncMethod_messageList +{ +public: + SimpleChatService(); + void run(std::string_view address); + + grpc::Status sendMessage(grpc::ServerContext *context, const ChatMessage *request, + None *) override; + +private: + std::optional checkUserCredentials(grpc::ServerContext *context); + void addMessageHandler(MessageListHandler *userHandler); + void sendMessageToClients(const ChatMessage *message); + + Users m_usersDatabase; + ChatMessages m_messages; + std::list m_activeClients; +}; + +struct HandlerTag +{ + enum Type { Request = 1, Reply, Disconnect, Reject }; + + HandlerTag(HandlerTag::Type t, MessageListHandler *h) : tag(t), handler(h) { } + + HandlerTag::Type tag; + MessageListHandler *handler; +}; + +class MessageListHandler +{ +public: + MessageListHandler(SimpleChatService *service, grpc::ServerCompletionQueue *queue) + : writer(&ctx), cq(queue) + { + ctx.AsyncNotifyWhenDone(new HandlerTag(HandlerTag::Disconnect, this)); + service->RequestmessageList(&ctx, &request, &writer, cq, cq, + new HandlerTag(HandlerTag::Request, this)); + } + + None request; + grpc::ServerAsyncWriter writer; + grpc::ServerContext ctx; + grpc::ServerCompletionQueue *cq; +}; + +void SimpleChatService::run(std::string_view address) +{ + grpc::ServerBuilder builder; + builder.AddListeningPort(std::string(address), grpc::InsecureServerCredentials()); + builder.RegisterService(this); + std::unique_ptr cq = builder.AddCompletionQueue(); + std::unique_ptr server(builder.BuildAndStart()); + std::cout << "Server listening on " << address << '\n'; + + MessageListHandler *pending = new MessageListHandler(this, cq.get()); + while (true) { + HandlerTag *tag = nullptr; + bool ok = false; + cq->Next(reinterpret_cast(&tag), &ok); + + if (tag == nullptr) + continue; + + if (!ok) { + std::cout << "Unable to proccess tag from the completion queue\n"; + delete tag; + continue; + } + + switch (tag->tag) { + case HandlerTag::Request: { + std::cout << "New connection request received\n"; + std::string name; + if (!checkUserCredentials(&(pending->ctx))) { + std::cout << "Authentication failed\n"; + pending->writer.Finish(grpc::Status(grpc::StatusCode::UNAUTHENTICATED, + "User or login are invalid"), + new HandlerTag(HandlerTag::Reject, pending)); + } else { + std::cout << "User " << name << " connected to chat\n"; + addMessageHandler(pending); + pending->writer.Write(m_messages, nullptr); + } + pending = new MessageListHandler(this, cq.get()); + } break; + case HandlerTag::Disconnect: { + auto it = std::find(m_activeClients.begin(), m_activeClients.end(), tag->handler); + if (it != m_activeClients.end()) { + std::cout << "Client disconnected\n"; + m_activeClients.erase(it); + delete tag->handler; + } + } break; + case HandlerTag::Reject: + std::cout << "Connection rejected\n"; + m_activeClients.remove(tag->handler); + delete tag->handler; + break; + case HandlerTag::Reply: + std::cout << "Sending data to users\n"; + break; + } + delete tag; + } +} + +std::optional SimpleChatService::checkUserCredentials(grpc::ServerContext *context) +{ + assert(context != nullptr); + + std::string name; + std::string password; + for (const auto &[key, value] : std::as_const(context->client_metadata())) { + if (std::string(key.data(), key.size()) == nameHeader) { + name = std::string(value.data(), value.size()); + } + if (std::string(key.data(), key.size()) == passwordHeader) { + password = std::string(value.data(), value.size()); + } + } + + return std::find_if(m_usersDatabase.users().begin(), m_usersDatabase.users().end(), + [&name, &password](const auto &it) { + return it.name() == name && it.password() == password; + }) + != m_usersDatabase.users().end() + ? std::optional{ name } + : std::nullopt; +} + +SimpleChatService::SimpleChatService() +{ + // All passwords are 'qwerty' by default + User *newUser = m_usersDatabase.add_users(); + newUser->set_name("user1"); + newUser->set_password("qwerty"); + newUser = m_usersDatabase.add_users(); + newUser->set_name("user2"); + newUser->set_password("qwerty"); + newUser = m_usersDatabase.add_users(); + newUser->set_name("user3"); + newUser->set_password("qwerty"); + newUser = m_usersDatabase.add_users(); + newUser->set_name("user4"); + newUser->set_password("qwerty"); + newUser = m_usersDatabase.add_users(); + newUser->set_name("user5"); + newUser->set_password("qwerty"); +} + +void SimpleChatService::sendMessageToClients(const ChatMessage *message) +{ + assert(message != nullptr); + + // Send only new messages after users received all the messages for this session after stream + // call. + ChatMessages messages; + ChatMessage *msg = messages.add_messages(); + *msg = *message; + + for (auto client : m_activeClients) + client->writer.Write(messages, new HandlerTag(HandlerTag::Reply, client)); +} + +void SimpleChatService::addMessageHandler(MessageListHandler *handler) +{ + assert(handler != nullptr); + m_activeClients.push_back(handler); +} + +grpc::Status SimpleChatService::sendMessage(grpc::ServerContext *context, + const ChatMessage *request, None *) +{ + assert(context != nullptr); + assert(request != nullptr); + + auto name = checkUserCredentials(context); + if (!name) + return grpc::Status(grpc::StatusCode::UNAUTHENTICATED, "Login or password are invalid"); + + auto msg = m_messages.add_messages(); + *msg = *request; + msg->set_from(*name); + sendMessageToClients(msg); + return grpc::Status(); +} + +int main(int, char *[]) +{ + SimpleChatService srv; + srv.run("localhost:65002"); +} diff --git a/examples/grpc/doc/images/chatconversation.webp b/examples/grpc/doc/images/chatconversation.webp new file mode 100644 index 0000000000000000000000000000000000000000..9e2ce4df6c75125de6642ea12074dfe6dd355cee GIT binary patch literal 8734 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 Lr