Support pasting clipboard images as PNG attachments
This commit is contained in:
1 parent
c8012d35f1
commit
4d30fa6bcc
6 files changed
+546
-25
No files matched your search
+223
-11
@@ -32,7 +32,14 @@
|
||||
#include <QImageIOHandler>
|
||||
#include <QUrl>
|
||||
#include <QToolTip>
|
||||
#include <QBuffer>
|
||||
#include <QFile>
|
||||
#include <QImageWriter>
|
||||
#include <QPixmap>
|
||||
#include <QRunnable>
|
||||
#include <QTemporaryFile>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
|
||||
extern "C" {
|
||||
#include "video/video.h"
|
||||
@@ -42,6 +49,105 @@ extern "C" {
|
||||
|
||||
quint64 InputBar::s_submissionSequence = 0;
|
||||
|
||||
namespace {
|
||||
constexpr qint64 clipboardMaxBytes = 128 * 1024 * 1024;
|
||||
constexpr qint64 clipboardMaxPixels = 32 * 1024 * 1024;
|
||||
const QList<QByteArray> clipboardImageFormats = {"image/png", "image/jpeg", "image/bmp", "image/webp"};
|
||||
|
||||
bool hasClipboardImage(const QMimeData* source) {
|
||||
if (!source) return false;
|
||||
if (source->hasImage()) return true;
|
||||
for (const auto& format : clipboardImageFormats) if (source->hasFormat(QString::fromLatin1(format))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool validClipboardSize(const QSize& size) {
|
||||
return size.width() > 0 && size.height() > 0 && size.width() <= 16384 && size.height() <= 16384 &&
|
||||
qint64(size.width()) * size.height() <= clipboardMaxPixels;
|
||||
}
|
||||
|
||||
/* Общая точка вставки для клавиатуры и контекстного меню; обычный текст обрабатывает QTextEdit. */
|
||||
class ComposerTextEdit final : public QTextEdit {
|
||||
public:
|
||||
ComposerTextEdit(QWidget* parent, std::function<void(const QMimeData*)> pasteImage)
|
||||
: QTextEdit(parent), m_pasteImage(std::move(pasteImage)) {}
|
||||
protected:
|
||||
bool canInsertFromMimeData(const QMimeData* source) const override {
|
||||
return hasClipboardImage(source) || QTextEdit::canInsertFromMimeData(source);
|
||||
}
|
||||
void insertFromMimeData(const QMimeData* source) override {
|
||||
if (hasClipboardImage(source)) {
|
||||
if (isReadOnly()) { GUI_WARN("clipboard image: insertion rejected in read-only composer"); return; }
|
||||
m_pasteImage(source);
|
||||
} else QTextEdit::insertFromMimeData(source);
|
||||
}
|
||||
private:
|
||||
std::function<void(const QMimeData*)> m_pasteImage;
|
||||
};
|
||||
}
|
||||
|
||||
/* Не QObject: результат worker и GUI совместно держат путь, последний владелец удаляет только свой PNG. */
|
||||
struct ClipboardPng {
|
||||
QString path, name;
|
||||
QImage thumbnail;
|
||||
QSize dimensions;
|
||||
qint64 bytes = 0;
|
||||
~ClipboardPng() {
|
||||
if (path.isEmpty()) return;
|
||||
QFile file(path);
|
||||
if (!file.remove()) GUI_WARN("clipboard image: cleanup failed path=%s error=%s", qPrintable(path), qPrintable(file.errorString()));
|
||||
else GUI_DEBUG("clipboard image: temporary PNG removed path=%s", qPrintable(path));
|
||||
}
|
||||
};
|
||||
|
||||
/* Возврат происходит после destructor QTemporaryFile: его native handle уже закрыт, в том числе в Windows. */
|
||||
static std::shared_ptr<ClipboardPng> encodeClipboardPng(QImage image, const QByteArray& encoded,
|
||||
const QString& name, QString& error) {
|
||||
if (image.isNull()) {
|
||||
QBuffer buffer; buffer.setData(encoded); buffer.open(QIODevice::ReadOnly);
|
||||
QImageReader reader(&buffer); reader.setAutoTransform(true);
|
||||
if (!reader.canRead()) error = reader.errorString();
|
||||
else if (!validClipboardSize(reader.size())) error = QStringLiteral("invalid dimensions or more than 32 megapixels");
|
||||
else { image = reader.read(); if (image.isNull()) error = reader.errorString(); }
|
||||
}
|
||||
if (error.isEmpty() && (!validClipboardSize(image.size()) || image.sizeInBytes() > clipboardMaxBytes))
|
||||
error = QStringLiteral("image exceeds 16384 pixels per side, 32 megapixels or 128 MiB");
|
||||
if (!error.isEmpty()) return {};
|
||||
image.setDevicePixelRatio(1);
|
||||
auto result = std::make_shared<ClipboardPng>();
|
||||
QTemporaryFile file(QDir::tempPath() + "/vibechat-clipboard-XXXXXX.png");
|
||||
if (!file.open()) { error = file.errorString(); return {}; }
|
||||
result->path = file.fileName(); file.setAutoRemove(false);
|
||||
QImageWriter writer(&file, "png");
|
||||
if (!writer.write(image)) { error = writer.errorString(); return {}; }
|
||||
if (!file.flush()) { error = file.errorString(); return {}; }
|
||||
result->name = name; result->dimensions = image.size(); result->bytes = file.size();
|
||||
result->thumbnail = image.scaled(80, 60, Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Независимый source для worker: удаление/замена черновика не влияет на запрос, а отказ не мешает повтору. */
|
||||
static bool copyClipboardPng(const QString& source, struct attachment_send_req* request, QString& error) {
|
||||
ClipboardPng temporary;
|
||||
QTemporaryFile output(QDir::tempPath() + "/vibechat-clipboard-send-XXXXXX.png");
|
||||
if (!output.open()) { error = output.errorString(); return false; }
|
||||
temporary.path = output.fileName(); output.setAutoRemove(false);
|
||||
const QByteArray path = temporary.path.toUtf8();
|
||||
if (path.size() >= int(sizeof(request->source))) { error = QStringLiteral("temporary source path is too long"); return false; }
|
||||
QFile input(source);
|
||||
if (!input.open(QIODevice::ReadOnly)) { error = input.errorString(); return false; }
|
||||
while (!input.atEnd()) {
|
||||
const QByteArray bytes = input.read(65536);
|
||||
if (input.error() != QFileDevice::NoError) { error = input.errorString(); return false; }
|
||||
if (output.write(bytes) != bytes.size()) { error = output.errorString(); return false; }
|
||||
}
|
||||
if (!output.flush()) { error = output.errorString(); return false; }
|
||||
snprintf(request->source, sizeof(request->source), "%s", path.constData());
|
||||
request->temporary_source = 1;
|
||||
temporary.path.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool isImageFile(const QString& suffix) {
|
||||
QString s = suffix.toLower();
|
||||
return s == "jpg" || s == "jpeg" || s == "png" || s == "gif" ||
|
||||
@@ -88,12 +194,13 @@ QString InputBar::formatFileSize(qint64 bytes) {
|
||||
|
||||
InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *parent)
|
||||
: QWidget(parent)
|
||||
, m_input(new QTextEdit(this))
|
||||
, m_input(new ComposerTextEdit(this, [this](const QMimeData* source) { pasteImage(source); }))
|
||||
, m_emojiBtn(nullptr)
|
||||
, m_pttBtn(nullptr)
|
||||
, m_emojiPanel(emojiPanel)
|
||||
, m_recorder(recorder)
|
||||
{
|
||||
m_clipboardPool.setMaxThreadCount(1);
|
||||
auto *mainLayout = new QVBoxLayout(this);
|
||||
mainLayout->setContentsMargins(8, 4, 8, 0);
|
||||
mainLayout->setSpacing(2);
|
||||
@@ -114,7 +221,12 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
|
||||
auto *prevLay = new QHBoxLayout(m_attachPreview);
|
||||
prevLay->setContentsMargins(0, 2, 0, 2);
|
||||
prevLay->setSpacing(4);
|
||||
m_attachThumbnail = new QLabel(m_attachPreview);
|
||||
m_attachThumbnail->setObjectName("clipboardImageThumbnail");
|
||||
m_attachThumbnail->hide();
|
||||
prevLay->addWidget(m_attachThumbnail);
|
||||
m_attachPreviewLabel = new QLabel(m_attachPreview);
|
||||
m_attachPreviewLabel->setTextFormat(Qt::PlainText);
|
||||
m_attachPreviewLabel->setStyleSheet("background: palette(light); border-radius: 6px; padding: 2px 8px;");
|
||||
prevLay->addWidget(m_attachPreviewLabel, 1);
|
||||
m_encryptCheck = new QCheckBox(QCoreApplication::translate("ChatGui", "Encrypt attachment"), m_attachPreview);
|
||||
@@ -137,6 +249,7 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
|
||||
mainLayout->addWidget(m_attachPreview);
|
||||
|
||||
m_preparationLabel = new QLabel(this);
|
||||
m_preparationLabel->setObjectName("mediaPreparationStatus");
|
||||
m_preparationLabel->hide();
|
||||
mainLayout->addWidget(m_preparationLabel);
|
||||
|
||||
@@ -244,6 +357,9 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
|
||||
|
||||
/* QWidget уничтожает дочерние viewport после полей InputBar: снимаем callbacks до их уничтожения. */
|
||||
InputBar::~InputBar() {
|
||||
cancelClipboardPreparation();
|
||||
m_clipboardPool.waitForDone();
|
||||
m_clipboardDraft.reset();
|
||||
GUI_DEBUG("InputBar: removing file drop targets count=%d", int(m_dropTargets.size()));
|
||||
for (auto* target : m_dropTargets) {
|
||||
target->removeEventFilter(this);
|
||||
@@ -281,7 +397,7 @@ bool InputBar::submissionPending() const {
|
||||
/* Канальный текст и каждая часть ответа имеют собственное подтверждение и снимок черновика. */
|
||||
quint64 InputBar::beginSubmission(const QByteArray& reply, quint64 generation, const QString& target,
|
||||
const QString& text, const QString& source) {
|
||||
if (reply.isEmpty() && text.isEmpty() && (source.isEmpty() || m_dmMode)) return 0;
|
||||
if (reply.isEmpty() && text.isEmpty() && (source.isEmpty() || (m_dmMode && !m_clipboardDraft))) return 0;
|
||||
const quint64 request = ++s_submissionSequence;
|
||||
m_pendingSubmissions.insert(request, {generation, target, text, source, m_input->document()->revision(), m_attachmentRevision});
|
||||
GUI_DEBUG("send: pending ch=%s reply=%s request=%llu", qPrintable(target), reply.toHex().constData(), (unsigned long long)request);
|
||||
@@ -319,7 +435,8 @@ void InputBar::submissionResult(quint64 request, bool success) {
|
||||
}
|
||||
if (!pending.text.isEmpty() && pending.textRevision == m_input->document()->revision()) m_input->clear();
|
||||
if (!pending.source.isEmpty() && pending.attachmentRevision == m_attachmentRevision) clearPendingAttachment();
|
||||
if (!submissionPending() && !m_submissionFailed && m_input->toPlainText().isEmpty() && m_pendingAttachSrc.isEmpty()) clearReply();
|
||||
if (!submissionPending() && !m_submissionFailed && !m_clipboardPreparing &&
|
||||
m_input->toPlainText().isEmpty() && m_pendingAttachSrc.isEmpty()) clearReply();
|
||||
}
|
||||
|
||||
/* Черновик и выбранный ответ восстанавливаются после сохранения/отмены. Вложения остаются в composer. */
|
||||
@@ -327,6 +444,7 @@ void InputBar::editMessage(const QByteArray& id, quint64 version, const QString&
|
||||
if (m_dmMode || id.size() != 32 || !version || m_editRequest || submissionPending() || m_pttActive) {
|
||||
GUI_WARN("edit: composer busy/invalid target=%s", qPrintable(m_channelIdForRecord)); return;
|
||||
}
|
||||
cancelClipboardPreparation();
|
||||
if (!m_editId.isEmpty()) cancelEdit();
|
||||
m_savedText = m_input->toPlainText(); m_savedReply = m_replyTo; m_savedReplyLabel = m_replyLabel->text();
|
||||
clearReply(); m_editId = id; m_editVersion = version;
|
||||
@@ -369,6 +487,78 @@ void InputBar::rejectAttachment(const QString& reason, const QString& source) {
|
||||
emit attachmentRejected(reason);
|
||||
}
|
||||
|
||||
void InputBar::cancelClipboardPreparation() {
|
||||
++m_clipboardGeneration;
|
||||
m_clipboardPool.clear();
|
||||
m_clipboardPreparing = false;
|
||||
showPreparation();
|
||||
}
|
||||
|
||||
void InputBar::pasteImage(const QMimeData* source) {
|
||||
if (!canAttachFile()) {
|
||||
rejectAttachment(QCoreApplication::translate("ChatGui", "Images cannot be pasted in the current composer state.")); return;
|
||||
}
|
||||
const quint64 context = m_attachmentContext, generation = ++m_clipboardGeneration;
|
||||
const QString target = m_channelIdForRecord;
|
||||
m_clipboardPool.clear(); m_clipboardPreparing = true; showPreparation();
|
||||
GUI_DEBUG("clipboard image: snapshot target=%s generation=%llu formats=%s", qPrintable(target),
|
||||
(unsigned long long)generation, qPrintable(source->formats().join(",")));
|
||||
QImage image;
|
||||
QByteArray representation = "Qt image";
|
||||
if (source->hasImage()) {
|
||||
const QVariant data = source->imageData();
|
||||
image = qvariant_cast<QImage>(data);
|
||||
if (image.isNull() && data.canConvert<QPixmap>()) image = qvariant_cast<QPixmap>(data).toImage();
|
||||
}
|
||||
QByteArray encoded;
|
||||
if (image.isNull()) for (const auto& format : clipboardImageFormats) {
|
||||
if (!source->hasFormat(QString::fromLatin1(format))) continue;
|
||||
encoded = source->data(QString::fromLatin1(format));
|
||||
if (!encoded.isEmpty()) { representation = format; break; }
|
||||
}
|
||||
if (context != m_attachmentContext || generation != m_clipboardGeneration) {
|
||||
GUI_DEBUG("clipboard image: snapshot superseded target=%s generation=%llu", qPrintable(target),
|
||||
(unsigned long long)generation); return;
|
||||
}
|
||||
if ((!image.isNull() && (!validClipboardSize(image.size()) || image.sizeInBytes() > clipboardMaxBytes)) ||
|
||||
encoded.size() > clipboardMaxBytes || (image.isNull() && encoded.isEmpty())) {
|
||||
m_clipboardPreparing = false; showPreparation();
|
||||
GUI_WARN("clipboard image: invalid snapshot target=%s dimensions=%dx%d image_bytes=%lld encoded_bytes=%lld",
|
||||
qPrintable(target), image.width(), image.height(), (long long)image.sizeInBytes(), (long long)encoded.size());
|
||||
rejectAttachment(QCoreApplication::translate("ChatGui", "Cannot paste image: invalid data or more than 32 megapixels / 128 MiB."));
|
||||
return;
|
||||
}
|
||||
const QString name = "clipboard-" + QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss-zzz") + ".png";
|
||||
++m_attachmentRevision;
|
||||
GUI_INFO("clipboard image: preparing PNG target=%s generation=%llu representation=%s dimensions=%dx%d encoded_bytes=%lld",
|
||||
qPrintable(target), (unsigned long long)generation, representation.constData(), image.width(), image.height(),
|
||||
(long long)encoded.size());
|
||||
m_clipboardPool.start(QRunnable::create([this, image, encoded, name, target, context, generation] {
|
||||
QString error;
|
||||
const auto result = encodeClipboardPng(image, encoded, name, error);
|
||||
if (!result) GUI_ERROR("clipboard image: PNG preparation failed target=%s reason=%s", qPrintable(target), qPrintable(error));
|
||||
if (!QMetaObject::invokeMethod(this, [this, result, error, target, context, generation] {
|
||||
if (context != m_attachmentContext || generation != m_clipboardGeneration) {
|
||||
GUI_DEBUG("clipboard image: stale PNG discarded target=%s generation=%llu", qPrintable(target),
|
||||
(unsigned long long)generation); return;
|
||||
}
|
||||
m_clipboardPreparing = false; showPreparation();
|
||||
if (!result) {
|
||||
rejectAttachment(QCoreApplication::translate("ChatGui", "Could not prepare clipboard image: %1").arg(error)); return;
|
||||
}
|
||||
if (!prepareAttachment(result->path, context)) return;
|
||||
m_clipboardDraft = result;
|
||||
m_pendingAttachName = result->name;
|
||||
m_attachPreviewLabel->setText(QString("%1 (%2, %3×%4)").arg(result->name, formatFileSize(result->bytes))
|
||||
.arg(result->dimensions.width()).arg(result->dimensions.height()));
|
||||
m_attachThumbnail->setPixmap(QPixmap::fromImage(result->thumbnail)); m_attachThumbnail->show();
|
||||
GUI_INFO("clipboard image: preview ready target=%s source=%s dimensions=%dx%d bytes=%lld",
|
||||
qPrintable(target), qPrintable(result->path), result->dimensions.width(), result->dimensions.height(),
|
||||
(long long)result->bytes);
|
||||
}, Qt::QueuedConnection)) GUI_ERROR("clipboard image: cannot queue PNG result target=%s", qPrintable(target));
|
||||
}));
|
||||
}
|
||||
|
||||
bool InputBar::eventFilter(QObject *obj, QEvent *event) {
|
||||
if (m_dropTargets.contains(obj)) {
|
||||
if (event->type() == QEvent::DragLeave) {
|
||||
@@ -464,6 +654,9 @@ void InputBar::onSendClicked() {
|
||||
m_editRequest = ++s_submissionSequence; m_input->setReadOnly(true);
|
||||
emit saveEdit(m_channelIdForRecord, m_editId, m_editVersion, text, m_editRequest); return;
|
||||
}
|
||||
if (m_clipboardPreparing) {
|
||||
GUI_WARN("send: clipboard PNG still preparing target=%s", qPrintable(m_channelIdForRecord)); return;
|
||||
}
|
||||
if (submissionPending()) {
|
||||
GUI_WARN("send: submission still pending ch=%s", qPrintable(m_channelIdForRecord)); return;
|
||||
}
|
||||
@@ -486,15 +679,17 @@ void InputBar::onSendClicked() {
|
||||
const bool typedImage = m_pendingAttachAsImage &&
|
||||
QStringList{"jpg", "jpeg", "png"}.contains(QFileInfo(attachmentSource).suffix().toLower());
|
||||
if (typedImage) {
|
||||
QImageReader reader(attachmentSource); reader.setAutoTransform(true);
|
||||
const QByteArray format = reader.format().toLower();
|
||||
QSize size = reader.size();
|
||||
if (reader.transformation() & QImageIOHandler::TransformationRotate90) size.transpose();
|
||||
QByteArray format; QSize size; QString readError; bool readable;
|
||||
{ // Reader должен закрыть исходник до синхронного submissionResult, который может удалить PNG черновика.
|
||||
QImageReader reader(attachmentSource); reader.setAutoTransform(true);
|
||||
format = reader.format().toLower(); size = reader.size(); readable = reader.canRead(); readError = reader.errorString();
|
||||
if (reader.transformation() & QImageIOHandler::TransformationRotate90) size.transpose();
|
||||
}
|
||||
const QByteArray source = attachmentSource.toUtf8(), name = attachmentName.toUtf8();
|
||||
if (!reader.canRead() || (format != "jpeg" && format != "png") || size.width() <= 0 || size.height() <= 0 ||
|
||||
if (!readable || (format != "jpeg" && format != "png") || size.width() <= 0 || size.height() <= 0 ||
|
||||
size.width() > 16384 || size.height() > 16384 || source.size() >= 1024 || name.size() >= 256) {
|
||||
GUI_ERROR("image rejected: source=%s format=%s dimensions=%dx%d error=%s", qPrintable(attachmentSource),
|
||||
format.constData(), size.width(), size.height(), qPrintable(reader.errorString()));
|
||||
format.constData(), size.width(), size.height(), qPrintable(readError));
|
||||
submissionResult(requestId, false);
|
||||
rejectAttachment(QCoreApplication::translate("ChatGui", "Cannot read the image or its dimensions are too large."), attachmentSource);
|
||||
return;
|
||||
@@ -508,6 +703,15 @@ void InputBar::onSendClicked() {
|
||||
snprintf(request->info.name, sizeof(request->info.name), "%s", name.constData());
|
||||
if (reply.size() == 32) memcpy(request->reply_to, reply.constData(), 32);
|
||||
request->request_id = requestId;
|
||||
if (m_clipboardDraft) {
|
||||
QString error;
|
||||
if (!copyClipboardPng(attachmentSource, request, error)) {
|
||||
GUI_ERROR("clipboard image: send snapshot failed source=%s reason=%s", qPrintable(attachmentSource), qPrintable(error));
|
||||
attachment_send_discard(request); submissionResult(requestId, false); return;
|
||||
}
|
||||
GUI_DEBUG("clipboard image: send owns snapshot target=%s draft=%s source=%s request=%llu", qPrintable(target),
|
||||
qPrintable(attachmentSource), request->source, (unsigned long long)requestId);
|
||||
}
|
||||
GUI_INFO("image queued: target=%s format=%s dimensions=%dx%d encrypted=%d", qPrintable(target),
|
||||
format.constData(), size.width(), size.height(), encrypted);
|
||||
emit submitAttachment(target, request);
|
||||
@@ -615,13 +819,14 @@ void InputBar::setMediaEnabled(bool enabled) {
|
||||
if (m_encryptCheck) m_encryptCheck->setEnabled(enabled);
|
||||
if (m_pttBtn) m_pttBtn->setEnabled(enabled && m_editId.isEmpty());
|
||||
if (m_attachBtn) m_attachBtn->setEnabled(enabled && m_editId.isEmpty());
|
||||
if (!enabled) cancelClipboardPreparation();
|
||||
if (!enabled && m_dropHint) m_dropHint->hide();
|
||||
}
|
||||
|
||||
void InputBar::onPttPressed() {
|
||||
if (!m_editId.isEmpty()) { GUI_WARN("voice: blocked while editing"); return; }
|
||||
if (!isEnabled() || m_channelIdForRecord.isEmpty()) { GUI_WARN("voice: composer unavailable"); return; }
|
||||
if (submissionPending()) { GUI_WARN("send: recording blocked while submission pending"); return; }
|
||||
if (submissionPending() || m_clipboardPreparing) { GUI_WARN("send: recording blocked while submission/PNG pending"); return; }
|
||||
if (!m_recorder || !m_recorder->isInitialized()) return;
|
||||
m_recorder->startRecording();
|
||||
m_pttActive = m_recorder->isRecording();
|
||||
@@ -724,6 +929,9 @@ bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) {
|
||||
}
|
||||
|
||||
m_encryptCheck->setChecked(false);
|
||||
cancelClipboardPreparation();
|
||||
m_clipboardDraft.reset();
|
||||
m_attachThumbnail->clear(); m_attachThumbnail->hide();
|
||||
m_pendingAttachSrc = srcPath;
|
||||
++m_attachmentRevision;
|
||||
m_pendingAttachDest = destPath;
|
||||
@@ -744,6 +952,9 @@ bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) {
|
||||
}
|
||||
|
||||
void InputBar::clearPendingAttachment() {
|
||||
cancelClipboardPreparation();
|
||||
m_clipboardDraft.reset();
|
||||
m_attachThumbnail->clear(); m_attachThumbnail->hide();
|
||||
m_encryptCheck->setChecked(false);
|
||||
++m_attachmentRevision;
|
||||
m_pendingAttachSrc.clear();
|
||||
@@ -772,7 +983,8 @@ void InputBar::updatePreparation(const QString& target, const QByteArray& job, i
|
||||
|
||||
void InputBar::showPreparation() {
|
||||
QString status;
|
||||
if (!m_preparations.value(m_channelIdForRecord).isEmpty()) status = QCoreApplication::translate("ChatGui", "Preparing media…");
|
||||
if (m_clipboardPreparing || !m_preparations.value(m_channelIdForRecord).isEmpty())
|
||||
status = QCoreApplication::translate("ChatGui", "Preparing media…");
|
||||
else if (m_preparationErrors.contains(m_channelIdForRecord)) status = QCoreApplication::translate("ChatGui", "Failed to prepare attachment");
|
||||
m_preparationLabel->setText(status);
|
||||
m_preparationLabel->setVisible(!status.isEmpty());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* GUI-composer канала/PM: текст, emoji, ответ/редактирование, голосовая запись и preview одного вложения.
|
||||
* Сам не отправляет данные в сеть: владелец обрабатывает сигналы в GUI и передаёт работу ядру.
|
||||
* Для submitAttachment нужен ровно один обработчик, принимающий request вместе с PCM/compressor даже при отказе.
|
||||
* Канальный текст/файлы/ответы/правки ждут submissionResult; PM-вложения очищаются сразу после сигнала отправки.
|
||||
* Канальный текст/файлы/ответы/правки и PNG из буфера в PM ждут submissionResult; остальные PM-вложения очищаются сразу.
|
||||
* emojiPanel/recorder заимствованы, могут быть nullptr и должны жить во время использования; остальные виджеты — дочерние. */
|
||||
#pragma once
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include <QHash>
|
||||
#include <QSet>
|
||||
#include <QPointer>
|
||||
#include <QThreadPool>
|
||||
#include <memory>
|
||||
|
||||
class QTextEdit;
|
||||
class QPushButton;
|
||||
@@ -18,7 +20,9 @@ class QProgressBar;
|
||||
class QTimer;
|
||||
class EmojiPanel;
|
||||
class AudioRecorder;
|
||||
class QMimeData;
|
||||
struct attachment_send_req;
|
||||
struct ClipboardPng;
|
||||
|
||||
class InputBar : public QWidget {
|
||||
Q_OBJECT
|
||||
@@ -27,10 +31,10 @@ public:
|
||||
* Для PTT recorder должен быть init; его жизненным циклом и остановкой при закрытии владеет вызывающий. */
|
||||
explicit InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder,
|
||||
QWidget *parent = nullptr);
|
||||
~InputBar() override; // Снимает drop-фильтры/callbacks, удаляет drag-подсказку; не уничтожает заимствованные объекты.
|
||||
~InputBar() override; // Ждёт PNG-worker, удаляет свой временный PNG, снимает drop-фильтры; заимствованные объекты не уничтожает.
|
||||
|
||||
/* mediaDirBase — каталог media; channelId — канал либо dm:<conv_id>, dmGeneration — поколение PM (для канала 0).
|
||||
* Изменение контекста отменяет edit/reply/preview и отложенный drop; обычный текст сохраняется.
|
||||
* Изменение контекста отменяет edit/reply/preview, PNG-worker и отложенный drop; обычный текст сохраняется.
|
||||
* Запись уже фиксирует прежний target/generation; удаление текущего поколения PM отдельно очищает его черновик. */
|
||||
void setRecordingContext(const QString& mediaDirBase, const QString& channelId, quint64 dmGeneration = 0);
|
||||
/* Очистить текст/reply/preview и остановить запись только при совпадении target/generation.
|
||||
@@ -52,7 +56,7 @@ public:
|
||||
* PM/пустая цель/неверный ключ отклоняются; edit сначала отменяется. Новый ответ имеет собственное поколение. */
|
||||
void setReply(const QByteArray& messageId, const QString& author, const QString& preview);
|
||||
void clearReply(); // Снять ответ и сменить поколение; текст/preview вложения не очищаются, старые результаты не применяются.
|
||||
/* Подтвердить канальный текст/файл, правку либо одну часть ответа по request; 0/неизвестный ID игнорируется.
|
||||
/* Подтвердить канальный текст/файл, PNG из буфера в PM, правку либо одну часть ответа по request.
|
||||
* Ошибка сохраняет черновик и показывает отказ. Успех очищает лишь неизменённую ревизию текста/вложения;
|
||||
* ответ снимается после успеха всех частей и очистки черновика. Старое поколение/другая цель не затрагиваются. */
|
||||
void submissionResult(quint64 request, bool success);
|
||||
@@ -90,7 +94,7 @@ private slots:
|
||||
void onPttPressed(); // Запускает recorder и фиксирует target/generation/reply только при успешном старте.
|
||||
void onPttReleased(); // Stop; запись от 500 мс передаётся через takeRecording/submitAttachment, меньшая отбрасывается.
|
||||
void onAttachClicked(); // QFileDialog → та же подготовка preview, что у drop; контекст проверяется после модального выбора.
|
||||
void clearPendingAttachment(); // Снять preview/encrypt, увеличить ревизию; исходный файл не удаляется.
|
||||
void clearPendingAttachment(); // Снять preview/encrypt и подготовку PNG; удалить только собственный временный исходник.
|
||||
void updateRecordingUI(int ms); // Длительность и RMS recorder (-70..0 dB → 0..100), чтение peakLevel потребляет метрику.
|
||||
void showRecordOverlay(); // Показать overlay и начальную длительность/уровень по recordingStarted.
|
||||
void hideRecordOverlay(); // Скрыть по recordingStopped; не выполняет отправку записи.
|
||||
@@ -104,7 +108,7 @@ private:
|
||||
quint64 attachmentRevision; // Ревизия preview для такой же защиты выбранного файла.
|
||||
};
|
||||
/* Запомнить канальный текст/файл либо часть ответа; ID общий для всех composer, чтобы late result не попал новой форме.
|
||||
* Вложение без source/reply и файл PM не требуют подтверждения: вернуть 0 и ничего не запоминать. */
|
||||
* Вложение без source/reply и обычный файл PM не требуют подтверждения: вернуть 0 и ничего не запоминать. */
|
||||
quint64 beginSubmission(const QByteArray& reply, quint64 generation, const QString& target,
|
||||
const QString& text = {}, const QString& source = {});
|
||||
bool submissionPending() const; // Есть ожидающая часть текущего контекста/выбора ответа.
|
||||
@@ -133,7 +137,8 @@ private:
|
||||
QPushButton *m_cancelRecBtn = nullptr; // Stop без отправки: снимает m_pttActive до остановки recorder.
|
||||
QProgressBar *m_levelBar = nullptr; // RMS последнего обработанного блока recorder, 0..100.
|
||||
QWidget *m_attachPreview = nullptr; // Preview выбранного файла, скрыт до успешной подготовки выбора.
|
||||
QLabel *m_attachPreviewLabel = nullptr; // Имя/размер файла; его содержимое здесь не читается.
|
||||
QLabel *m_attachPreviewLabel = nullptr; // Plain text: имя/размер файла, для PNG из буфера также размеры в пикселях.
|
||||
QLabel *m_attachThumbnail = nullptr; // Миниатюра PNG из буфера; скрыта для обычного файла.
|
||||
QPushButton *m_attachClearBtn = nullptr; // Снять preview без удаления исходника.
|
||||
bool m_pttActive = false; // Composer начал запись, которую release ещё может отправить.
|
||||
bool m_dmMode = false; // PM: общий формат JPEG/PNG/voice/video, групповое шифрование скрыто.
|
||||
@@ -142,17 +147,23 @@ private:
|
||||
QString m_mediaDirBase; // База планируемых путей вложений; файлы PM без transcode остаются по исходному пути.
|
||||
QString m_channelIdForRecord; // Текущий target composer: канал либо dm:<id>.
|
||||
QString m_recordTarget; // Target, зафиксированный при старте PTT, независимо от дальнейшего выбора канала.
|
||||
QString m_pendingAttachSrc; // Локальный source preview, не копируется/удаляется composer.
|
||||
QString m_pendingAttachSrc; // Локальный source preview; только PNG из буфера принадлежит composer.
|
||||
QString m_pendingAttachDest; // Планируемый mediaDirBase/target/имя либо исходный путь PM.
|
||||
QString m_pendingAttachName; // Исходное имя для отображения/метаданных; видео при отправке получает суффикс mp4.
|
||||
qint64 m_pendingAttachSize = 0; // Размер на момент выбора, bytes.
|
||||
bool m_pendingAttachAsVideo = false; // Пользователь выбрал подготовку/transcode видео.
|
||||
bool m_pendingAttachAsImage = false; // Расширение картинки в канале; PM не использует этот режим.
|
||||
bool m_pendingAttachAsImage = false; // Картинка в канале; в PM только JPEG/PNG.
|
||||
std::shared_ptr<ClipboardPng> m_clipboardDraft; // Собственный PNG черновика; каждой отправке принадлежит отдельная копия.
|
||||
QThreadPool m_clipboardPool; // Один PNG-worker; destructor ждёт завершения, queued-результат владеет своим файлом.
|
||||
quint64 m_clipboardGeneration = 0; // Номер вставки/отмены; устаревший результат не меняет preview.
|
||||
bool m_clipboardPreparing = false; // Пока последний PNG кодируется, отправка заблокирована, старый preview сохраняется.
|
||||
QLabel* m_preparationLabel = nullptr; // Статус подготовки текущего target.
|
||||
QHash<QString, QSet<QByteArray>> m_preparations; // Target → UUID активных задач ядра.
|
||||
QSet<QString> m_preparationErrors; // Target с последней ошибкой, снимается при новом start или forget.
|
||||
QSet<QByteArray> m_discardedPreparations; /* Завершения задач удалённой истории, ещё ожидаемые из ядра. */
|
||||
void showPreparation(); // Обновить индикатор для текущей цели по кешу задач/ошибок.
|
||||
void pasteImage(const QMimeData* source); // Синхронно забрать изображение/байты буфера, проверить лимиты, запустить PNG-worker.
|
||||
void cancelClipboardPreparation(); // Сменить поколение, снять очередь PNG и индикатор; активный worker закончит без применения.
|
||||
bool canAttachFile() const; // Enabled, выбран target, скрепка разрешена, нет edit/PTT/записи recorder.
|
||||
/* Проверить source/context, выбрать режим видео и подготовить preview; true — выбор принят, false — отмена/отказ.
|
||||
* Старый preview заменяется лишь после успеха; source остаётся неизменным, для групп/видео создаётся каталог назначения. */
|
||||
|
||||
@@ -32,6 +32,31 @@ extern "C" {
|
||||
#include <QPointer>
|
||||
#include <QScopeGuard>
|
||||
#include <QImage>
|
||||
#include <QClipboard>
|
||||
#include <QBuffer>
|
||||
#include <QMenu>
|
||||
#include <QAction>
|
||||
#include <QDir>
|
||||
#include <QProcess>
|
||||
#include <QStandardPaths>
|
||||
|
||||
static QSet<QString> clipboardPaths() {
|
||||
QSet<QString> paths;
|
||||
const QDir directory(QDir::tempPath());
|
||||
for (const auto& name : directory.entryList({"vibechat-clipboard-*.png"}, QDir::Files)) paths.insert(directory.filePath(name));
|
||||
return paths;
|
||||
}
|
||||
|
||||
static void copyImage(const QImage& image, bool encoded = false) {
|
||||
auto* mime = new QMimeData;
|
||||
if (encoded) {
|
||||
QByteArray bytes; QBuffer buffer(&bytes); buffer.open(QIODevice::WriteOnly);
|
||||
if (!image.save(&buffer, "PNG")) qFatal("Cannot create clipboard PNG fixture");
|
||||
mime->setData("image/png", bytes);
|
||||
} else mime->setImageData(image);
|
||||
mime->setText("image alternate text"); mime->setHtml("<b>image alternate text</b>");
|
||||
QApplication::clipboard()->setMimeData(mime);
|
||||
}
|
||||
|
||||
static QString makeFile(const QTemporaryDir& dir, const QString& name, const QByteArray& contents = "test") {
|
||||
const QString path = dir.filePath(name);
|
||||
@@ -57,6 +82,199 @@ static bool dropUrls(QWidget* target, const QList<QUrl>& urls, bool process = tr
|
||||
class FileDropTest : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void cleanup() { QApplication::clipboard()->clear(); }
|
||||
|
||||
void clipboardImageUsesPngPipeline_data() {
|
||||
QTest::addColumn<bool>("dm"); QTest::addColumn<bool>("encrypted"); QTest::addColumn<QString>("method");
|
||||
QTest::newRow("group-ctrl-v") << false << false << QString("ctrl");
|
||||
QTest::newRow("pm-ctrl-v") << true << false << QString("ctrl");
|
||||
QTest::newRow("encrypted-shift-insert") << false << true << QString("shift");
|
||||
QTest::newRow("group-context-menu-png") << false << false << QString("menu");
|
||||
}
|
||||
void clipboardImageUsesPngPipeline() {
|
||||
QFETCH(bool, dm); QFETCH(bool, encrypted); QFETCH(QString, method);
|
||||
const auto before = clipboardPaths(); QTemporaryDir directory; QVERIFY(directory.isValid());
|
||||
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), dm ? "dm:456" : "123", dm ? 7 : 0);
|
||||
bar.setDmMode(dm); bar.show();
|
||||
auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor);
|
||||
auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail);
|
||||
QSignalSpy errors(&bar, &InputBar::attachmentRejected), texts(&bar, &InputBar::sendMessage);
|
||||
attachment_send_req* pending = nullptr;
|
||||
const auto discard = qScopeGuard([&] { attachment_send_discard(pending); });
|
||||
connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString& target, attachment_send_req* request) {
|
||||
QCOMPARE(target, dm ? QString("dm:456") : QString("123")); QVERIFY(!pending); pending = request;
|
||||
});
|
||||
QImage original(120, 80, QImage::Format_ARGB32); original.fill(qRgba(17, 61, 95, 127));
|
||||
editor->setPlainText("existing draft"); copyImage(original, method == "menu");
|
||||
if (method == "ctrl") QTest::keyClick(editor, Qt::Key_V, Qt::ControlModifier);
|
||||
else if (method == "shift") QTest::keyClick(editor, Qt::Key_Insert, Qt::ShiftModifier);
|
||||
else {
|
||||
std::unique_ptr<QMenu> menu(editor->createStandardContextMenu()); QAction* paste = nullptr;
|
||||
for (auto* action : menu->actions()) if (action->text().remove('&').startsWith("Paste")) paste = action;
|
||||
QVERIFY(paste); QVERIFY(paste->isEnabled()); paste->trigger();
|
||||
}
|
||||
QCOMPARE(editor->toPlainText(), QString("existing draft")); QVERIFY(!pending); QCOMPARE(texts.size(), 0);
|
||||
QTest::keyClick(editor, Qt::Key_Return); QVERIFY(!pending); QCOMPARE(texts.size(), 0); // PNG ещё кодируется.
|
||||
QApplication::clipboard()->setText("clipboard has changed");
|
||||
QTRY_VERIFY(!thumbnail->isHidden());
|
||||
QCOMPARE(errors.size(), 0); QCOMPARE(editor->toPlainText(), QString("existing draft"));
|
||||
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1); const QString draft = *drafts.begin();
|
||||
QImage decoded(draft); QCOMPARE(decoded.size(), original.size()); QCOMPARE(decoded.pixelColor(0, 0), original.pixelColor(0, 0));
|
||||
editor->clear(); const QByteArray reply(32, 'r'); if (!dm) bar.setReply(reply, "Member", "Original");
|
||||
if (encrypted) bar.findChild<QCheckBox*>()->setChecked(true);
|
||||
QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending); QVERIFY(pending->request_id); QVERIFY(pending->temporary_source);
|
||||
QCOMPARE(pending->info.kind, ATTACHMENT_PNG); QCOMPARE(pending->info.width, 120); QCOMPARE(pending->info.height, 80);
|
||||
QCOMPARE(pending->encrypted, int(encrypted)); QCOMPARE(pending->dm_generation, dm ? UINT64_C(7) : UINT64_C(0));
|
||||
QVERIFY(QString::fromUtf8(pending->info.name).startsWith("clipboard-"));
|
||||
QVERIFY(QString::fromUtf8(pending->info.name).endsWith(".png"));
|
||||
if (!dm) QCOMPARE(QByteArray(reinterpret_cast<const char*>(pending->reply_to), 32), reply);
|
||||
const QString source = QString::fromUtf8(pending->source); QVERIFY(source != draft);
|
||||
QCOMPARE(QImage(source).pixelColor(0, 0), original.pixelColor(0, 0));
|
||||
bar.submissionResult(pending->request_id, true); QVERIFY(thumbnail->isHidden()); QVERIFY(!QFile::exists(draft));
|
||||
QVERIFY(QFile::exists(source)); // Worker владеет отдельным source после очистки preview.
|
||||
attachment_send_discard(pending); pending = nullptr; QVERIFY(!QFile::exists(source));
|
||||
QCOMPARE(clipboardPaths(), before);
|
||||
}
|
||||
|
||||
void clipboardFailureKeepsDraftAndRetries_data() {
|
||||
QTest::addColumn<bool>("dm"); QTest::newRow("group") << false; QTest::newRow("pm") << true;
|
||||
}
|
||||
void clipboardFailureKeepsDraftAndRetries() {
|
||||
QFETCH(bool, dm); const auto before = clipboardPaths(); QTemporaryDir directory;
|
||||
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), dm ? "dm:456" : "123", dm ? 7 : 0);
|
||||
bar.setDmMode(dm); bar.show(); auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor);
|
||||
auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail);
|
||||
attachment_send_req* pending = nullptr;
|
||||
const auto discard = qScopeGuard([&] { attachment_send_discard(pending); });
|
||||
connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString&, attachment_send_req* request) { pending = request; });
|
||||
QImage image(32, 24, QImage::Format_RGB32); image.fill(Qt::green); copyImage(image); editor->paste();
|
||||
QTRY_VERIFY(!thumbnail->isHidden());
|
||||
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1); const QString draft = *drafts.begin();
|
||||
QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending); const auto first = pending->request_id; QVERIFY(first);
|
||||
QTest::keyClick(editor, Qt::Key_Return); QCOMPARE(pending->request_id, first);
|
||||
attachment_send_discard(pending); pending = nullptr;
|
||||
QSignalSpy errors(&bar, &InputBar::attachmentRejected); bar.submissionResult(first, false);
|
||||
QCOMPARE(errors.size(), 1); QVERIFY(!thumbnail->isHidden()); QVERIFY(QFile::exists(draft));
|
||||
QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending); QVERIFY(pending->request_id != first);
|
||||
bar.submissionResult(pending->request_id, true); QVERIFY(thumbnail->isHidden()); QVERIFY(!QFile::exists(draft));
|
||||
attachment_send_discard(pending); pending = nullptr; QCOMPARE(clipboardPaths(), before);
|
||||
}
|
||||
|
||||
void clipboardOldSuccessPreservesNewPaste() {
|
||||
const auto before = clipboardPaths(); QTemporaryDir directory;
|
||||
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show();
|
||||
auto* editor = bar.findChild<QTextEdit*>(); auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail");
|
||||
QVERIFY(editor); QVERIFY(thumbnail);
|
||||
attachment_send_req* pending = nullptr;
|
||||
const auto discard = qScopeGuard([&] { attachment_send_discard(pending); });
|
||||
connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString&, attachment_send_req* request) { pending = request; });
|
||||
QImage image(32, 24, QImage::Format_RGB32); image.fill(Qt::red); copyImage(image); editor->paste();
|
||||
QTRY_VERIFY(!thumbnail->isHidden()); QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending);
|
||||
const auto oldDrafts = clipboardPaths() - before; QCOMPARE(oldDrafts.size(), 2);
|
||||
image.fill(Qt::blue); copyImage(image); editor->paste();
|
||||
bar.submissionResult(pending->request_id, true); // Успех A не должен отменить кодирование B.
|
||||
const QString sendSource = QString::fromUtf8(pending->source);
|
||||
auto* status = bar.findChild<QLabel*>("mediaPreparationStatus"); QVERIFY(status); QTRY_VERIFY(status->isHidden());
|
||||
const auto newDrafts = clipboardPaths() - before - QSet<QString>{sendSource}; QCOMPARE(newDrafts.size(), 1);
|
||||
QCOMPARE(QImage(*newDrafts.begin()).pixelColor(0, 0), QColor(Qt::blue));
|
||||
QVERIFY(!thumbnail->isHidden()); QVERIFY(QFile::exists(sendSource));
|
||||
attachment_send_discard(pending); pending = nullptr;
|
||||
bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before);
|
||||
}
|
||||
|
||||
void clipboardWorkIsSupersededAndCleaned_data() {
|
||||
QTest::addColumn<QString>("action");
|
||||
for (const char* action : {"replace", "change-chat", "clear", "destroy", "edit", "disable", "file"})
|
||||
QTest::newRow(action) << QString(action);
|
||||
}
|
||||
void clipboardWorkIsSupersededAndCleaned() {
|
||||
QFETCH(QString, action); const auto before = clipboardPaths(); QTemporaryDir directory;
|
||||
{
|
||||
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show();
|
||||
auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor);
|
||||
QImage image(120, 80, QImage::Format_RGB32); image.fill(Qt::red); copyImage(image); editor->paste();
|
||||
if (action != "destroy") {
|
||||
if (action == "change-chat") bar.setRecordingContext(directory.path(), "456");
|
||||
if (action == "clear") QVERIFY(QMetaObject::invokeMethod(&bar, "clearPendingAttachment", Qt::DirectConnection));
|
||||
if (action == "edit") { bar.editMessage(QByteArray(32, 'e'), 1, "edit"); bar.cancelEdit(); }
|
||||
if (action == "disable") { bar.setMediaEnabled(false); bar.setMediaEnabled(true); }
|
||||
if (action == "file") QVERIFY(dropUrls(editor->viewport(), {QUrl::fromLocalFile(makeFile(directory, "file.txt"))}));
|
||||
image = QImage(60, 40, QImage::Format_RGB32); image.fill(Qt::blue); copyImage(image); editor->paste();
|
||||
auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail); QTRY_VERIFY(!thumbnail->isHidden());
|
||||
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1);
|
||||
const QImage decoded(*drafts.begin()); QCOMPARE(decoded.size(), image.size()); QCOMPARE(decoded.pixelColor(0, 0), QColor(Qt::blue));
|
||||
}
|
||||
}
|
||||
QCOMPARE(clipboardPaths(), before);
|
||||
}
|
||||
|
||||
void clipboardInvalidImagePreservesDraft_data() {
|
||||
QTest::addColumn<QString>("kind");
|
||||
for (const char* kind : {"corrupt-png", "too-wide", "too-many-pixels"}) QTest::newRow(kind) << QString(kind);
|
||||
}
|
||||
void clipboardInvalidImagePreservesDraft() {
|
||||
QFETCH(QString, kind); const auto before = clipboardPaths(); QTemporaryDir directory;
|
||||
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show();
|
||||
auto* editor = bar.findChild<QTextEdit*>(); auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail");
|
||||
QVERIFY(editor); QVERIFY(thumbnail);
|
||||
QImage original(32, 24, QImage::Format_RGB32); original.fill(Qt::green); copyImage(original); editor->paste();
|
||||
QTRY_VERIFY(!thumbnail->isHidden()); const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1);
|
||||
QSignalSpy errors(&bar, &InputBar::attachmentRejected);
|
||||
if (kind == "corrupt-png") {
|
||||
auto* mime = new QMimeData; mime->setData("image/png", "invalid PNG"); QApplication::clipboard()->setMimeData(mime);
|
||||
} else copyImage(QImage(kind == "too-wide" ? 16385 : 8192, kind == "too-wide" ? 1 : 4097, QImage::Format_Mono));
|
||||
editor->paste(); QTRY_COMPARE(errors.size(), 1);
|
||||
QCOMPARE(clipboardPaths() - before, drafts); QVERIFY(!thumbnail->isHidden());
|
||||
QCOMPARE(QImage(*drafts.begin()).pixelColor(0, 0), QColor(Qt::green));
|
||||
bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before);
|
||||
}
|
||||
|
||||
void clipboardTextAndUnavailableComposer_data() {
|
||||
QTest::addColumn<QString>("mode");
|
||||
for (const char* mode : {"text", "disabled-media", "no-target", "edit"}) QTest::newRow(mode) << QString(mode);
|
||||
}
|
||||
void clipboardTextAndUnavailableComposer() {
|
||||
QFETCH(QString, mode); const auto before = clipboardPaths(); QTemporaryDir directory;
|
||||
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123");
|
||||
auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor); QSignalSpy errors(&bar, &InputBar::attachmentRejected);
|
||||
if (mode == "text") {
|
||||
QApplication::clipboard()->setText("обычный текст\nnext line"); editor->paste();
|
||||
QCOMPARE(editor->toPlainText(), QString("обычный текст\nnext line")); QCOMPARE(errors.size(), 0);
|
||||
} else {
|
||||
if (mode == "disabled-media") bar.setMediaEnabled(false);
|
||||
if (mode == "no-target") bar.setRecordingContext(directory.path(), "");
|
||||
if (mode == "edit") bar.editMessage(QByteArray(32, 'e'), 1, "edit");
|
||||
const QString text = editor->toPlainText();
|
||||
QImage image(32, 24, QImage::Format_RGB32); image.fill(Qt::red); copyImage(image); editor->paste();
|
||||
QCOMPARE(errors.size(), 1); QCOMPARE(editor->toPlainText(), text);
|
||||
}
|
||||
QCOMPARE(clipboardPaths(), before);
|
||||
}
|
||||
|
||||
void externalClipboardImage() {
|
||||
const QString platform = QGuiApplication::platformName();
|
||||
const bool wayland = platform.startsWith("wayland");
|
||||
if (!wayland && platform != "xcb") QSKIP("Run with QT_QPA_PLATFORM=xcb or wayland to test an external clipboard owner.");
|
||||
const QString program = QStandardPaths::findExecutable(wayland ? "wl-copy" : "xclip");
|
||||
if (program.isEmpty()) QSKIP("Install wl-copy/xclip to test an external PNG clipboard owner.");
|
||||
const auto before = clipboardPaths(); QTemporaryDir directory;
|
||||
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show(); bar.activateWindow();
|
||||
QVERIFY(QTest::qWaitForWindowActive(&bar));
|
||||
QImage original(120, 80, QImage::Format_ARGB32); original.fill(qRgba(41, 81, 121, 127));
|
||||
QByteArray bytes; QBuffer buffer(&bytes); QVERIFY(buffer.open(QIODevice::WriteOnly)); QVERIFY(original.save(&buffer, "PNG"));
|
||||
QProcess owner;
|
||||
const auto stop = qScopeGuard([&] { owner.kill(); owner.waitForFinished(1000); });
|
||||
owner.start(program, wayland ? QStringList{"--foreground", "--type", "image/png"}
|
||||
: QStringList{"-selection", "clipboard", "-target", "image/png", "-in", "-quiet"});
|
||||
QVERIFY(owner.waitForStarted(1000)); QCOMPARE(owner.write(bytes), qint64(bytes.size())); owner.closeWriteChannel();
|
||||
QTRY_VERIFY(QApplication::clipboard()->mimeData()->hasFormat("image/png"));
|
||||
auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor); editor->paste();
|
||||
auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail); QTRY_VERIFY(!thumbnail->isHidden());
|
||||
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1);
|
||||
QCOMPARE(QImage(*drafts.begin()).pixelColor(0, 0), original.pixelColor(0, 0));
|
||||
bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before);
|
||||
}
|
||||
|
||||
void ordinaryTextWaitsForResultAndCanRetry() {
|
||||
InputBar bar(nullptr, nullptr); bar.setRecordingContext("/tmp", "123");
|
||||
auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor);
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
#include <QCheckBox>
|
||||
#include <QScopeGuard>
|
||||
#include <QPointer>
|
||||
#include <QClipboard>
|
||||
#include <QImage>
|
||||
#include <QLabel>
|
||||
#include <QDir>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <atomic>
|
||||
@@ -26,6 +30,8 @@ extern "C" {
|
||||
#include "utun_instance.h"
|
||||
#include "chat/chat_core.h"
|
||||
#include "chat/chat_event.h"
|
||||
#include "dm/dm_core.h"
|
||||
#include "dm/dm_crypto.h"
|
||||
#include "debug_config.h"
|
||||
#include "mem.h"
|
||||
}
|
||||
@@ -78,18 +84,19 @@ struct SendCore {
|
||||
void openHistory() {
|
||||
history = new MessageList(&db); sendHistory = history; history->loadChannel(channel);
|
||||
}
|
||||
bool start(bool reply = false) {
|
||||
bool start(bool reply = false, const QString& dbPath = {}) {
|
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
|
||||
debug_set_category_level(DEBUG_CATEGORY_CHAT, DEBUG_LEVEL_DEBUG);
|
||||
debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); gui_bridge_init();
|
||||
instance = ua ? utun_instance_create_from_str(ua,
|
||||
"[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n"
|
||||
const QByteArray config = "[global]\ndb_path=" + dbPath.toUtf8() +
|
||||
"\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n"
|
||||
"my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n"
|
||||
"tun_enabled=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nmember_files_enabled=0\n"
|
||||
"local_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n") : nullptr;
|
||||
"local_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n";
|
||||
instance = ua ? utun_instance_create_from_str(ua, config.constData()) : nullptr;
|
||||
if (!instance || utun_core_start(instance) || chat_service_start(instance)) return false;
|
||||
const auto id = chat_core_create_channel_auto(instance, "Send tests"); if (!id) return false;
|
||||
channel = QString::number(id); db.setDb(instance->topo_sqlite_db); db.setMyNodeId(instance->node_id);
|
||||
channel = QString::number(id); db.setDb(instance->topo_sqlite_db); db.setMyNodeId(instance->node_id); db.setDbPath(dbPath);
|
||||
if (reply) {
|
||||
chat_msg_submit request{}; request.inst = instance; strcpy(request.content_type, "text/plain");
|
||||
snprintf(request.channel_id, sizeof(request.channel_id), "%s", qPrintable(channel));
|
||||
@@ -119,6 +126,55 @@ static int inCore(std::function<int(UTUN_INSTANCE*)> operation) {
|
||||
class GroupEditTest : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void clipboardPngCommitsThroughCore_data() {
|
||||
QTest::addColumn<bool>("dm"); QTest::newRow("group-reply") << false; QTest::newRow("pm") << true;
|
||||
}
|
||||
void clipboardPngCommitsThroughCore() {
|
||||
QFETCH(bool, dm); QTemporaryDir directory; QVERIFY(directory.isValid()); SendCore core;
|
||||
QVERIFY(core.start(!dm, directory.path())); QString conversation;
|
||||
if (dm) {
|
||||
SC_MYKEYS peer{}; uint8_t ed[32]; QCOMPARE(sc_generate_keypair(&peer), 0);
|
||||
QCOMPARE(sc_derive_ed25519_pubkey(peer.private_key, ed), 0);
|
||||
const auto peerId = sc_derive_node_id_from_pubkey(peer.public_key);
|
||||
QCOMPARE(dm_start(core.instance, peerId, peer.public_key, ed, "Clipboard peer", nullptr), 0);
|
||||
conversation = QString::number(dm_derive_conv_id(core.instance->node_id, peerId));
|
||||
core.history->loadChannel("dm:" + conversation);
|
||||
}
|
||||
core.history->show();
|
||||
auto* thumbnail = core.input()->findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail);
|
||||
QSignalSpy errors(core.input(), &InputBar::attachmentRejected);
|
||||
const auto before = QDir(QDir::tempPath()).entryList({"vibechat-clipboard-*.png"}, QDir::Files);
|
||||
const auto clear = qScopeGuard([] { QApplication::clipboard()->clear(); });
|
||||
QImage image(120, 80, QImage::Format_ARGB32); image.fill(qRgba(17, 61, 95, 127));
|
||||
QApplication::clipboard()->setImage(image); core.editor()->paste(); QTRY_VERIFY(!thumbnail->isHidden());
|
||||
gui_bridge_set_inst(nullptr); QVERIFY(core.send()); QCOMPARE(errors.size(), 1); QVERIFY(!thumbnail->isHidden());
|
||||
core.attach(); QVERIFY(core.send());
|
||||
QTRY_VERIFY_WITH_TIMEOUT((core.pump(), thumbnail->isHidden()), 5000);
|
||||
QCOMPARE(errors.size(), 1);
|
||||
QCOMPARE(QDir(QDir::tempPath()).entryList({"vibechat-clipboard-*.png"}, QDir::Files), before);
|
||||
if (dm) {
|
||||
char json[8192]; size_t length = 0;
|
||||
QCOMPARE(dm_list_messages_json(core.instance, qPrintable(conversation), 20, 0, json, sizeof(json), &length), 0);
|
||||
const QByteArray messages(json, int(length));
|
||||
QVERIFY(messages.contains("\"content_type\":\"image/png\""));
|
||||
QVERIFY(messages.contains("\"width\":120,\"height\":80")); QVERIFY(messages.contains("clipboard-"));
|
||||
} else {
|
||||
const auto messages = core.db.getMessages(core.channel, 10); QCOMPARE(messages.size(), 2);
|
||||
bool found = false;
|
||||
for (const auto& message : messages) if (message.contentType == "image/png") {
|
||||
found = true; QCOMPARE(message.replyTo, core.original);
|
||||
const int separator = message.data.indexOf('|'); QVERIFY(separator > 1); QVERIFY(message.data.startsWith('*'));
|
||||
QVERIFY(QByteArray::fromBase64(message.data.mid(1, separator - 1)).startsWith("clipboard-"));
|
||||
QVERIFY(message.data.endsWith("|w=120|h=80"));
|
||||
}
|
||||
QVERIFY(found);
|
||||
const QDir media(directory.filePath("media/" + core.channel));
|
||||
const auto files = media.entryList({"*.png"}, QDir::Files); QCOMPARE(files.size(), 1);
|
||||
const QImage decoded(media.filePath(files.front()));
|
||||
QCOMPARE(decoded.size(), image.size()); QCOMPARE(decoded.pixelColor(0, 0), image.pixelColor(0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
void channelSend_data() {
|
||||
QTest::addColumn<bool>("reply"); QTest::addColumn<QString>("failure");
|
||||
QStringList failures{"none", "unavailable", "stopped", "cancelled", "sql"};
|
||||
|
||||
@@ -2815,5 +2815,17 @@ See log:
|
||||
<source>Camera connection error: %1</source>
|
||||
<translation>Camera connection error: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Images cannot be pasted in the current composer state.</source>
|
||||
<translation>Images cannot be pasted in the current composer state.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot paste image: invalid data or more than 32 megapixels / 128 MiB.</source>
|
||||
<translation>Cannot paste image: invalid data or more than 32 megapixels / 128 MiB.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Could not prepare clipboard image: %1</source>
|
||||
<translation>Could not prepare clipboard image: %1</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
@@ -2824,5 +2824,17 @@ See log:
|
||||
<source>Camera connection error: %1</source>
|
||||
<translation>Ошибка подключения к камере: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Images cannot be pasted in the current composer state.</source>
|
||||
<translation>Сейчас нельзя вставить изображение в сообщение.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot paste image: invalid data or more than 32 megapixels / 128 MiB.</source>
|
||||
<translation>Не удалось вставить изображение: повреждённые данные либо превышен предел 32 Мп / 128 МиБ.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Could not prepare clipboard image: %1</source>
|
||||
<translation>Не удалось подготовить изображение из буфера: %1</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
Reference in new issue
Block a user