qtgrpc/examples/grpc/chat/client/clientworker.cpp

405 lines
13 KiB
C++

// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
#include "clientworker.h"
#include "chatmessages.qpb.h"
#include <QtQml/QQmlFile>
#include <QtGrpc/QGrpcCallOptions>
#include <QtGrpc/QGrpcChannelOptions>
#include <QtGrpc/QGrpcHttp2Channel>
#include <QtNetwork/QSslCertificate>
#include <QtNetwork/QSslConfiguration>
#include <QtCore/QFile>
#include <QtCore/QFileInfo>
#include <QtCore/QMimeDatabase>
#include <QtCore/QStandardPaths>
#include <QtCore/QUrl>
#include <QtCore/QTimer>
#include <chrono>
using namespace std::chrono_literals;
using namespace Backend;
ClientWorker::ClientWorker(QObject *parent) : QObject(parent)
{
}
ClientWorker::~ClientWorker() = default;
std::optional<QDir> ClientWorker::getValidDownloadDir() const
{
if (!m_client || !m_chatStream || m_userCredentials.name().isEmpty())
return {};
const auto path = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
if (path.isEmpty()) {
qWarning("No download location found for standard path!");
return {};
}
QDir downloadDir(path + "/QtGrpcChat/" + m_userCredentials.name());
if (!downloadDir.exists())
downloadDir.mkpath(".");
return downloadDir;
}
std::optional<chat::ChatMessage> ClientWorker::createMessage() const
{
if (m_userCredentials.name().isEmpty())
return {};
chat::ChatMessage message;
message.setTimestamp(QDateTime::currentDateTimeUtc().toMSecsSinceEpoch());
message.setUsername(m_userCredentials.name());
return message;
}
void ClientWorker::setHostUri(const QUrl &hostUri)
{
const auto scheme = hostUri.scheme();
if (scheme != "http" && scheme != "https") {
emit chatError(tr("Specified scheme '%1' is not supported. Use 'http' or 'https'.")
.arg(scheme));
return;
}
if (m_hostUri == hostUri)
return;
m_hostUri = hostUri;
m_hostUriDirty = true;
}
//! [client-4b]
void ClientWorker::registerUser(const chat::Credentials &credentials)
{
if (credentials.name().isEmpty() || credentials.password().isEmpty()) {
emit chatError(tr("Invalid credentials for registration"));
return;
}
if ((!m_client || m_hostUriDirty) && !initializeClient()) {
emit chatError(tr("Failed registration: unabled to initialize client"));
return;
}
auto reply = m_client->Register(credentials, QGrpcCallOptions{}.setDeadlineTimeout(5s));
const auto *replyPtr = reply.get();
connect(
replyPtr, &QGrpcCallReply::finished, this,
[this, reply = std::move(reply)](const QGrpcStatus &status) {
emit registerFinished(status);
},
Qt::SingleShotConnection);
}
//! [client-4b]
//! [client-5a]
void ClientWorker::login(const chat::Credentials &credentials)
{
if (credentials.name().isEmpty() || credentials.password().isEmpty()) {
emit chatError(tr("Invalid credentials for login"));
return;
}
//! [client-5a]
if ((!m_client || m_hostUriDirty) && !initializeClient()) {
emit chatError(tr("Failed login: unabled to initialize client"));
return;
}
if (m_chatStream) {
emit chatError(tr("Already logged in to an active session."));
return;
}
m_userCredentials = credentials;
//! [client-5b]
QGrpcCallOptions opts;
opts.setMetadata({
{ "user-name", credentials.name().toUtf8() },
{ "user-password", credentials.password().toUtf8() },
});
connectStream(opts);
}
//! [client-5b]
void ClientWorker::logout()
{
if (!m_chatStream || m_chatState == ChatState::Disconnected
|| m_chatState == ChatState::Disconnecting) {
return;
}
setState(ChatState::Disconnecting);
m_chatStream->writesDone();
}
void ClientWorker::sendFile(const QUrl &url)
{
// By default gRPC accepts a maximum message size of 4 MiB
constexpr auto MaxMessageSize = quint64(3.9 * 1024 * 1024);
if (m_chatState == ChatState::Connecting) {
m_fileRequestBuffer.push_back(url);
return;
}
if (!m_chatStream || m_chatState != ChatState::Connected) {
emit chatError("Unable to send file: no active chat session");
return;
}
const auto fileUrl = QQmlFile::urlToLocalFileOrQrc(url);
const auto utf8Url = url.toString().toUtf8();
const auto fileName = QFileInfo(fileUrl).fileName();
if (!QFile::exists(fileUrl)) {
emit chatError(tr("The file \"%1\" could not be found").arg(fileName));
return;
}
static QMimeDatabase mimeDb;
QFile file(fileUrl);
if (!file.open(QIODevice::ReadOnly)) {
emit chatError(tr("The file \"%1\" could not be opened").arg(fileName));
return;
}
chat::FileMessage fileMessage;
fileMessage.setName(fileName);
fileMessage.setSize(uint64_t(file.size()));
QMimeType mime = mimeDb.mimeTypeForFile(fileUrl);
const auto mimeName = mime.name();
if (mimeName.contains("image"))
fileMessage.setType(chat::FileMessage::Type::IMAGE);
else if (mimeName.contains("audio"))
fileMessage.setType(chat::FileMessage::Type::AUDIO);
else if (mimeName.contains("video"))
fileMessage.setType(chat::FileMessage::Type::VIDEO);
else if (mime.inherits("text/plain"))
fileMessage.setType(chat::FileMessage::Type::TEXT);
else
fileMessage.setType(chat::FileMessage::Type::UNKNOWN);
// Send the file as chunks when the maximum supported message size is exceeded.
if (auto count = (quint64(file.size()) + MaxMessageSize - 1) / MaxMessageSize; count > 1) {
chat::FileMessage::Continuation continuation;
continuation.setIndex(0);
continuation.setCount(count);
continuation.setUuid(QUuid::createUuid());
fileMessage.setContinuation(std::move(continuation));
}
auto message = createMessage();
if (!message)
return;
while (!file.atEnd()) {
QByteArray content = file.read(MaxMessageSize);
message->setFile(fileMessage);
message->file().setContent(std::move(content));
m_chatStream->writeMessage(*message);
// We only need the content for writing it to the disk. The visual
// representation only needs the url.
message->file().setContent(utf8Url);
emit fileMessageSent(*message);
if (fileMessage.hasContinuation()) {
auto c = fileMessage.continuation();
c.setIndex(c.index() + 1);
fileMessage.setContinuation(std::move(c));
}
}
}
void ClientWorker::sendFiles(const QList<QUrl> &urls)
{
for (const auto &url : urls)
sendFile(url);
}
//! [client-7b]
void ClientWorker::sendMessage(const chat::ChatMessage &message)
{
if (!m_chatStream || m_chatState != ChatState::Connected) {
emit chatError(tr("Unable to send message"));
return;
}
m_chatStream->writeMessage(message);
}
//! [client-7b]
void ClientWorker::setState(ChatState state)
{
if (m_chatState == state)
return;
m_chatState = state;
emit chatStateChanged(state);
}
//! [client-6a]
void ClientWorker::connectStream(const QGrpcCallOptions &opts)
{
//! [client-6a]
auto initialMessage = createMessage();
if (!initialMessage)
return;
//! [client-6b]
m_chatStream = m_client->ChatRoom(*initialMessage, opts);
//! [client-6b]
if (!m_chatStream) {
m_userCredentials = {};
emit chatError(tr("initiating bidirectional stream failed"));
return;
}
//! [client-6c]
connect(m_chatStream.get(), &QGrpcBidiStream::finished, this,
[this, opts](const QGrpcStatus &status) {
if (m_chatState == ChatState::Connected) {
// If we're connected retry again in 250 ms, no matter the error.
QTimer::singleShot(250, [this, opts]() { connectStream(opts); });
} else {
setState(ChatState::Disconnected);
m_chatResponse = {};
m_userCredentials = {};
m_chatStream.reset();
emit chatStreamFinished(status);
}
});
//! [client-6c]
//! [client-6d]
connect(m_chatStream.get(), &QGrpcBidiStream::messageReceived, this, [this] {
//! [client-6d]
if (!m_chatStream->read(&m_chatResponse)) {
qWarning("Failed to deserialize!");
return;
}
//! [client-6e]
switch (m_chatResponse.contentField()) {
case chat::ChatMessage::ContentFields::UninitializedField:
qDebug("Received uninitialized message");
return;
case chat::ChatMessage::ContentFields::Text:
if (m_chatResponse.text().content().isEmpty())
return;
break;
case chat::ChatMessage::ContentFields::File:
// Download any file messages and store the downloaded URL in the
// content, allowing the model to reference it from there.
m_chatResponse.file()
.setContent(saveFileRequest(m_chatResponse.file()).toString().toUtf8());
break;
//! [client-6e]
case chat::ChatMessage::ContentFields::UserStatus:
if (m_chatResponse.userStatus().type() == chat::UserStatus::Type::LOGIN
&& m_chatResponse.username() == m_userCredentials.name()) {
setState(ChatState::Connected); // Login successful
for (const auto &m : m_fileRequestBuffer) // Send potentially buffered messages
sendFile(m);
m_fileRequestBuffer.clear();
}
}
//! [client-6f]
emit chatStreamMessageReceived(m_chatResponse);
});
setState(Backend::ChatState::Connecting);
}
//! [client-6f]
bool ClientWorker::initializeClient()
{
QGrpcChannelOptions opts;
#if QT_CONFIG(ssl)
//! [client-ssl]
if (m_hostUri.scheme() == "https") {
if (!QSslSocket::supportsSsl()) {
emit chatError(tr("The device doesn't support SSL. Please use the 'http' scheme."));
return false;
}
QFile crtFile(":/res/root.crt");
if (!crtFile.open(QFile::ReadOnly)) {
qFatal("Unable to load root certificate");
return false;
}
QSslConfiguration sslConfig;
QSslCertificate crt(crtFile.readAll());
sslConfig.addCaCertificate(crt);
sslConfig.setProtocol(QSsl::TlsV1_2OrLater);
sslConfig.setAllowedNextProtocols({ "h2" }); // Allow HTTP/2
// Disable hostname verification to allow connections from any local IP.
// Acceptable for development but avoid in production for security.
sslConfig.setPeerVerifyMode(QSslSocket::VerifyNone);
opts.setSslConfiguration(sslConfig);
}
//! [client-ssl]
#endif
m_client = std::make_unique<chat::QtGrpcChat::Client>();
if (!m_client->attachChannel(std::make_shared<QGrpcHttp2Channel>(m_hostUri, opts)))
return false;
m_hostUriDirty = false;
return true;
}
QString ClientWorker::toUniqueFilePath(const QString &filePath)
{
qsizetype count = 0;
QFileInfo fileInfo(filePath);
QString newFilePath = filePath;
while (QFile::exists(newFilePath)) {
newFilePath = QString("%1/%2_%3.%4")
.arg(fileInfo.absolutePath(), fileInfo.completeBaseName())
.arg(count)
.arg(fileInfo.suffix());
++count;
}
return newFilePath;
}
QUrl ClientWorker::saveFileRequest(const chat::FileMessage &fileRequest) const
{
const auto downloadDir = getValidDownloadDir();
if (!downloadDir)
return {};
QString savedFile = downloadDir->path() + QDir::separator();
if (fileRequest.hasContinuation()) {
savedFile += fileRequest.continuation().uuid().toString();
} else {
// Since this is the only transmission it must be locally unique already
savedFile = toUniqueFilePath(savedFile += fileRequest.name());
}
QFile writer(savedFile);
if (!writer.open(QIODevice::WriteOnly | QIODevice::Append)) {
qWarning() << "cannot open" << writer.fileName();
return {};
}
if (writer.write(fileRequest.content()) < 0) {
qDebug() << "Failed to write content to file" << writer.fileName();
return {};
}
if (fileRequest.hasContinuation()
&& fileRequest.continuation().index() == fileRequest.continuation().count() - 1) {
savedFile = toUniqueFilePath(downloadDir->path() + '/' + fileRequest.name());
if (!writer.rename(savedFile))
return {};
} else if (fileRequest.hasContinuation()) {
return {}; // Don't return the path to incomplete downloads
}
return QUrl::fromLocalFile(savedFile);
}