diff --git a/CMakeLists.txt b/CMakeLists.txt index 2ea1f485a..22f401865 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -249,6 +249,8 @@ set(${PROJECT_NAME}_SOURCES src/chatlog/documentcache.h src/chatlog/pixmapcache.cpp src/chatlog/pixmapcache.h + src/chatlog/toxfileprogress.cpp + src/chatlog/toxfileprogress.h src/chatlog/textformatter.cpp src/chatlog/textformatter.h src/core/coreav.cpp diff --git a/src/chatlog/content/filetransferwidget.cpp b/src/chatlog/content/filetransferwidget.cpp index 3da558b3c..c846a62f8 100644 --- a/src/chatlog/content/filetransferwidget.cpp +++ b/src/chatlog/content/filetransferwidget.cpp @@ -39,8 +39,10 @@ #include #include +#include #include + // The leftButton is used to accept, pause, or resume a file transfer, as well as to open a // received file. // The rightButton is used to cancel a file transfer, or to open the directory a file was @@ -50,7 +52,6 @@ FileTransferWidget::FileTransferWidget(QWidget* parent, ToxFile file) : QWidget(parent) , ui(new Ui::FileTransferWidget) , fileInfo(file) - , lastTick(QTime::currentTime()) , backgroundColor(Style::getColor(Style::LightGrey)) , buttonColor(Style::getColor(Style::Yellow)) , buttonBackgroundColor(Style::getColor(Style::White)) @@ -84,8 +85,6 @@ FileTransferWidget::FileTransferWidget(QWidget* parent, ToxFile file) update(); }); - setBackgroundColor(Style::getColor(Style::LightGrey), false); - connect(Core::getInstance(), &Core::fileTransferInfo, this, &FileTransferWidget::onFileTransferInfo); connect(Core::getInstance(), &Core::fileTransferAccepted, this, @@ -105,15 +104,9 @@ FileTransferWidget::FileTransferWidget(QWidget* parent, ToxFile file) connect(ui->previewButton, &QPushButton::clicked, this, &FileTransferWidget::onPreviewButtonClicked); - setupButtons(); - - // preview - if (fileInfo.direction == ToxFile::SENDING) { - showPreview(fileInfo.filePath); - ui->progressLabel->setText(tr("Waiting to send...", "file transfer widget")); - } else { - ui->progressLabel->setText(tr("Accept to receive this file", "file transfer widget")); - } + // Set lastStatus to anything but the file's current value, this forces an update + lastStatus = file.status == ToxFile::FINISHED ? ToxFile::INITIALIZING : ToxFile::FINISHED; + updateWidget(file); setFixedHeight(64); } @@ -155,10 +148,11 @@ void FileTransferWidget::autoAcceptTransfer(const QString& path) // Do not automatically accept the file-transfer if the path is not writable. // The user can still accept it manually. - if (tryRemoveFile(filepath)) + if (tryRemoveFile(filepath)) { Core::getInstance()->acceptFileRecvRequest(fileInfo.friendId, fileInfo.fileNum, filepath); - else + } else { qWarning() << "Cannot write to " << filepath; + } } bool FileTransferWidget::isActive() const @@ -168,8 +162,9 @@ bool FileTransferWidget::isActive() const void FileTransferWidget::acceptTransfer(const QString& filepath) { - if (filepath.isEmpty()) + if (filepath.isEmpty()) { return; + } // test if writable if (!tryRemoveFile(filepath)) { @@ -267,169 +262,49 @@ void FileTransferWidget::paintEvent(QPaintEvent*) void FileTransferWidget::onFileTransferInfo(ToxFile file) { - QTime now = QTime::currentTime(); - qint64 dt = lastTick.msecsTo(now); // ms - - if (fileInfo != file || dt < 1000) - return; - - fileInfo = file; - - if (fileInfo.status == ToxFile::TRANSMITTING) { - // update progress - qreal progress = static_cast(file.bytesSent) / static_cast(file.filesize); - ui->progressBar->setValue(static_cast(progress * 100.0)); - - // ETA, speed - qreal deltaSecs = dt / 1000.0; - - // (can't use ::abs or ::max on unsigned types substraction, they'd just overflow) - quint64 deltaBytes = file.bytesSent > lastBytesSent ? file.bytesSent - lastBytesSent - : lastBytesSent - file.bytesSent; - qreal bytesPerSec = static_cast(static_cast(deltaBytes) / deltaSecs); - - // calculate mean - meanIndex = meanIndex % TRANSFER_ROLLING_AVG_COUNT; - meanData[meanIndex++] = bytesPerSec; - - qreal meanBytesPerSec = 0.0; - for (size_t i = 0; i < TRANSFER_ROLLING_AVG_COUNT; ++i) - meanBytesPerSec += meanData[i]; - - meanBytesPerSec /= static_cast(TRANSFER_ROLLING_AVG_COUNT); - - // update UI - if (meanBytesPerSec > 0) { - // ETA - QTime toGo = QTime(0, 0).addSecs((file.filesize - file.bytesSent) / meanBytesPerSec); - QString format = toGo.hour() > 0 ? "hh:mm:ss" : "mm:ss"; - ui->etaLabel->setText(toGo.toString(format)); - } else { - ui->etaLabel->setText(""); - } - - ui->progressLabel->setText(getHumanReadableSize(meanBytesPerSec) + "/s"); - - lastBytesSent = file.bytesSent; - } - - lastTick = now; - - // trigger repaint - update(); + updateWidget(file); } void FileTransferWidget::onFileTransferAccepted(ToxFile file) { - if (fileInfo != file) - return; - - fileInfo = file; - - setBackgroundColor(Style::getColor(Style::LightGrey), false); - - setupButtons(); + updateWidget(file); } void FileTransferWidget::onFileTransferCancelled(ToxFile file) { - if (fileInfo != file) - return; - - fileInfo = file; - active = false; - - setBackgroundColor(Style::getColor(Style::Red), true); - - setupButtons(); - hideWidgets(); - - disconnect(Core::getInstance(), nullptr, this, nullptr); + updateWidget(file); } void FileTransferWidget::onFileTransferPaused(ToxFile file) { - if (fileInfo != file) - return; - - fileInfo = file; - - ui->etaLabel->setText(""); - ui->progressLabel->setText(tr("Paused", "file transfer widget")); - - // reset mean - meanIndex = 0; - for (size_t i = 0; i < TRANSFER_ROLLING_AVG_COUNT; ++i) - meanData[i] = 0.0; - - setBackgroundColor(Style::getColor(Style::LightGrey), false); - - setupButtons(); + updateWidget(file); } void FileTransferWidget::onFileTransferResumed(ToxFile file) { - if (fileInfo != file) - return; - - fileInfo = file; - - ui->etaLabel->setText(""); - ui->progressLabel->setText(tr("Resuming...", "file transfer widget")); - - // reset mean - meanIndex = 0; - for (size_t i = 0; i < TRANSFER_ROLLING_AVG_COUNT; ++i) - meanData[i] = 0.0; - - setBackgroundColor(Style::getColor(Style::LightGrey), false); - - setupButtons(); + updateWidget(file); } void FileTransferWidget::onFileTransferFinished(ToxFile file) { - if (fileInfo != file) - return; - - fileInfo = file; - active = false; - - setBackgroundColor(Style::getColor(Style::Green), true); - - setupButtons(); - hideWidgets(); - - ui->leftButton->setIcon(QIcon(Style::getImagePath("fileTransferInstance/yes.svg"))); - ui->leftButton->setObjectName("ok"); - ui->leftButton->setToolTip(tr("Open file")); - ui->leftButton->show(); - - ui->rightButton->setIcon(QIcon(Style::getImagePath("fileTransferInstance/dir.svg"))); - ui->rightButton->setObjectName("dir"); - ui->rightButton->setToolTip(tr("Open file directory")); - ui->rightButton->show(); - - // preview - if (fileInfo.direction == ToxFile::RECEIVING) - showPreview(fileInfo.filePath); - - disconnect(Core::getInstance(), nullptr, this, nullptr); + updateWidget(file); } void FileTransferWidget::fileTransferRemotePausedUnpaused(ToxFile file, bool paused) { - if (paused) + if (paused) { onFileTransferPaused(file); - else + } else { onFileTransferResumed(file); + } } void FileTransferWidget::fileTransferBrokenUnbroken(ToxFile file, bool broken) { // TODO: Handle broken transfer differently once we have resuming code - if (broken) + if (broken) { onFileTransferCancelled(file); + } } QString FileTransferWidget::getHumanReadableSize(qint64 size) @@ -437,24 +312,173 @@ QString FileTransferWidget::getHumanReadableSize(qint64 size) static const char* suffix[] = {"B", "kiB", "MiB", "GiB", "TiB"}; int exp = 0; - if (size > 0) + if (size > 0) { exp = std::min((int)(log(size) / log(1024)), (int)(sizeof(suffix) / sizeof(suffix[0]) - 1)); + } return QString().setNum(size / pow(1024, exp), 'f', exp > 1 ? 2 : 0).append(suffix[exp]); } -void FileTransferWidget::hideWidgets() +void FileTransferWidget::updateWidgetColor(ToxFile const& file) { - ui->leftButton->hide(); - ui->rightButton->hide(); - ui->progressBar->hide(); - ui->progressLabel->hide(); - ui->etaLabel->hide(); + if (lastStatus == file.status) { + return; + } + + switch (file.status) { + case ToxFile::INITIALIZING: + case ToxFile::PAUSED: + case ToxFile::TRANSMITTING: + setBackgroundColor(Style::getColor(Style::LightGrey), false); + break; + case ToxFile::BROKEN: + case ToxFile::CANCELED: + setBackgroundColor(Style::getColor(Style::Red), true); + break; + case ToxFile::FINISHED: + setBackgroundColor(Style::getColor(Style::Green), true); + break; + default: + qCritical() << "Invalid file status"; + assert(false); + } } -void FileTransferWidget::setupButtons() +void FileTransferWidget::updateWidgetText(ToxFile const& file) { - switch (fileInfo.status) { + if (lastStatus == file.status) { + return; + } + + switch (file.status) { + case ToxFile::INITIALIZING: + if (file.direction == ToxFile::SENDING) { + ui->progressLabel->setText(tr("Waiting to send...", "file transfer widget")); + } else { + ui->progressLabel->setText(tr("Accept to receive this file", "file transfer widget")); + } + break; + case ToxFile::PAUSED: + ui->etaLabel->setText(""); + ui->progressLabel->setText(tr("Paused", "file transfer widget")); + break; + case ToxFile::TRANSMITTING: + ui->etaLabel->setText(""); + ui->progressLabel->setText(tr("Resuming...", "file transfer widget")); + break; + case ToxFile::BROKEN: + case ToxFile::CANCELED: + break; + case ToxFile::FINISHED: + break; + default: + qCritical() << "Invalid file status"; + assert(false); + } +} + +void FileTransferWidget::updatePreview(ToxFile const& file) +{ + if (lastStatus == file.status) { + return; + } + + switch (file.status) { + case ToxFile::INITIALIZING: + case ToxFile::PAUSED: + case ToxFile::TRANSMITTING: + case ToxFile::BROKEN: + case ToxFile::CANCELED: + if (file.direction == ToxFile::SENDING) { + showPreview(file.filePath); + } + break; + case ToxFile::FINISHED: + showPreview(file.filePath); + break; + default: + qCritical() << "Invalid file status"; + assert(false); + } +} + +void FileTransferWidget::updateFileProgress(ToxFile const& file) +{ + switch (file.status) { + case ToxFile::INITIALIZING: + break; + case ToxFile::PAUSED: + fileProgress.resetSpeed(); + break; + case ToxFile::TRANSMITTING: { + if (!fileProgress.needsUpdate()) { + break; + } + + fileProgress.addSample(file); + auto speed = fileProgress.getSpeed(); + auto progress = fileProgress.getProgress(); + auto remainingTime = fileProgress.getTimeLeftSeconds(); + + ui->progressBar->setValue(static_cast(progress * 100.0)); + + // update UI + if (speed > 0) { + // ETA + QTime toGo = QTime(0, 0).addSecs(remainingTime); + QString format = toGo.hour() > 0 ? "hh:mm:ss" : "mm:ss"; + ui->etaLabel->setText(toGo.toString(format)); + } else { + ui->etaLabel->setText(""); + } + + ui->progressLabel->setText(getHumanReadableSize(speed) + "/s"); + break; + } + case ToxFile::BROKEN: + case ToxFile::CANCELED: + case ToxFile::FINISHED: { + ui->progressBar->hide(); + ui->progressLabel->hide(); + ui->etaLabel->hide(); + break; + } + default: + qCritical() << "Invalid file status"; + assert(false); + } +} + +void FileTransferWidget::updateSignals(ToxFile const& file) +{ + if (lastStatus == file.status) { + return; + } + + switch (file.status) { + case ToxFile::CANCELED: + case ToxFile::BROKEN: + case ToxFile::FINISHED: + active = false; + disconnect(Core::getInstance(), nullptr, this, nullptr); + break; + case ToxFile::INITIALIZING: + case ToxFile::PAUSED: + case ToxFile::TRANSMITTING: + break; + default: + qCritical() << "Invalid file status"; + assert(false); + } +} + +void FileTransferWidget::setupButtons(ToxFile const& file) +{ + if (lastStatus == file.status) { + return; + } + + switch (file.status) { case ToxFile::TRANSMITTING: ui->leftButton->setIcon(QIcon(Style::getImagePath("fileTransferInstance/pause.svg"))); ui->leftButton->setObjectName("pause"); @@ -479,13 +503,12 @@ void FileTransferWidget::setupButtons() setButtonColor(Style::getColor(Style::LightGrey)); break; - case ToxFile::STOPPED: - case ToxFile::BROKEN: + case ToxFile::INITIALIZING: ui->rightButton->setIcon(QIcon(Style::getImagePath("fileTransferInstance/no.svg"))); ui->rightButton->setObjectName("cancel"); ui->rightButton->setToolTip(tr("Cancel transfer")); - if (fileInfo.direction == ToxFile::SENDING) { + if (file.direction == ToxFile::SENDING) { ui->leftButton->setIcon(QIcon(Style::getImagePath("fileTransferInstance/pause.svg"))); ui->leftButton->setObjectName("pause"); ui->leftButton->setToolTip(tr("Pause transfer")); @@ -495,27 +518,48 @@ void FileTransferWidget::setupButtons() ui->leftButton->setToolTip(tr("Accept transfer")); } break; + case ToxFile::CANCELED: + case ToxFile::BROKEN: + ui->leftButton->hide(); + ui->rightButton->hide(); + break; + case ToxFile::FINISHED: + ui->leftButton->setIcon(QIcon(Style::getImagePath("fileTransferInstance/yes.svg"))); + ui->leftButton->setObjectName("ok"); + ui->leftButton->setToolTip(tr("Open file")); + ui->leftButton->show(); + + ui->rightButton->setIcon(QIcon(Style::getImagePath("fileTransferInstance/dir.svg"))); + ui->rightButton->setObjectName("dir"); + ui->rightButton->setToolTip(tr("Open file directory")); + ui->rightButton->show(); + + break; + default: + qCritical() << "Invalid file status"; + assert(false); } } void FileTransferWidget::handleButton(QPushButton* btn) { if (fileInfo.direction == ToxFile::SENDING) { - if (btn->objectName() == "cancel") + if (btn->objectName() == "cancel") { Core::getInstance()->cancelFileSend(fileInfo.friendId, fileInfo.fileNum); - else if (btn->objectName() == "pause") + } else if (btn->objectName() == "pause") { Core::getInstance()->pauseResumeFileSend(fileInfo.friendId, fileInfo.fileNum); - else if (btn->objectName() == "resume") + } else if (btn->objectName() == "resume") { Core::getInstance()->pauseResumeFileSend(fileInfo.friendId, fileInfo.fileNum); + } } else // receiving or paused { - if (btn->objectName() == "cancel") + if (btn->objectName() == "cancel") { Core::getInstance()->cancelFileRecv(fileInfo.friendId, fileInfo.fileNum); - else if (btn->objectName() == "pause") + } else if (btn->objectName() == "pause") { Core::getInstance()->pauseResumeFileRecv(fileInfo.friendId, fileInfo.fileNum); - else if (btn->objectName() == "resume") + } else if (btn->objectName() == "resume") { Core::getInstance()->pauseResumeFileRecv(fileInfo.friendId, fileInfo.fileNum); - else if (btn->objectName() == "accept") { + } else if (btn->objectName() == "accept") { QString path = QFileDialog::getSaveFileName(Q_NULLPTR, tr("Save a file", "Title of the file saving dialog"), @@ -544,12 +588,12 @@ void FileTransferWidget::showPreview(const QString& filename) QFile imageFile(filename); if (!imageFile.open(QIODevice::ReadOnly)) { - qCritical() << "Failed to open file for preview"; return; } const QByteArray imageFileData = imageFile.readAll(); QImage image = QImage::fromData(imageFileData); - const int exifOrientation = getExifOrientation(imageFileData.constData(), imageFileData.size()); + const int exifOrientation = + getExifOrientation(imageFileData.constData(), imageFileData.size()); if (exifOrientation) { applyTransformation(exifOrientation, image); } @@ -559,16 +603,16 @@ void FileTransferWidget::showPreview(const QString& filename) ui->previewButton->setIcon(QIcon(iconPixmap)); ui->previewButton->setIconSize(iconPixmap.size()); ui->previewButton->show(); - // Show mouseover preview, but make sure it's not larger than 50% of the screen width/height + // Show mouseover preview, but make sure it's not larger than 50% of the screen + // width/height const QRect desktopSize = QApplication::desktop()->screenGeometry(); const int maxPreviewWidth{desktopSize.width() / 2}; - const int maxPreviewHeight{desktopSize.height() /2}; + const int maxPreviewHeight{desktopSize.height() / 2}; const QImage previewImage = [&image, maxPreviewWidth, maxPreviewHeight]() { if (image.width() > maxPreviewWidth || image.height() > maxPreviewHeight) { - return image.scaled(maxPreviewWidth, maxPreviewHeight, - Qt::KeepAspectRatio, Qt::SmoothTransformation); - } - else { + return image.scaled(maxPreviewWidth, maxPreviewHeight, Qt::KeepAspectRatio, + Qt::SmoothTransformation); + } else { return image; } }(); @@ -578,8 +622,7 @@ void FileTransferWidget::showPreview(const QString& filename) buffer.open(QIODevice::WriteOnly); previewImage.save(&buffer, "PNG"); buffer.close(); - ui->previewButton->setToolTip(""); + ui->previewButton->setToolTip(""); } } @@ -602,7 +645,8 @@ QPixmap FileTransferWidget::scaleCropIntoSquare(const QPixmap& source, const int { QPixmap result; - // Make sure smaller-than-icon images (at least one dimension is smaller) will not be upscaled + // Make sure smaller-than-icon images (at least one dimension is smaller) will not be + // upscaled if (source.width() < targetSize || source.height() < targetSize) { result = source; } else { @@ -612,10 +656,11 @@ QPixmap FileTransferWidget::scaleCropIntoSquare(const QPixmap& source, const int // Then, image has to be cropped (if needed) so it will not overflow rectangle // Only one dimension will be bigger after Qt::KeepAspectRatioByExpanding - if (result.width() > targetSize) + if (result.width() > targetSize) { return result.copy((result.width() - targetSize) / 2, 0, targetSize, targetSize); - else if (result.height() > targetSize) + } else if (result.height() > targetSize) { return result.copy(0, (result.height() - targetSize) / 2, targetSize, targetSize); + } // Picture was rectangle in the first place, no cropping return result; @@ -625,8 +670,9 @@ int FileTransferWidget::getExifOrientation(const char* data, const int size) { ExifData* exifData = exif_data_new_from_data(reinterpret_cast(data), size); - if (!exifData) + if (!exifData) { return 0; + } int orientation = 0; const ExifByteOrder byteOrder = exif_data_get_byte_order(exifData); @@ -641,12 +687,11 @@ int FileTransferWidget::getExifOrientation(const char* data, const int size) void FileTransferWidget::applyTransformation(const int orientation, QImage& image) { QTransform exifTransform; - switch(static_cast(orientation)) - { + switch (static_cast(orientation)) { case ExifOrientation::TopLeft: break; case ExifOrientation::TopRight: - image = image.mirrored(1,0); + image = image.mirrored(1, 0); break; case ExifOrientation::BottomRight: exifTransform.rotate(180); @@ -673,3 +718,35 @@ void FileTransferWidget::applyTransformation(const int orientation, QImage& imag } image = image.transformed(exifTransform); } + +void FileTransferWidget::updateWidget(ToxFile const& file) +{ + if (fileInfo != file) { + return; + } + + fileInfo = file; + + // If we repainted on every packet our gui would be *very* slow + bool bTransmitNeedsUpdate = fileProgress.needsUpdate(); + + updatePreview(file); + updateFileProgress(file); + updateWidgetText(file); + updateWidgetColor(file); + setupButtons(file); + updateSignals(file); + + lastStatus = file.status; + + // trigger repaint + switch (file.status) { + case ToxFile::TRANSMITTING: + if (!bTransmitNeedsUpdate) { + break; + } + // fallthrough + default: + update(); + } +} diff --git a/src/chatlog/content/filetransferwidget.h b/src/chatlog/content/filetransferwidget.h index 10b418014..fff5ea6ba 100644 --- a/src/chatlog/content/filetransferwidget.h +++ b/src/chatlog/content/filetransferwidget.h @@ -24,6 +24,7 @@ #include #include "src/chatlog/chatlinecontent.h" +#include "src/chatlog/toxfileprogress.h" #include "src/core/toxfile.h" @@ -56,8 +57,12 @@ protected slots: protected: QString getHumanReadableSize(qint64 size); - void hideWidgets(); - void setupButtons(); + void updateWidgetColor(ToxFile const& file); + void updateWidgetText(ToxFile const& file); + void updateFileProgress(ToxFile const& file); + void updateSignals(ToxFile const& file); + void updatePreview(ToxFile const& file); + void setupButtons(ToxFile const& file); void handleButton(QPushButton* btn); void showPreview(const QString& filename); void acceptTransfer(const QString& filepath); @@ -79,28 +84,29 @@ private: static void applyTransformation(const int oritentation, QImage& image); static bool tryRemoveFile(const QString &filepath); + void updateWidget(ToxFile const& file); + private: Ui::FileTransferWidget* ui; + ToxFileProgress fileProgress; ToxFile fileInfo; - QTime lastTick; - quint64 lastBytesSent = 0; QVariantAnimation* backgroundColorAnimation = nullptr; QVariantAnimation* buttonColorAnimation = nullptr; QColor backgroundColor; QColor buttonColor; QColor buttonBackgroundColor; - static const uint8_t TRANSFER_ROLLING_AVG_COUNT = 4; - uint8_t meanIndex = 0; - qreal meanData[TRANSFER_ROLLING_AVG_COUNT] = {0.0}; - bool active; - enum class ExifOrientation { + ToxFile::FileStatus lastStatus = ToxFile::INITIALIZING; + + enum class ExifOrientation + { /* do not change values, this is exif spec * * name corresponds to where the 0 row and 0 column is in form row-column - * i.e. entry 5 here means that the 0'th row corresponds to the left side of the scene and the 0'th column corresponds - * to the top of the captured scene. This means that the image needs to be mirrored and rotated to be displayed. + * i.e. entry 5 here means that the 0'th row corresponds to the left side of the scene and + * the 0'th column corresponds to the top of the captured scene. This means that the image + * needs to be mirrored and rotated to be displayed. */ TopLeft = 1, TopRight = 2, diff --git a/src/chatlog/toxfileprogress.cpp b/src/chatlog/toxfileprogress.cpp new file mode 100644 index 000000000..d048ab8de --- /dev/null +++ b/src/chatlog/toxfileprogress.cpp @@ -0,0 +1,93 @@ +/* + Copyright © 2018 by The qTox Project Contributors + + This file is part of qTox, a Qt-based graphical interface for Tox. + + qTox is libre software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + qTox is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with qTox. If not, see . +*/ + +#include "toxfileprogress.h" + +#include "src/core/toxfile.h" + +bool ToxFileProgress::needsUpdate() const +{ + QTime now = QTime::currentTime(); + qint64 dt = lastTick.msecsTo(now); // ms + + if (dt < 1000) { + return false; + } + + return true; +} + +void ToxFileProgress::addSample(ToxFile const& file) +{ + QTime now = QTime::currentTime(); + qint64 dt = lastTick.msecsTo(now); // ms + + if (dt < 1000) { + return; + } + + // ETA, speed + qreal deltaSecs = dt / 1000.0; + + // (can't use ::abs or ::max on unsigned types substraction, they'd just overflow) + quint64 deltaBytes = file.bytesSent > lastBytesSent ? file.bytesSent - lastBytesSent + : lastBytesSent - file.bytesSent; + qreal bytesPerSec = static_cast(static_cast(deltaBytes) / deltaSecs); + + // Update member variables + meanIndex = meanIndex % TRANSFER_ROLLING_AVG_COUNT; + meanData[meanIndex++] = bytesPerSec; + + double meanBytesPerSec = 0.0; + for (size_t i = 0; i < TRANSFER_ROLLING_AVG_COUNT; ++i) { + meanBytesPerSec += meanData[i]; + } + meanBytesPerSec /= static_cast(TRANSFER_ROLLING_AVG_COUNT); + + lastTick = now; + + progress = static_cast(file.bytesSent) / static_cast(file.filesize); + speedBytesPerSecond = meanBytesPerSec; + timeLeftSeconds = (file.filesize - file.bytesSent) / getSpeed(); + + lastBytesSent = file.bytesSent; +} + +void ToxFileProgress::resetSpeed() +{ + meanIndex = 0; + for (auto& item : meanData) { + item = 0; + } +} + +double ToxFileProgress::getProgress() const +{ + return progress; +} + +double ToxFileProgress::getSpeed() const +{ + return speedBytesPerSecond; +} + +double ToxFileProgress::getTimeLeftSeconds() const +{ + return timeLeftSeconds; +} diff --git a/src/chatlog/toxfileprogress.h b/src/chatlog/toxfileprogress.h new file mode 100644 index 000000000..c16626901 --- /dev/null +++ b/src/chatlog/toxfileprogress.h @@ -0,0 +1,53 @@ +/* + Copyright © 2018 by The qTox Project Contributors + + This file is part of qTox, a Qt-based graphical interface for Tox. + + qTox is libre software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + qTox is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with qTox. If not, see . +*/ + +#ifndef TOXFILEPROGRESS_H +#define TOXFILEPROGRESS_H + +#include + +struct ToxFile; + +class ToxFileProgress +{ +public: + bool needsUpdate() const; + void addSample(ToxFile const& file); + void resetSpeed(); + + double getProgress() const; + double getSpeed() const; + double getTimeLeftSeconds() const; + +private: + uint64_t lastBytesSent = 0; + + static const uint8_t TRANSFER_ROLLING_AVG_COUNT = 4; + uint8_t meanIndex = 0; + double meanData[TRANSFER_ROLLING_AVG_COUNT] = {0.0}; + + QTime lastTick = QTime::currentTime(); + + double speedBytesPerSecond; + double timeLeftSeconds; + double progress; +}; + + +#endif // TOXFILEPROGRESS_H diff --git a/src/core/corefile.cpp b/src/core/corefile.cpp index 19a4edd40..8b38e1546 100644 --- a/src/core/corefile.cpp +++ b/src/core/corefile.cpp @@ -108,7 +108,6 @@ void CoreFile::sendAvatarFile(Core* core, uint32_t friendId, const QByteArray& d ToxFile file{fileNum, friendId, "", "", ToxFile::SENDING}; file.filesize = filesize; - file.fileName = QByteArray((char*)avatarHash, TOX_HASH_LENGTH); file.fileKind = TOX_FILE_KIND_AVATAR; file.avatarData = data; file.resumeFileId.resize(TOX_FILE_ID_LENGTH); @@ -121,19 +120,18 @@ void CoreFile::sendFile(Core* core, uint32_t friendId, QString filename, QString long long filesize) { QMutexLocker mlocker(&fileSendMutex); - - QByteArray fileName = filename.toUtf8(); + ToxString fileName(filename); TOX_ERR_FILE_SEND sendErr; uint32_t fileNum = tox_file_send(core->tox.get(), friendId, TOX_FILE_KIND_DATA, filesize, - nullptr, (uint8_t*)fileName.data(), fileName.size(), &sendErr); + nullptr, fileName.data(), fileName.size(), &sendErr); if (sendErr != TOX_ERR_FILE_SEND_OK) { qWarning() << "sendFile: Can't create the Tox file sender (" << sendErr << ")"; - emit core->fileSendFailed(friendId, filename); + emit core->fileSendFailed(friendId, fileName.getQString()); return; } qDebug() << QString("sendFile: Created file sender %1 with friend %2").arg(fileNum).arg(friendId); - ToxFile file{fileNum, friendId, fileName, filePath, ToxFile::SENDING}; + ToxFile file{fileNum, friendId, fileName.getQString(), filePath, ToxFile::SENDING}; file.filesize = filesize; file.resumeFileId.resize(TOX_FILE_ID_LENGTH); tox_file_get_file_id(core->tox.get(), friendId, fileNum, (uint8_t*)file.resumeFileId.data(), @@ -199,7 +197,7 @@ void CoreFile::cancelFileSend(Core* core, uint32_t friendId, uint32_t fileId) return; } - file->status = ToxFile::STOPPED; + file->status = ToxFile::CANCELED; emit core->fileTransferCancelled(*file); tox_file_control(core->tox.get(), file->friendId, file->fileNum, TOX_FILE_CONTROL_CANCEL, nullptr); removeFile(friendId, fileId); @@ -212,7 +210,7 @@ void CoreFile::cancelFileRecv(Core* core, uint32_t friendId, uint32_t fileId) qWarning("cancelFileRecv: No such file in queue"); return; } - file->status = ToxFile::STOPPED; + file->status = ToxFile::CANCELED; emit core->fileTransferCancelled(*file); tox_file_control(core->tox.get(), file->friendId, file->fileNum, TOX_FILE_CONTROL_CANCEL, nullptr); removeFile(friendId, fileId); @@ -225,7 +223,7 @@ void CoreFile::rejectFileRecvRequest(Core* core, uint32_t friendId, uint32_t fil qWarning("rejectFileRecvRequest: No such file in queue"); return; } - file->status = ToxFile::STOPPED; + file->status = ToxFile::CANCELED; emit core->fileTransferCancelled(*file); tox_file_control(core->tox.get(), file->friendId, file->fileNum, TOX_FILE_CONTROL_CANCEL, nullptr); removeFile(friendId, fileId); @@ -379,6 +377,7 @@ void CoreFile::onFileControlCallback(Tox*, uint32_t friendId, uint32_t fileId, if (control == TOX_FILE_CONTROL_CANCEL) { if (file->fileKind != TOX_FILE_KIND_AVATAR) qDebug() << "File tranfer" << friendId << ":" << fileId << "cancelled by friend"; + file->status = ToxFile::CANCELED; emit static_cast(core)->fileTransferCancelled(*file); removeFile(friendId, fileId); } else if (control == TOX_FILE_CONTROL_PAUSE) { @@ -409,6 +408,7 @@ void CoreFile::onFileDataCallback(Tox* tox, uint32_t friendId, uint32_t fileId, // If we reached EOF, ack and cleanup the transfer if (!length) { + file->status = ToxFile::FINISHED; if (file->fileKind != TOX_FILE_KIND_AVATAR) { emit static_cast(core)->fileTransferFinished(*file); emit static_cast(core)->fileUploadFinished(file->filePath); @@ -429,12 +429,14 @@ void CoreFile::onFileDataCallback(Tox* tox, uint32_t friendId, uint32_t fileId, nread = file->file->read((char*)data.get(), length); if (nread <= 0) { qWarning("onFileDataCallback: Failed to read from file"); + file->status = ToxFile::CANCELED; emit static_cast(core)->fileTransferCancelled(*file); tox_file_send_chunk(tox, friendId, fileId, pos, nullptr, 0, nullptr); removeFile(friendId, fileId); return; } file->bytesSent += length; + file->hashGenerator->addData((const char*)data.get(), length); } if (!tox_file_send_chunk(tox, friendId, fileId, pos, data.get(), nread, nullptr)) { @@ -458,14 +460,17 @@ void CoreFile::onFileRecvChunkCallback(Tox* tox, uint32_t friendId, uint32_t fil if (file->bytesSent != position) { qWarning("onFileRecvChunkCallback: Received a chunk out-of-order, aborting transfer"); - if (file->fileKind != TOX_FILE_KIND_AVATAR) + if (file->fileKind != TOX_FILE_KIND_AVATAR) { + file->status = ToxFile::CANCELED; emit core->fileTransferCancelled(*file); + } tox_file_control(tox, friendId, fileId, TOX_FILE_CONTROL_CANCEL, nullptr); removeFile(friendId, fileId); return; } if (!length) { + file->status = ToxFile::FINISHED; if (file->fileKind == TOX_FILE_KIND_AVATAR) { QPixmap pic; pic.loadFromData(file->avatarData); @@ -486,6 +491,7 @@ void CoreFile::onFileRecvChunkCallback(Tox* tox, uint32_t friendId, uint32_t fil else file->file->write((char*)data, length); file->bytesSent += length; + file->hashGenerator->addData((const char*)data, length); if (file->fileKind != TOX_FILE_KIND_AVATAR) emit static_cast(core)->fileTransferInfo(*file); diff --git a/src/core/toxfile.cpp b/src/core/toxfile.cpp index bd1ac7665..47a8820d3 100644 --- a/src/core/toxfile.cpp +++ b/src/core/toxfile.cpp @@ -18,7 +18,7 @@ /** * @brief ToxFile constructor */ -ToxFile::ToxFile(uint32_t fileNum, uint32_t friendId, QByteArray filename, QString filePath, +ToxFile::ToxFile(uint32_t fileNum, uint32_t friendId, QString filename, QString filePath, FileDirection Direction) : fileKind{TOX_FILE_KIND_DATA} , fileNum(fileNum) @@ -28,10 +28,9 @@ ToxFile::ToxFile(uint32_t fileNum, uint32_t friendId, QByteArray filename, QStri , file{new QFile(filePath)} , bytesSent{0} , filesize{0} - , status{STOPPED} + , status{INITIALIZING} , direction{Direction} -{ -} +{} bool ToxFile::operator==(const ToxFile& other) const { diff --git a/src/core/toxfile.h b/src/core/toxfile.h index 358366fb9..fa47e0f9c 100644 --- a/src/core/toxfile.h +++ b/src/core/toxfile.h @@ -3,32 +3,36 @@ #include #include +#include class QFile; class QTimer; struct ToxFile { + // Note do not change values, these are directly inserted into the DB in their + // current form, changing order would mess up database state! enum FileStatus { - STOPPED, - PAUSED, - TRANSMITTING, - BROKEN + INITIALIZING = 0, + PAUSED = 1, + TRANSMITTING = 2, + BROKEN = 3, + CANCELED = 4, + FINISHED = 5, }; + // Note do not change values, these are directly inserted into the DB in their + // current form (can add fields though as db representation is an int) enum FileDirection : bool { - SENDING, - RECEIVING + SENDING = 0, + RECEIVING = 1, }; ToxFile() = default; - ToxFile(uint32_t FileNum, uint32_t FriendId, QByteArray FileName, QString filePath, + ToxFile(uint32_t FileNum, uint32_t FriendId, QString FileName, QString filePath, FileDirection Direction); - ~ToxFile() - { - } bool operator==(const ToxFile& other) const; bool operator!=(const ToxFile& other) const; @@ -39,7 +43,7 @@ struct ToxFile uint8_t fileKind; uint32_t fileNum; uint32_t friendId; - QByteArray fileName; + QString fileName; QString filePath; std::shared_ptr file; quint64 bytesSent; @@ -48,6 +52,7 @@ struct ToxFile FileDirection direction; QByteArray avatarData; QByteArray resumeFileId; + std::shared_ptr hashGenerator = std::make_shared(QCryptographicHash::Sha256); }; #endif // CORESTRUCTS_H diff --git a/src/persistence/history.cpp b/src/persistence/history.cpp index b259e8bdd..573d4f767 100644 --- a/src/persistence/history.cpp +++ b/src/persistence/history.cpp @@ -34,7 +34,15 @@ * Caches mappings to speed up message saving. */ -static constexpr int NUM_MESSAGES_DEFAULT = 100; // arbitrary number of messages loaded when not loading by date +static constexpr int NUM_MESSAGES_DEFAULT = + 100; // arbitrary number of messages loaded when not loading by date +static constexpr int SCHEMA_VERSION = 1; + +FileDbInsertionData::FileDbInsertionData() +{ + static int id = qRegisterMetaType(); + (void)id; +} /** * @brief Prepares the database to work with the history. @@ -48,14 +56,44 @@ History::History(std::shared_ptr db) return; } + dbSchemaUpgrade(); + + // dbSchemaUpgrade may have put us in an invalid state + if (!isValid()) { + return; + } + + connect(this, &History::fileInsertionReady, this, &History::onFileInsertionReady); + connect(this, &History::fileInserted, this, &History::onFileInserted); + db->execLater( "CREATE TABLE IF NOT EXISTS peers (id INTEGER PRIMARY KEY, public_key TEXT NOT NULL " "UNIQUE);" "CREATE TABLE IF NOT EXISTS aliases (id INTEGER PRIMARY KEY, owner INTEGER," "display_name BLOB NOT NULL, UNIQUE(owner, display_name));" - "CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, " - "chat_id INTEGER NOT NULL, sender_alias INTEGER NOT NULL, " - "message BLOB NOT NULL);" + "CREATE TABLE IF NOT EXISTS history " + "(id INTEGER PRIMARY KEY," + "timestamp INTEGER NOT NULL," + "chat_id INTEGER NOT NULL," + "sender_alias INTEGER NOT NULL," + // even though technically a message can be null for file transfer, we've opted + // to just insert an empty string when there's no content, this moderately simplifies + // implementation as currently our database doesn't have support for optional fields. + // We would either have to insert "?" or "null" based on if message exists and then + // ensure that our blob vector always has the right number of fields. Better to just + // leave this as NOT NULL for now. + "message BLOB NOT NULL," + "file_id INTEGER);" + "CREATE TABLE IF NOT EXISTS file_transfers " + "(id INTEGER PRIMARY KEY," + "chat_id INTEGER NOT NULL," + "file_restart_id BLOB NOT NULL," + "file_name BLOB NOT NULL, " + "file_path BLOB NOT NULL," + "file_hash BLOB NOT NULL," + "file_size INTEGER NOT NULL," + "direction INTEGER NOT NULL," + "file_state INTEGER NOT NULL);" "CREATE TABLE IF NOT EXISTS faux_offline_pending (id INTEGER PRIMARY KEY);"); // Cache our current peers @@ -108,6 +146,7 @@ void History::eraseHistory() "DELETE FROM history;" "DELETE FROM aliases;" "DELETE FROM peers;" + "DELETE FROM file_transfers;" "VACUUM;"); } @@ -136,6 +175,7 @@ void History::removeFriendHistory(const QString& friendPk) "DELETE FROM history WHERE chat_id=%1; " "DELETE FROM aliases WHERE owner=%1; " "DELETE FROM peers WHERE id=%1; " + "DELETE FROM file_transfers WHERE chat_id=%1;" "VACUUM;") .arg(id); @@ -166,7 +206,7 @@ History::generateNewMessageQueries(const QString& friendPk, const QString& messa // Get the db id of the peer we're chatting with int64_t peerId; if (peers.contains(friendPk)) { - peerId = peers[friendPk]; + peerId = (peers)[friendPk]; } else { if (peers.isEmpty()) { peerId = 0; @@ -174,7 +214,7 @@ History::generateNewMessageQueries(const QString& friendPk, const QString& messa peerId = *std::max_element(peers.begin(), peers.end()) + 1; } - peers[friendPk] = peerId; + (peers)[friendPk] = peerId; queries += RawDatabase::Query(("INSERT INTO peers (id, public_key) " "VALUES (%1, '" + friendPk + "');") @@ -184,7 +224,7 @@ History::generateNewMessageQueries(const QString& friendPk, const QString& messa // Get the db id of the sender of the message int64_t senderId; if (peers.contains(sender)) { - senderId = peers[sender]; + senderId = (peers)[sender]; } else { if (peers.isEmpty()) { senderId = 0; @@ -192,7 +232,7 @@ History::generateNewMessageQueries(const QString& friendPk, const QString& messa senderId = *std::max_element(peers.begin(), peers.end()) + 1; } - peers[sender] = senderId; + (peers)[sender] = senderId; queries += RawDatabase::Query{("INSERT INTO peers (id, public_key) " "VALUES (%1, '" + sender + "');") @@ -228,6 +268,121 @@ History::generateNewMessageQueries(const QString& friendPk, const QString& messa return queries; } +void History::onFileInsertionReady(FileDbInsertionData data) +{ + + QVector queries; + std::weak_ptr weakThis = shared_from_this(); + + // peerId is guaranteed to be inserted since we just used it in addNewMessage + auto peerId = peers[data.friendPk]; + // Copy to pass into labmda for later + auto fileId = data.fileId; + queries += + RawDatabase::Query(QStringLiteral( + "INSERT INTO file_transfers (chat_id, file_restart_id, " + "file_path, file_name, file_hash, file_size, direction, file_state) " + "VALUES (%1, ?, ?, ?, ?, %2, %3, %4);") + .arg(peerId) + .arg(data.size) + .arg(static_cast(data.direction)) + .arg(ToxFile::CANCELED), + {data.fileId.toUtf8(), data.filePath.toUtf8(), data.fileName.toUtf8(), QByteArray()}, + [weakThis, fileId](int64_t id) { + auto pThis = weakThis.lock(); + if (pThis) { + emit pThis->fileInserted(id, fileId); + } + }); + + + queries += RawDatabase::Query(QStringLiteral("UPDATE history " + "SET file_id = (last_insert_rowid()) " + "WHERE id = %1") + .arg(data.historyId)); + + db->execLater(queries); +} + +void History::onFileInserted(int64_t dbId, QString fileId) +{ + auto& fileInfo = fileInfos[fileId]; + if (fileInfo.finished) { + db->execLater( + generateFileFinished(dbId, fileInfo.success, fileInfo.filePath, fileInfo.fileHash)); + fileInfos.remove(fileId); + } else { + fileInfo.finished = false; + fileInfo.fileId = dbId; + } +} + +RawDatabase::Query History::generateFileFinished(int64_t id, bool success, const QString& filePath, + const QByteArray& fileHash) +{ + auto file_state = success ? ToxFile::FINISHED : ToxFile::CANCELED; + if (filePath.length()) { + return RawDatabase::Query(QStringLiteral("UPDATE file_transfers " + "SET file_state = %1, file_path = ?, file_hash = ?" + "WHERE id = %2") + .arg(file_state) + .arg(id), + {filePath.toUtf8(), fileHash}); + } else { + return RawDatabase::Query(QStringLiteral("UPDATE file_transfers " + "SET finished = %1 " + "WHERE id = %2") + .arg(file_state) + .arg(id)); + } +} + +void History::addNewFileMessage(const QString& friendPk, const QString& fileId, + const QString& fileName, const QString& filePath, int64_t size, + const QString& sender, const QDateTime& time, QString const& dispName) +{ + // This is an incredibly far from an optimal way of implementing this, + // but given the frequency that people are going to be initiating a file + // transfer we can probably live with it. + + // Since both inserting an alias for a user and inserting a file transfer + // will generate new ids, there is no good way to inject both new ids into the + // history query without refactoring our RawDatabase::Query and processor loops. + + // What we will do instead is chain callbacks to try to get reasonable behavior. + // We can call the generateNewMessageQueries() fn to insert a message with an empty + // message in it, and get the id with the callbck. Once we have the id we can ammend + // the data to have our newly inserted file_id as well + + ToxFile::FileDirection direction; + if (sender == friendPk) { + direction = ToxFile::RECEIVING; + } else { + direction = ToxFile::SENDING; + } + + std::weak_ptr weakThis = shared_from_this(); + FileDbInsertionData insertionData; + insertionData.friendPk = friendPk; + insertionData.fileId = fileId; + insertionData.fileName = fileName; + insertionData.filePath = filePath; + insertionData.size = size; + insertionData.direction = direction; + + auto insertFileTransferFn = [weakThis, insertionData](int64_t messageId) { + auto insertionDataRw = std::move(insertionData); + + insertionDataRw.historyId = messageId; + + auto thisPtr = weakThis.lock(); + if (thisPtr) + emit thisPtr->fileInsertionReady(std::move(insertionDataRw)); + }; + + addNewMessage(friendPk, "", sender, time, true, dispName, insertFileTransferFn); +} + /** * @brief Saves a chat message in the database. * @param friendPk Friend publick key to save. @@ -254,6 +409,21 @@ void History::addNewMessage(const QString& friendPk, const QString& message, con insertIdCallback)); } +void History::setFileFinished(const QString& fileId, bool success, const QString& filePath, + const QByteArray& fileHash) +{ + auto& fileInfo = fileInfos[fileId]; + if (fileInfo.fileId == -1) { + fileInfo.finished = true; + fileInfo.success = success; + fileInfo.filePath = filePath; + fileInfo.fileHash = fileHash; + } else { + db->execLater(generateFileFinished(fileInfo.fileId, success, filePath, fileHash)); + } + + fileInfos.remove(fileId); +} /** * @brief Fetches chat messages from the database. * @param friendPk Friend publick key to fetch. @@ -261,8 +431,8 @@ void History::addNewMessage(const QString& friendPk, const QString& message, con * @param to End of period to fetch. * @return List of messages. */ -QList History::getChatHistoryFromDate(const QString& friendPk, const QDateTime& from, - const QDateTime& to) +QList History::getChatHistoryFromDate(const QString& friendPk, + const QDateTime& from, const QDateTime& to) { if (!isValid()) { return {}; @@ -280,7 +450,8 @@ QList History::getChatHistoryDefaultNum(const QString& fri if (!isValid()) { return {}; } - return getChatHistory(friendPk, QDateTime::fromMSecsSinceEpoch(0), QDateTime::currentDateTime(), NUM_MESSAGES_DEFAULT); + return getChatHistory(friendPk, QDateTime::fromMSecsSinceEpoch(0), QDateTime::currentDateTime(), + NUM_MESSAGES_DEFAULT); } @@ -333,7 +504,8 @@ QList History::getChatHistoryCounts(const ToxPk& friendPk * @param parameter for search * @return date of the message where the phrase was found */ -QDateTime History::getDateWhereFindPhrase(const QString& friendPk, const QDateTime& from, QString phrase, const ParameterSearch& parameter) +QDateTime History::getDateWhereFindPhrase(const QString& friendPk, const QDateTime& from, + QString phrase, const ParameterSearch& parameter) { QDateTime result; auto rowCallback = [&result](const QVector& row) { @@ -349,10 +521,12 @@ QDateTime History::getDateWhereFindPhrase(const QString& friendPk, const QDateTi message = QStringLiteral("message LIKE '%%1%'").arg(phrase); break; case FilterSearch::WordsOnly: - message = QStringLiteral("message REGEXP '%1'").arg(SearchExtraFunctions::generateFilterWordsOnly(phrase).toLower()); + message = QStringLiteral("message REGEXP '%1'") + .arg(SearchExtraFunctions::generateFilterWordsOnly(phrase).toLower()); break; case FilterSearch::RegisterAndWordsOnly: - message = QStringLiteral("REGEXPSENSITIVE(message, '%1')").arg(SearchExtraFunctions::generateFilterWordsOnly(phrase)); + message = QStringLiteral("REGEXPSENSITIVE(message, '%1')") + .arg(SearchExtraFunctions::generateFilterWordsOnly(phrase)); break; case FilterSearch::Regular: message = QStringLiteral("message REGEXP '%1'").arg(phrase); @@ -376,24 +550,27 @@ QDateTime History::getDateWhereFindPhrase(const QString& friendPk, const QDateTi period = QStringLiteral("ORDER BY timestamp ASC LIMIT 1;"); break; case PeriodSearch::AfterDate: - period = QStringLiteral("AND timestamp > '%1' ORDER BY timestamp ASC LIMIT 1;").arg(date.toMSecsSinceEpoch()); + period = QStringLiteral("AND timestamp > '%1' ORDER BY timestamp ASC LIMIT 1;") + .arg(date.toMSecsSinceEpoch()); break; case PeriodSearch::BeforeDate: - period = QStringLiteral("AND timestamp < '%1' ORDER BY timestamp DESC LIMIT 1;").arg(date.toMSecsSinceEpoch()); + period = QStringLiteral("AND timestamp < '%1' ORDER BY timestamp DESC LIMIT 1;") + .arg(date.toMSecsSinceEpoch()); break; default: - period = QStringLiteral("AND timestamp < '%1' ORDER BY timestamp DESC LIMIT 1;").arg(date.toMSecsSinceEpoch()); + period = QStringLiteral("AND timestamp < '%1' ORDER BY timestamp DESC LIMIT 1;") + .arg(date.toMSecsSinceEpoch()); break; } QString queryText = QStringLiteral("SELECT timestamp " - "FROM history " - "LEFT JOIN faux_offline_pending ON history.id = faux_offline_pending.id " - "JOIN peers chat ON chat_id = chat.id " - "WHERE chat.public_key='%1' " - "AND %2 " - "%3") + "FROM history " + "LEFT JOIN faux_offline_pending ON history.id = faux_offline_pending.id " + "JOIN peers chat ON chat_id = chat.id " + "WHERE chat.public_key='%1' " + "AND %2 " + "%3") .arg(friendPk) .arg(message) .arg(period); @@ -408,7 +585,7 @@ QDateTime History::getDateWhereFindPhrase(const QString& friendPk, const QDateTi * @param friendPk Friend public key * @return start date of correspondence */ -QDateTime History::getStartDateChatHistory(const QString &friendPk) +QDateTime History::getStartDateChatHistory(const QString& friendPk) { QDateTime result; auto rowCallback = [&result](const QVector& row) { @@ -416,11 +593,11 @@ QDateTime History::getStartDateChatHistory(const QString &friendPk) }; QString queryText = - QStringLiteral("SELECT timestamp " - "FROM history " - "LEFT JOIN faux_offline_pending ON history.id = faux_offline_pending.id " - "JOIN peers chat ON chat_id = chat.id " - "WHERE chat.public_key='%1' ORDER BY timestamp ASC LIMIT 1;") + QStringLiteral("SELECT timestamp " + "FROM history " + "LEFT JOIN faux_offline_pending ON history.id = faux_offline_pending.id " + "JOIN peers chat ON chat_id = chat.id " + "WHERE chat.public_key='%1' ORDER BY timestamp ASC LIMIT 1;") .arg(friendPk); db->execNow({queryText, rowCallback}); @@ -460,31 +637,50 @@ QList History::getChatHistory(const QString& friendPk, con auto rowCallback = [&messages](const QVector& row) { // dispName and message could have null bytes, QString::fromUtf8 // truncates on null bytes so we strip them - messages += {row[0].toLongLong(), - row[1].isNull(), - QDateTime::fromMSecsSinceEpoch(row[2].toLongLong()), - row[3].toString(), - QString::fromUtf8(row[4].toByteArray().replace('\0', "")), - row[5].toString(), - QString::fromUtf8(row[6].toByteArray().replace('\0', ""))}; + auto id = row[0].toLongLong(); + auto isOfflineMessage = row[1].isNull(); + auto timestamp = QDateTime::fromMSecsSinceEpoch(row[2].toLongLong()); + auto friend_key = row[3].toString(); + auto display_name = QString::fromUtf8(row[4].toByteArray().replace('\0', "")); + auto sender_key = row[5].toString(); + if (row[7].isNull()) { + messages += + {id, isOfflineMessage, timestamp, friend_key, display_name, sender_key, row[6].toString()}; + } else { + ToxFile file; + file.fileKind = TOX_FILE_KIND_DATA; + file.resumeFileId = row[7].toString().toUtf8(); + file.filePath = row[8].toString(); + file.fileName = row[9].toString(); + file.filesize = row[10].toLongLong(); + file.direction = static_cast(row[11].toLongLong()); + file.status = static_cast(row[12].toInt()); + messages += + {id, isOfflineMessage, timestamp, friend_key, display_name, sender_key, file}; + } }; // Don't forget to update the rowCallback if you change the selected columns! QString queryText = QString("SELECT history.id, faux_offline_pending.id, timestamp, " "chat.public_key, aliases.display_name, sender.public_key, " - "message FROM history " + "message, file_transfers.file_restart_id, " + "file_transfers.file_path, file_transfers.file_name, " + "file_transfers.file_size, file_transfers.direction, " + "file_transfers.file_state FROM history " "LEFT JOIN faux_offline_pending ON history.id = faux_offline_pending.id " - "JOIN peers chat ON chat_id = chat.id " + "JOIN peers chat ON history.chat_id = chat.id " "JOIN aliases ON sender_alias = aliases.id " "JOIN peers sender ON aliases.owner = sender.id " + "LEFT JOIN file_transfers ON history.file_id = file_transfers.id " "WHERE timestamp BETWEEN %1 AND %2 AND chat.public_key='%3'") .arg(from.toMSecsSinceEpoch()) .arg(to.toMSecsSinceEpoch()) .arg(friendPk); if (numMessages) { - queryText = "SELECT * FROM (" + queryText + - QString(" ORDER BY history.id DESC limit %1) AS T1 ORDER BY T1.id ASC;").arg(numMessages); + queryText = + "SELECT * FROM (" + queryText + + QString(" ORDER BY history.id DESC limit %1) AS T1 ORDER BY T1.id ASC;").arg(numMessages); } else { queryText = queryText + ";"; } @@ -493,3 +689,44 @@ QList History::getChatHistory(const QString& friendPk, con return messages; } + +/** + * @brief Upgrade the db schema + * @note On future alterations of the database all you have to do is bump the SCHEMA_VERSION + * variable and add another case to the switch statement below. Make sure to fall through on each case. + */ +void History::dbSchemaUpgrade() +{ + int64_t databaseSchemaVersion; + db->execNow(RawDatabase::Query("PRAGMA user_version", [&](const QVector& row) { + databaseSchemaVersion = row[0].toLongLong(); + })); + + if (databaseSchemaVersion > SCHEMA_VERSION) { + qWarning() << "Database version is newer than we currently support. Please upgrade qTox"; + // We don't know what future versions have done, we have to disable db access until we re-upgrade + db.reset(); + return; + } else if (databaseSchemaVersion == SCHEMA_VERSION) { + // No work to do + return; + } + + // Make sure to handle the un-created case as well in the following upgrade code + switch (databaseSchemaVersion) { + case 0: + // This will generate a warning on new profiles, but we have no easy way to chain execs. I + // don't want to block the rest of the program on db creation so I guess we can just live with the warning for now + db->execLater(RawDatabase::Query("ALTER TABLE history ADD file_id INTEGER;")); + // fallthrough + // case 1: + // do 1 -> 2 upgrade + // //fallthrough + // etc. + default: + db->execLater( + RawDatabase::Query(QStringLiteral("PRAGMA user_version = %1;").arg(SCHEMA_VERSION))); + qDebug() << "Database upgrade finished (databaseSchemaVersion " << databaseSchemaVersion + << " -> " << SCHEMA_VERSION << ")"; + } +} diff --git a/src/persistence/history.h b/src/persistence/history.h index 5da854378..4da8a9266 100644 --- a/src/persistence/history.h +++ b/src/persistence/history.h @@ -22,11 +22,14 @@ #include #include +#include #include +#include #include #include +#include "src/core/toxfile.h" #include "src/core/toxpk.h" #include "src/persistence/db/rawdatabase.h" #include "src/widget/searchtypes.h" @@ -34,8 +37,77 @@ class Profile; class HistoryKeeper; -class History +enum class HistMessageContentType { + message, + file +}; + +class HistMessageContent +{ +public: + HistMessageContent(QString message) + : data(std::make_shared(std::move(message))) + , type(HistMessageContentType::message) + {} + + HistMessageContent(ToxFile file) + : data(std::make_shared(std::move(file))) + , type(HistMessageContentType::file) + {} + + HistMessageContentType getType() const + { + return type; + } + + QString& asMessage() + { + assert(type == HistMessageContentType::message); + return *static_cast(data.get()); + } + + ToxFile& asFile() + { + assert(type == HistMessageContentType::file); + return *static_cast(data.get()); + } + + const QString& asMessage() const + { + assert(type == HistMessageContentType::message); + return *static_cast(data.get()); + } + + const ToxFile& asFile() const + { + assert(type == HistMessageContentType::file); + return *static_cast(data.get()); + } + +private: + // Not really shared but shared_ptr has support for shared_ptr + std::shared_ptr data; + HistMessageContentType type; +}; + +struct FileDbInsertionData +{ + FileDbInsertionData(); + + int64_t historyId; + QString friendPk; + QString fileId; + QString fileName; + QString filePath; + int64_t size; + int direction; +}; +Q_DECLARE_METATYPE(FileDbInsertionData); + +class History : public QObject, public std::enable_shared_from_this +{ + Q_OBJECT public: struct HistMessage { @@ -43,21 +115,32 @@ public: QString sender, QString message) : chat{chat} , sender{sender} - , message{message} , dispName{dispName} , timestamp{timestamp} , id{id} , isSent{isSent} - { - } + , content(std::move(message)) + {} + + HistMessage(qint64 id, bool isSent, QDateTime timestamp, QString chat, QString dispName, + QString sender, ToxFile file) + : chat{chat} + , sender{sender} + , dispName{dispName} + , timestamp{timestamp} + , id{id} + , isSent{isSent} + , content(std::move(file)) + {} + QString chat; QString sender; - QString message; QString dispName; QDateTime timestamp; qint64 id; bool isSent; + HistMessageContent content; }; struct DateMessages @@ -80,6 +163,12 @@ public: const QDateTime& time, bool isSent, QString dispName, const std::function& insertIdCallback = {}); + void addNewFileMessage(const QString& friendPk, const QString& fileId, + const QString& fileName, const QString& filePath, int64_t size, + const QString& sender, const QDateTime& time, QString const& dispName); + + void setFileFinished(const QString& fileId, bool success, const QString& filePath, const QByteArray& fileHash); + QList getChatHistoryFromDate(const QString& friendPk, const QDateTime& from, const QDateTime& to); QList getChatHistoryDefaultNum(const QString& friendPk); @@ -96,11 +185,37 @@ protected: const QString& sender, const QDateTime& time, bool isSent, QString dispName, std::function insertIdCallback = {}); +signals: + void fileInsertionReady(FileDbInsertionData data); + void fileInserted(int64_t dbId, QString fileId); + +private slots: + void onFileInsertionReady(FileDbInsertionData data); + void onFileInserted(int64_t dbId, QString fileId); + private: QList getChatHistory(const QString& friendPk, const QDateTime& from, const QDateTime& to, int numMessages); + + static RawDatabase::Query generateFileFinished(int64_t fileId, bool success, + const QString& filePath, const QByteArray& fileHash); + void dbSchemaUpgrade(); + std::shared_ptr db; + + QHash peers; + struct FileInfo + { + bool finished = false; + bool success = false; + QString filePath; + QByteArray fileHash; + int64_t fileId = -1; + }; + + // This needs to be a shared pointer to avoid callback lifetime issues + QHash fileInfos; }; #endif // HISTORY_H diff --git a/src/persistence/profile.cpp b/src/persistence/profile.cpp index 67308d798..696c6945b 100644 --- a/src/persistence/profile.cpp +++ b/src/persistence/profile.cpp @@ -748,7 +748,7 @@ QStringList Profile::remove() qWarning() << "Could not remove file " << dbPath; } - history.release(); + history.reset(); database.reset(); return ret; diff --git a/src/persistence/profile.h b/src/persistence/profile.h index f16dbea99..ac1004b4e 100644 --- a/src/persistence/profile.h +++ b/src/persistence/profile.h @@ -109,7 +109,7 @@ private: QString name; std::unique_ptr passkey = nullptr; std::shared_ptr database; - std::unique_ptr history; + std::shared_ptr history; bool isRemoved; bool encrypted = false; static QStringList profiles; diff --git a/src/widget/form/chatform.cpp b/src/widget/form/chatform.cpp index a1f930285..1cde8ddf2 100644 --- a/src/widget/form/chatform.cpp +++ b/src/widget/form/chatform.cpp @@ -161,6 +161,9 @@ ChatForm::ChatForm(Friend* chatFriend, History* history) connect(core, &Core::fileReceiveRequested, this, &ChatForm::onFileRecvRequest); connect(profile, &Profile::friendAvatarChanged, this, &ChatForm::onAvatarChanged); connect(core, &Core::fileSendStarted, this, &ChatForm::startFileSend); + connect(core, &Core::fileTransferFinished, this, &ChatForm::onFileTransferFinished); + connect(core, &Core::fileTransferCancelled, this, &ChatForm::onFileTransferCancelled); + connect(core, &Core::fileTransferBrokenUnbroken, this, &ChatForm::onFileTransferBrokenUnbroken); connect(core, &Core::fileSendFailed, this, &ChatForm::onFileSendFailed); connect(core, &Core::receiptRecieved, this, &ChatForm::onReceiptReceived); connect(core, &Core::friendMessageReceived, this, &ChatForm::onFriendMessageReceived); @@ -312,9 +315,35 @@ void ChatForm::startFileSend(ToxFile file) insertChatMessage( ChatMessage::createFileTransferMessage(name, file, true, QDateTime::currentDateTime())); + + if (history && Settings::getInstance().getEnableLogging()) { + auto selfPk = Core::getInstance()->getSelfId().toString(); + auto pk = f->getPublicKey().toString(); + auto name = Core::getInstance()->getUsername(); + history->addNewFileMessage(pk, file.resumeFileId, file.fileName, file.filePath, + file.filesize, selfPk, QDateTime::currentDateTime(), name); + } + Widget::getInstance()->updateFriendActivity(f); } +void ChatForm::onFileTransferFinished(ToxFile file) +{ + history->setFileFinished(file.resumeFileId, true, file.filePath, file.hashGenerator->result()); +} + +void ChatForm::onFileTransferBrokenUnbroken(ToxFile file, bool broken) +{ + if (broken) { + history->setFileFinished(file.resumeFileId, false, file.filePath, file.hashGenerator->result()); + } +} + +void ChatForm::onFileTransferCancelled(ToxFile file) +{ + history->setFileFinished(file.resumeFileId, false, file.filePath, file.hashGenerator->result()); +} + void ChatForm::onFileRecvRequest(ToxFile file) { if (file.friendId != f->getId()) { @@ -331,9 +360,17 @@ void ChatForm::onFileRecvRequest(ToxFile file) ChatMessage::Ptr msg = ChatMessage::createFileTransferMessage(name, file, false, QDateTime::currentDateTime()); + insertChatMessage(msg); + if (history && Settings::getInstance().getEnableLogging()) { + auto pk = f->getPublicKey().toString(); + auto name = f->getDisplayedName(); + history->addNewFileMessage(pk, file.resumeFileId, file.fileName, file.filePath, + file.filesize, pk, QDateTime::currentDateTime(), name); + } ChatLineContentProxy* proxy = static_cast(msg->getContent(1)); + assert(proxy->getWidgetType() == ChatLineContentProxy::FileTransferWidgetType); FileTransferWidget* tfWidget = static_cast(proxy->getWidget()); @@ -794,6 +831,11 @@ void ChatForm::handleLoadedMessages(QList newHistMsgs, boo MessageMetadata const metadata = getMessageMetadata(histMessage); lastDate = addDateLineIfNeeded(chatLines, lastDate, histMessage, metadata); auto msg = chatMessageFromHistMessage(histMessage, metadata); + + if (!msg) { + continue; + } + if (processUndelivered) { sendLoadedMessage(msg, metadata); } @@ -838,7 +880,9 @@ ChatForm::MessageMetadata ChatForm::getMessageMetadata(History::HistMessage cons const QDateTime msgDateTime = histMessage.timestamp.toLocalTime(); const bool isSelf = Core::getInstance()->getSelfId().getPublicKey() == authorPk; const bool needSending = !histMessage.isSent && isSelf; - const bool isAction = histMessage.message.startsWith(ACTION_PREFIX, Qt::CaseInsensitive); + const bool isAction = + histMessage.content.getType() == HistMessageContentType::message + && histMessage.content.asMessage().startsWith(ACTION_PREFIX, Qt::CaseInsensitive); const qint64 id = histMessage.id; return {isSelf, needSending, isAction, id, authorPk, msgDateTime}; } @@ -848,11 +892,31 @@ ChatMessage::Ptr ChatForm::chatMessageFromHistMessage(History::HistMessage const { ToxPk authorPk(ToxId(histMessage.sender).getPublicKey()); QString authorStr = getMsgAuthorDispName(authorPk, histMessage.dispName); - QString messageText = - metadata.isAction ? histMessage.message.mid(ACTION_PREFIX.length()) : histMessage.message; - ChatMessage::MessageType type = metadata.isAction ? ChatMessage::ACTION : ChatMessage::NORMAL; QDateTime dateTime = metadata.needSending ? QDateTime() : metadata.msgDateTime; - auto msg = ChatMessage::createChatMessage(authorStr, messageText, type, metadata.isSelf, dateTime); + + + ChatMessage::Ptr msg; + + switch (histMessage.content.getType()) { + case HistMessageContentType::message: { + ChatMessage::MessageType type = metadata.isAction ? ChatMessage::ACTION : ChatMessage::NORMAL; + auto& message = histMessage.content.asMessage(); + QString messageText = metadata.isAction ? message.mid(ACTION_PREFIX.length()) : message; + + msg = ChatMessage::createChatMessage(authorStr, messageText, type, metadata.isSelf, dateTime); + break; + } + case HistMessageContentType::file: { + auto& file = histMessage.content.asFile(); + bool isMe = file.direction == ToxFile::SENDING; + msg = ChatMessage::createFileTransferMessage(authorStr, file, isMe, dateTime); + break; + } + default: + qCritical() << "Invalid HistMessageContentType"; + assert(false); + } + if (!metadata.isAction && needsToHideName(authorPk, metadata.msgDateTime)) { msg->hideSender(); } @@ -1135,13 +1199,17 @@ void ChatForm::onExportChat() QString buffer; for (const auto& it : msgs) { + if (it.content.getType() != HistMessageContentType::message) { + continue; + } QString timestamp = it.timestamp.time().toString("hh:mm:ss"); QString datestamp = it.timestamp.date().toString("yyyy-MM-dd"); ToxPk authorPk(ToxId(it.sender).getPublicKey()); QString author = getMsgAuthorDispName(authorPk, it.dispName); buffer = buffer - % QString{datestamp % '\t' % timestamp % '\t' % author % '\t' % it.message % '\n'}; + % QString{datestamp % '\t' % timestamp % '\t' % author % '\t' + % it.content.asMessage() % '\n'}; } file.write(buffer.toUtf8()); file.close(); diff --git a/src/widget/form/chatform.h b/src/widget/form/chatform.h index 9f166d2db..3ee1045e4 100644 --- a/src/widget/form/chatform.h +++ b/src/widget/form/chatform.h @@ -68,11 +68,14 @@ signals: public slots: void startFileSend(ToxFile file); + void onFileTransferFinished(ToxFile file); + void onFileTransferCancelled(ToxFile file); + void onFileTransferBrokenUnbroken(ToxFile file, bool broken); void onFileRecvRequest(ToxFile file); void onAvInvite(uint32_t friendId, bool video); void onAvStart(uint32_t friendId, bool video); void onAvEnd(uint32_t friendId, bool error); - void onAvatarChanged(const ToxPk &friendPk, const QPixmap& pic); + void onAvatarChanged(const ToxPk& friendPk, const QPixmap& pic); void onFileNameChanged(const ToxPk& friendPk); void clearChatArea();