// 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 #include #include #include #include #include #include #include #include #include #include #include #include using namespace std::chrono_literals; using namespace Backend; ClientWorker::ClientWorker(QObject *parent) : QObject(parent) { } ClientWorker::~ClientWorker() = default; std::optional 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 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 &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(); if (!m_client->attachChannel(std::make_shared(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); }