feat(filesform): Add in progress transfers to files form

As part of #1532 it was identified that long running file transfers
could get lost deep in the chatlog. This could result in unexpected use
of bandwidth over time if users lose track of old/large transfers. This
commit updates the files form to show in progress file transfers and
offer a way to control them.

* FilesForm now works on ToxFiles instead of finished file paths
* FilesForm widgets have been replaced with an MV tree view with depth
  1. The existing QListWidget did not provide us the controls to render
  more complex items. The use of delegates allows us to efficiently draw
  progress bars and controls
* getHumanReadableSize has been extracted from FileTransferWidget into a
  more general utils file
reviewable/pr6386/r10
Mick Sayson 2021-09-25 13:19:02 -07:00
parent c7efe320d2
commit 257a19caaa
20 changed files with 1188 additions and 56 deletions

View File

@ -57,6 +57,7 @@ auto_test(model messageprocessor "")
auto_test(model sessionchatlog "")
auto_test(model exiftransform "")
auto_test(model notificationgenerator "${MOCK_SOURCES}")
auto_test(widget filesform "")
if (UNIX)
auto_test(platform posixsignalnotifier "")

View File

@ -100,10 +100,13 @@
<file>themes/dark/chatArea/typing.svg</file>
<file>themes/dark/chatArea/error.svg</file>
<file>themes/dark/fileTransferInstance/no.svg</file>
<file>themes/dark/fileTransferInstance/no_dark.svg</file>
<file>themes/dark/fileTransferInstance/pause.svg</file>
<file>themes/dark/fileTransferInstance/pause_dark.svg</file>
<file>themes/dark/fileTransferInstance/yes.svg</file>
<file>themes/dark/fileTransferInstance/dir.svg</file>
<file>themes/dark/fileTransferInstance/arrow_white.svg</file>
<file>themes/dark/fileTransferInstance/arrow_black.svg</file>
<file>themes/dark/fileTransferInstance/browse.svg</file>
<file>themes/dark/fileTransferInstance/filetransferWidget.css</file>
<file>themes/dark/genericChatForm/genericChatForm.css</file>
@ -164,10 +167,13 @@
<file>themes/default/chatArea/typing.svg</file>
<file>themes/default/chatArea/error.svg</file>
<file>themes/default/fileTransferInstance/no.svg</file>
<file>themes/default/fileTransferInstance/no_dark.svg</file>
<file>themes/default/fileTransferInstance/pause.svg</file>
<file>themes/default/fileTransferInstance/pause_dark.svg</file>
<file>themes/default/fileTransferInstance/yes.svg</file>
<file>themes/default/fileTransferInstance/dir.svg</file>
<file>themes/default/fileTransferInstance/arrow_white.svg</file>
<file>themes/default/fileTransferInstance/arrow_black.svg</file>
<file>themes/default/fileTransferInstance/browse.svg</file>
<file>themes/default/fileTransferInstance/filetransferWidget.css</file>
<file>themes/default/genericChatForm/genericChatForm.css</file>

View File

@ -26,6 +26,7 @@
#include "src/widget/style.h"
#include "src/widget/widget.h"
#include "src/model/exiftransform.h"
#include "util/display.h"
#include <QBuffer>
#include <QDebug>
@ -234,18 +235,6 @@ void FileTransferWidget::reloadTheme()
updateBackgroundColor(lastStatus);
}
QString FileTransferWidget::getHumanReadableSize(qint64 size)
{
static const char* suffix[] = {"B", "KiB", "MiB", "GiB", "TiB"};
int exp = 0;
if (size > 0) {
exp = std::min(static_cast<int>(log(size) / log(1024)), static_cast<int>(sizeof(suffix) / sizeof(suffix[0]) - 1));
}
return QString().setNum(size / pow(1024, exp), 'f', exp > 1 ? 2 : 0).append(suffix[exp]);
}
void FileTransferWidget::updateWidgetColor(ToxFile const& file)
{
if (lastStatus == file.status) {

View File

@ -42,8 +42,6 @@ public:
explicit FileTransferWidget(QWidget* parent, CoreFile& _coreFile, ToxFile file);
virtual ~FileTransferWidget();
bool isActive() const;
static QString getHumanReadableSize(qint64 size);
void onFileTransferUpdate(ToxFile file);
protected:

View File

@ -448,7 +448,6 @@ void CoreFile::onFileDataCallback(Tox* tox, uint32_t friendId, uint32_t fileId,
file->status = ToxFile::FINISHED;
if (file->fileKind != TOX_FILE_KIND_AVATAR) {
emit coreFile->fileTransferFinished(*file);
emit coreFile->fileUploadFinished(file->filePath);
}
coreFile->removeFile(friendId, fileId);
return;
@ -519,7 +518,6 @@ void CoreFile::onFileRecvChunkCallback(Tox* tox, uint32_t friendId, uint32_t fil
}
} else {
emit coreFile->fileTransferFinished(*file);
emit coreFile->fileDownloadFinished(file->filePath);
}
coreFile->removeFile(friendId, fileId);
return;

View File

@ -69,8 +69,6 @@ signals:
void fileTransferAccepted(ToxFile file);
void fileTransferCancelled(ToxFile file);
void fileTransferFinished(ToxFile file);
void fileUploadFinished(const QString& path);
void fileDownloadFinished(const QString& path);
void fileTransferPaused(ToxFile file);
void fileTransferInfo(ToxFile file);
void fileTransferRemotePausedUnpaused(ToxFile file, bool paused);

View File

@ -19,6 +19,7 @@
#include "notificationgenerator.h"
#include "src/chatlog/content/filetransferwidget.h"
#include "util/display.h"
#include <QCollator>
@ -223,7 +224,7 @@ NotificationData NotificationGenerator::fileTransferNotification(const Friend* f
{
//: e.g. Bob - file transfer
ret.title = tr("%1 - file transfer").arg(f->getDisplayedName());
ret.message = filename + " (" + FileTransferWidget::getHumanReadableSize(fileSize) + ")";
ret.message = filename + " (" + getHumanReadableSize(fileSize) + ")";
}
ret.pixmap = getSenderAvatar(profile, f->getPublicKey());

View File

@ -18,16 +18,410 @@
*/
#include "filesform.h"
#include "src/core/toxfile.h"
#include "src/widget/contentlayout.h"
#include "src/widget/translator.h"
#include "src/widget/style.h"
#include "src/widget/widget.h"
#include "src/friendlist.h"
#include "util/display.h"
#include <QFileInfo>
#include <QWindow>
#include <QTableView>
#include <QHeaderView>
#include <QPushButton>
#include <QPainter>
#include <QMouseEvent>
#include <cmath>
FilesForm::FilesForm()
namespace {
QRect pauseRect(const QStyleOptionViewItem& option)
{
float controlSize = option.rect.height() * 0.8f;
float rectWidth = option.rect.width();
float buttonHorizontalArea = rectWidth / 2;
// To center the button, we find the horizontal center and subtract half
// our width from it
int buttonXPos = std::round(option.rect.x() + buttonHorizontalArea / 2 - controlSize / 2);
int buttonYPos = std::round(option.rect.y() + option.rect.height() * 0.1f);
return QRect(buttonXPos, buttonYPos, controlSize, controlSize);
}
QRect stopRect(const QStyleOptionViewItem& option)
{
float controlSize = option.rect.height() * 0.8;
float rectWidth = option.rect.width();
float buttonHorizontalArea = rectWidth / 2;
// To center the button, we find the horizontal center and subtract half
// our width from it
int buttonXPos = std::round(option.rect.x() + buttonHorizontalArea + buttonHorizontalArea / 2 - controlSize / 2);
int buttonYPos = std::round(option.rect.y() + option.rect.height() * 0.1f);
return QRect(buttonXPos, buttonYPos, controlSize, controlSize);
}
QString fileStatusString(ToxFile file)
{
switch (file.status)
{
case ToxFile::INITIALIZING:
return QObject::tr("Initializing");
case ToxFile::TRANSMITTING:
return QObject::tr("Transmitting");
case ToxFile::FINISHED:
return QObject::tr("Finished");
case ToxFile::BROKEN:
return QObject::tr("Broken");
case ToxFile::CANCELED:
return QObject::tr("Canceled");
case ToxFile::PAUSED:
if (file.pauseStatus.localPaused()) {
return QObject::tr("Paused");
} else {
return QObject::tr("Remote paused");
}
}
qWarning("Corrupt file status %d", file.status);
return "";
}
bool fileTransferFailed(const ToxFile::FileStatus& status) {
switch (status)
{
case ToxFile::INITIALIZING:
case ToxFile::PAUSED:
case ToxFile::TRANSMITTING:
case ToxFile::FINISHED:
return false;
case ToxFile::BROKEN:
case ToxFile::CANCELED:
return true;
}
qWarning("Invalid file status: %d", status);
return true;
}
bool shouldProcessFileKind(uint8_t inKind)
{
auto kind = static_cast<TOX_FILE_KIND>(inKind);
switch (kind)
{
case TOX_FILE_KIND_DATA: return true;
// Avatar sharing should be seamless, the user does not need to see
// these in their file transfer list.
case TOX_FILE_KIND_AVATAR: return false;
}
qWarning("Unexpected file kind %d", kind);
return false;
}
} // namespace
namespace FileTransferList
{
Column toFileTransferListColumn(int in) {
if (in >= 0 && in < static_cast<int>(Column::invalid)) {
return static_cast<Column>(in);
}
qWarning("Invalid file transfer list column %d", in);
return Column::invalid;
}
QString toQString(Column column) {
switch (column)
{
case Column::fileName:
return QObject::tr("File Name");
case Column::contact:
return QObject::tr("Contact");
case Column::progress:
return QObject::tr("Progress");
case Column::size:
return QObject::tr("Size");
case Column::speed:
return QObject::tr("Speed");
case Column::status:
return QObject::tr("Status");
case Column::control:
return QObject::tr("Control");
case Column::invalid:
break;
}
return "";
}
EditorAction toEditorAction(int in) {
if (in < 0 || in >= static_cast<int>(EditorAction::invalid)) {
qWarning("Unexpected editor action %d", in);
return EditorAction::invalid;
}
return static_cast<EditorAction>(in);
}
Model::Model(QObject* parent)
: QAbstractTableModel(parent)
{}
QVariant Model::headerData(int section, Qt::Orientation orientation, int role) const
{
if (role != Qt::DisplayRole) {
return QVariant();
}
if (orientation != Qt::Orientation::Horizontal) {
return QVariant();
}
const auto column = toFileTransferListColumn(section);
return toQString(column);
}
void Model::onFileUpdated(const ToxFile& file)
{
if (!shouldProcessFileKind(file.fileKind)) {
return;
}
auto idxIt = idToRow.find(file.resumeFileId);
int rowIdx = 0;
if (idxIt == idToRow.end()) {
if (files.size() >= std::numeric_limits<int>::max()) {
// Bug waiting to happen, but also what can we do if qt just doesn't
// support this many items in a list
qWarning("Too many file transfers rendered, ignoring");
return;
}
auto insertedIdx = files.size();
emit rowsAboutToBeInserted(QModelIndex(), insertedIdx, insertedIdx, {});
files.push_back(file);
idToRow.insert(file.resumeFileId, insertedIdx);
emit rowsInserted(QModelIndex(), insertedIdx, insertedIdx, {});
} else {
rowIdx = idxIt.value();
files[rowIdx] = file;
if (fileTransferFailed(file.status)) {
emit rowsAboutToBeRemoved(QModelIndex(), rowIdx, rowIdx, {});
for (auto it = idToRow.begin(); it != idToRow.end(); ++it) {
if (it.value() > rowIdx) {
it.value() -= 1;
}
}
idToRow.remove(file.resumeFileId);
files.erase(files.begin() + rowIdx);
emit rowsRemoved(QModelIndex(), rowIdx, rowIdx, {});
}
else {
emit dataChanged(index(rowIdx, 0), index(rowIdx, columnCount()));
}
}
}
int Model::rowCount(const QModelIndex& parent) const
{
return files.size();
}
int Model::columnCount(const QModelIndex& parent) const
{
return static_cast<int>(Column::invalid);
}
QVariant Model::data(const QModelIndex& index, int role) const
{
const auto row = index.row();
if (row < 0 || static_cast<size_t>(row) > files.size()) {
qWarning("Invalid file transfer row %d (files: %lu)", row, files.size());
return QVariant();
}
if (role == Qt::UserRole) {
return files[row].filePath;
}
if (role != Qt::DisplayRole) {
return QVariant();
}
const auto column = toFileTransferListColumn(index.column());
switch (column)
{
case Column::fileName:
return files[row].fileName;
case Column::contact:
{
auto f = FriendList::findFriend(FriendList::id2Key(files[row].friendId));
if (f == nullptr) {
qWarning("Invalid friend for file transfer");
return "Unknown";
}
return f->getDisplayedName();
}
case Column::progress:
return files[row].progress.getProgress() * 100.0;
case Column::size:
return getHumanReadableSize(files[row].progress.getFileSize());
case Column::speed:
return getHumanReadableSize(files[row].progress.getSpeed()) + "/s";
case Column::status:
return fileStatusString(files[row]);
case Column::control:
return files[row].pauseStatus.localPaused();
case Column::invalid:
break;
}
return QVariant();
}
bool Model::setData(const QModelIndex &index, const QVariant &value, int role)
{
const auto column = toFileTransferListColumn(index.column());
if (column != Column::control) {
return false;
}
if (!value.canConvert<int>()) {
qWarning("Unexpected model data");
return false;
}
const auto action = toEditorAction(value.toInt());
switch (action)
{
case EditorAction::cancel:
emit cancel(files[index.row()]);
break;
case EditorAction::pause:
emit togglePause(files[index.row()]);
break;
case EditorAction::invalid:
return false;
}
return true;
}
Delegate::Delegate(QWidget* parent)
: QStyledItemDelegate(parent)
{}
void Delegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const
{
const auto column = toFileTransferListColumn(index.column());
switch (column)
{
case Column::progress:
{
int progress = index.data().toInt();
QStyleOptionProgressBar progressBarOption;
progressBarOption.rect = option.rect;
progressBarOption.minimum = 0;
progressBarOption.maximum = 100;
progressBarOption.progress = progress;
progressBarOption.text = QString::number(progress) + "%";
progressBarOption.textVisible = true;
QApplication::style()->drawControl(QStyle::CE_ProgressBar,
&progressBarOption, painter);
return;
}
case Column::control:
{
const auto data = index.data();
if (!data.canConvert<bool>()) {
qWarning("Unexpected control type, not rendering controls");
return;
}
const auto localPaused = data.toBool();
QPixmap pausePixmap = localPaused
? QPixmap(Style::getImagePath("fileTransferInstance/arrow_black.svg"))
: QPixmap(Style::getImagePath("fileTransferInstance/pause_dark.svg"));
QApplication::style()->drawItemPixmap(painter, pauseRect(option), Qt::AlignCenter, pausePixmap);
QPixmap stopPixmap(Style::getImagePath("fileTransferInstance/no_dark.svg"));
QApplication::style()->drawItemPixmap(painter, stopRect(option), Qt::AlignCenter, stopPixmap);
return;
}
case Column::fileName:
case Column::contact:
case Column::size:
case Column::speed:
case Column::status:
case Column::invalid:
break;
}
QStyledItemDelegate::paint(painter, option, index);
}
bool Delegate::editorEvent(QEvent* event, QAbstractItemModel* model,
const QStyleOptionViewItem& option, const QModelIndex& index)
{
if (toFileTransferListColumn(index.column()) == Column::control)
{
if (event->type() == QEvent::MouseButtonPress) {
auto mouseEvent = reinterpret_cast<QMouseEvent*>(event);
const auto pos = mouseEvent->pos();
const auto posRect = pauseRect(option);
const auto stRect = stopRect(option);
if (posRect.contains(pos)) {
model->setData(index, static_cast<int>(EditorAction::pause));
} else if (stRect.contains(pos)) {
model->setData(index, static_cast<int>(EditorAction::cancel));
}
}
return true;
}
return false;
}
View::View(QAbstractItemModel* model, QWidget* parent)
: QTableView(parent)
{
setModel(model);
// Resize to contents but stretch the file name to fill the full view
horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch);
// Visually tuned until it looked ok
horizontalHeader()->setMinimumSectionSize(75);
horizontalHeader()->setStretchLastSection(false);
verticalHeader()->hide();
setShowGrid(false);
setSelectionBehavior(QAbstractItemView::SelectRows);
setSelectionMode(QAbstractItemView::SingleSelection);
setItemDelegate(new Delegate(this));
}
View::~View() = default;
} // namespace FileTransferList
FilesForm::FilesForm(CoreFile& coreFile)
: QObject()
, doneIcon(Style::getImagePath("fileTransferWidget/fileDone.svg"))
{
head = new QWidget();
QFont bold;
@ -36,14 +430,34 @@ FilesForm::FilesForm()
head->setLayout(&headLayout);
headLayout.addWidget(&headLabel);
recvd = new QListWidget;
sent = new QListWidget;
recvdModel = new FileTransferList::Model(this);
sentModel = new FileTransferList::Model(this);
auto pauseFile = [&coreFile] (ToxFile file) {
coreFile.pauseResumeFile(file.friendId, file.fileNum);
};
auto cancelFileRecv = [&coreFile] (ToxFile file) {
coreFile.cancelFileRecv(file.friendId, file.fileNum);
};
auto cancelFileSend = [&coreFile] (ToxFile file) {
coreFile.cancelFileSend(file.friendId, file.fileNum);
};
connect(recvdModel, &FileTransferList::Model::togglePause, pauseFile);
connect(recvdModel, &FileTransferList::Model::cancel, cancelFileRecv);
connect(sentModel, &FileTransferList::Model::togglePause, pauseFile);
connect(sentModel, &FileTransferList::Model::cancel, cancelFileSend);
recvd = new FileTransferList::View(recvdModel);
sent = new FileTransferList::View(sentModel);
main.addTab(recvd, QString());
main.addTab(sent, QString());
connect(sent, &QListWidget::itemActivated, this, &FilesForm::onFileActivated);
connect(recvd, &QListWidget::itemActivated, this, &FilesForm::onFileActivated);
connect(sent, &QTableView::activated, this, &FilesForm::onSentFileActivated);
connect(recvd, &QTableView::activated, this, &FilesForm::onReceivedFileActivated);
retranslateUi();
Translator::registerHandler(std::bind(&FilesForm::retranslateUi, this), this);
@ -75,28 +489,33 @@ void FilesForm::show(ContentLayout* contentLayout)
head->show();
}
void FilesForm::onFileDownloadComplete(const QString& path)
void FilesForm::onFileUpdated(const ToxFile& inFile)
{
QListWidgetItem* tmp = new QListWidgetItem(doneIcon, QFileInfo(path).fileName());
tmp->setData(Qt::UserRole, path);
recvd->addItem(tmp);
if (!shouldProcessFileKind(inFile.fileKind)) {
return;
}
if (inFile.direction == ToxFile::SENDING) {
sentModel->onFileUpdated(inFile);
}
else if (inFile.direction == ToxFile::RECEIVING) {
recvdModel->onFileUpdated(inFile);
}
else {
qWarning("Unexpected file direction");
}
}
void FilesForm::onFileUploadComplete(const QString& path)
void FilesForm::onSentFileActivated(const QModelIndex& index)
{
QListWidgetItem* tmp = new QListWidgetItem(doneIcon, QFileInfo(path).fileName());
tmp->setData(Qt::UserRole, path);
sent->addItem(tmp);
const auto& filePath = sentModel->data(index, Qt::UserRole).toString();
Widget::confirmExecutableOpen(filePath);
}
// sadly, the ToxFile struct in core only has the file name, not the file path...
// so currently, these don't work as intended (though for now, downloads might work
// whenever they're not saved anywhere custom, thanks to the hack)
// I could do some digging around, but for now I'm tired and others already
// might know it without me needing to dig, so...
void FilesForm::onFileActivated(QListWidgetItem* item)
void FilesForm::onReceivedFileActivated(const QModelIndex& index)
{
Widget::confirmExecutableOpen(QFileInfo(item->data(Qt::UserRole).toString()));
const auto& filePath = recvdModel->data(index, Qt::UserRole).toString();
Widget::confirmExecutableOpen(filePath);
}
void FilesForm::retranslateUi()

View File

@ -19,41 +19,121 @@
#pragma once
#include "src/core/toxfile.h"
#include "src/core/corefile.h"
#include <QLabel>
#include <QListWidgetItem>
#include <QHash>
#include <QString>
#include <QTabWidget>
#include <QVBoxLayout>
#include <QAbstractTableModel>
#include <QStyledItemDelegate>
#include <QTableView>
class ContentLayout;
class QListWidget;
class QTableView;
namespace FileTransferList
{
enum class Column : int {
// NOTE: Order defines order in UI
fileName,
contact,
progress,
size,
speed,
status,
control,
invalid
};
Column toFileTransferListColumn(int in);
QString toQString(Column column);
enum class EditorAction : int {
pause,
cancel,
invalid,
};
EditorAction toEditorAction(int in);
class Model : public QAbstractTableModel
{
Q_OBJECT
public:
Model(QObject* parent = nullptr);
~Model() = default;
void onFileUpdated(const ToxFile& file);
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
int columnCount(const QModelIndex& parent = QModelIndex()) const override;
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override;
signals:
void togglePause(ToxFile file);
void cancel(ToxFile file);
private:
QHash<QByteArray /*file id*/, int /*row index*/> idToRow;
std::vector<ToxFile> files;
};
class Delegate : public QStyledItemDelegate
{
public:
Delegate(QWidget* parent = nullptr);
void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override;
bool editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, const QModelIndex& index) override;
};
class View : public QTableView
{
public:
View(QAbstractItemModel* model, QWidget* parent = nullptr);
~View();
};
} // namespace FileTransferList
class FilesForm : public QObject
{
Q_OBJECT
public:
FilesForm();
FilesForm(CoreFile& coreFile);
~FilesForm();
bool isShown() const;
void show(ContentLayout* contentLayout);
public slots:
void onFileDownloadComplete(const QString& path);
void onFileUploadComplete(const QString& path);
void onFileUpdated(const ToxFile& file);
private slots:
void onFileActivated(QListWidgetItem* item);
void onSentFileActivated(const QModelIndex& item);
void onReceivedFileActivated(const QModelIndex& item);
private:
struct FileInfo
{
QListWidgetItem* item = nullptr;
ToxFile file;
};
void retranslateUi();
private:
QWidget* head;
QIcon doneIcon;
QLabel headLabel;
QVBoxLayout headLayout;
QTabWidget main;
QListWidget *sent, *recvd;
QTableView *sent, *recvd;
FileTransferList::Model *sentModel, *recvdModel;
};

View File

@ -279,7 +279,8 @@ void Widget::init()
Style::setThemeColor(settings.getThemeColor());
filesForm = new FilesForm();
CoreFile* coreFile = core->getCoreFile();
filesForm = new FilesForm(*coreFile);
addFriendForm = new AddFriendForm(core->getSelfId());
groupInviteForm = new GroupInviteForm;
@ -292,7 +293,6 @@ void Widget::init()
updateCheck->checkForUpdate();
#endif
CoreFile* coreFile = core->getCoreFile();
profileInfo = new ProfileInfo(core, &profile);
profileForm = new ProfileForm(profileInfo);
@ -307,8 +307,6 @@ void Widget::init()
connect(&profile, &Profile::selfAvatarChanged, profileForm, &ProfileForm::onSelfAvatarLoaded);
connect(coreFile, &CoreFile::fileReceiveRequested, this, &Widget::onFileReceiveRequested);
connect(coreFile, &CoreFile::fileDownloadFinished, filesForm, &FilesForm::onFileDownloadComplete);
connect(coreFile, &CoreFile::fileUploadFinished, filesForm, &FilesForm::onFileUploadComplete);
connect(ui->addButton, &QPushButton::clicked, this, &Widget::onAddClicked);
connect(ui->groupButton, &QPushButton::clicked, this, &Widget::onGroupClicked);
connect(ui->transferButton, &QPushButton::clicked, this, &Widget::onTransferClicked);
@ -1136,6 +1134,8 @@ void Widget::dispatchFile(ToxFile file)
const auto senderPk = (file.direction == ToxFile::SENDING) ? core->getSelfPublicKey() : pk;
friendChatLogs[pk]->onFileUpdated(senderPk, file);
filesForm->onFileUpdated(file);
}
void Widget::dispatchFileWithBool(ToxFile file, bool)

View File

@ -0,0 +1,282 @@
/*
Copyright © 2021 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 <http://www.gnu.org/licenses/>.
*/
#include "src/widget/form/filesform.h"
#include "src/friendlist.h"
#include "src/model/friend.h"
#include <QTest>
#include <limits>
class TestFileTransferList : public QObject
{
Q_OBJECT
private slots:
void testFileTransferListConversion();
void testEditorActionConversion();
void testFileName();
// NOTE: Testing contact return requires a lookup in FriendList which goes
// down a large dependency chain that is not linked to this test
// void testContact();
void testProgress();
void testSize();
void testSpeed();
void testStatus();
void testControl();
void testAvatarIgnored();
void testMultipleFiles();
void testFileRemoval();
};
using namespace FileTransferList;
void TestFileTransferList::testFileTransferListConversion()
{
Model model;
for (int i = 0; i < model.columnCount(); ++i) {
QVERIFY(toFileTransferListColumn(i) != Column::invalid);
}
QCOMPARE(toFileTransferListColumn(100), Column::invalid);
}
void TestFileTransferList::testEditorActionConversion()
{
QCOMPARE(toEditorAction(static_cast<int>(EditorAction::pause)), EditorAction::pause);
QCOMPARE(toEditorAction(static_cast<int>(EditorAction::cancel)), EditorAction::cancel);
QCOMPARE(toEditorAction(55), EditorAction::invalid);
}
void TestFileTransferList::testFileName()
{
Model model;
ToxFile file;
file.fileKind = TOX_FILE_KIND_DATA;
file.fileName = "Test";
model.onFileUpdated(file);
const auto idx = model.index(0, static_cast<int>(Column::fileName));
const auto fileName = idx.data();
QCOMPARE(fileName.toString(), QString("Test"));
}
void TestFileTransferList::testProgress()
{
Model model;
ToxFile file(0, 0, "", "", 1000, ToxFile::FileDirection::SENDING);
file.progress.addSample(100, QTime(1, 0, 0));
model.onFileUpdated(file);
const auto idx = model.index(0, static_cast<int>(Column::progress));
const auto progress = idx.data();
// Progress should be in percent units, 100/1000 == 10
QCOMPARE(progress.toFloat(), 10.0f);
}
void TestFileTransferList::testSize()
{
Model model;
ToxFile file(0, 0, "", "", 1000, ToxFile::FileDirection::SENDING);
model.onFileUpdated(file);
const auto idx = model.index(0, static_cast<int>(Column::size));
auto size = idx.data();
// Size should be a human readable string
QCOMPARE(size.toString(), QString("1000B"));
// 1GB + a little to avoid floating point inaccuracy
file = ToxFile(0, 0, "", "", 1024 * 1024 * 1024 + 2, ToxFile::FileDirection::SENDING);
model.onFileUpdated(file);
size = idx.data();
QCOMPARE(size.toString(), QString("1.00GiB"));
}
void TestFileTransferList::testSpeed()
{
Model model;
ToxFile file(0, 0, "", "", 1024 * 1024, ToxFile::FileDirection::SENDING);
file.progress.addSample(100 * 1024, QTime(1, 0, 0));
file.progress.addSample(200 * 1024, QTime(1, 0, 1));
model.onFileUpdated(file);
const auto idx = model.index(0, static_cast<int>(Column::speed));
const auto speed = idx.data();
// Speed should be a human readable string
QCOMPARE(speed.toString(), QString("100KiB/s"));
}
void TestFileTransferList::testStatus()
{
Model model;
ToxFile file(0, 0, "", "", 1024 * 1024, ToxFile::FileDirection::SENDING);
file.status = ToxFile::TRANSMITTING;
model.onFileUpdated(file);
const auto idx = model.index(0, static_cast<int>(Column::status));
auto status = idx.data();
QCOMPARE(status.toString(), QString("Transmitting"));
file.status = ToxFile::PAUSED;
file.pauseStatus.remotePause();
model.onFileUpdated(file);
status = idx.data();
QCOMPARE(status.toString(), QString("Remote paused"));
file.status = ToxFile::PAUSED;
file.pauseStatus.localPause();
file.pauseStatus.remoteResume();
model.onFileUpdated(file);
status = idx.data();
QCOMPARE(status.toString(), QString("Paused"));
}
void TestFileTransferList::testControl()
{
Model model;
bool cancelCalled = false;
bool pauseCalled = false;
QObject::connect(&model, &Model::cancel, [&] (ToxFile file) {
cancelCalled = true;
});
QObject::connect(&model, &Model::togglePause, [&] (ToxFile file) {
pauseCalled = true;
});
ToxFile file(0, 0, "", "", 1024 * 1024, ToxFile::FileDirection::SENDING);
file.status = ToxFile::TRANSMITTING;
model.onFileUpdated(file);
const auto idx = model.index(0, static_cast<int>(Column::control));
model.setData(idx, static_cast<int>(EditorAction::pause));
QVERIFY(pauseCalled);
QVERIFY(!cancelCalled);
pauseCalled = false;
model.setData(idx, static_cast<int>(EditorAction::cancel));
QVERIFY(!pauseCalled);
QVERIFY(cancelCalled);
file.status = ToxFile::TRANSMITTING;
model.onFileUpdated(file);
// True if paused
QCOMPARE(idx.data().toBool(), false);
file.status = ToxFile::PAUSED;
file.pauseStatus.localPause();
model.onFileUpdated(file);
// True if _local_ paused
QCOMPARE(idx.data().toBool(), true);
}
void TestFileTransferList::testAvatarIgnored()
{
Model model;
ToxFile file;
file.fileKind = TOX_FILE_KIND_AVATAR;
model.onFileUpdated(file);
QCOMPARE(model.rowCount(), 0);
}
void TestFileTransferList::testMultipleFiles()
{
Model model;
ToxFile file;
file.resumeFileId = QByteArray();
file.fileKind = TOX_FILE_KIND_DATA;
file.status = ToxFile::TRANSMITTING;
file.fileName = "a";
model.onFileUpdated(file);
// File map keys off resume file ID
file.resumeFileId = QByteArray("asdfasdf");
file.fileName = "b";
model.onFileUpdated(file);
QCOMPARE(model.rowCount(), 2);
auto idx = model.index(0, static_cast<int>(Column::fileName));
QCOMPARE(idx.data().toString(), QString("a"));
idx = model.index(1, static_cast<int>(Column::fileName));
QCOMPARE(idx.data().toString(), QString("b"));
// File name should be updated instead of inserting a new file since the
// resume file ID is the same
file.fileName = "c";
model.onFileUpdated(file);
QCOMPARE(model.rowCount(), 2);
QCOMPARE(idx.data().toString(), QString("c"));
}
void TestFileTransferList::testFileRemoval()
{
// Model should keep files in the list if they are finished, but not if they
// were broken or canceled
Model model;
ToxFile file;
file.fileKind = TOX_FILE_KIND_DATA;
file.status = ToxFile::TRANSMITTING;
model.onFileUpdated(file);
QCOMPARE(model.rowCount(), 1);
file.status = ToxFile::BROKEN;
model.onFileUpdated(file);
QCOMPARE(model.rowCount(), 0);
file.status = ToxFile::TRANSMITTING;
model.onFileUpdated(file);
QCOMPARE(model.rowCount(), 1);
file.status = ToxFile::CANCELED;
model.onFileUpdated(file);
QCOMPARE(model.rowCount(), 0);
file.status = ToxFile::TRANSMITTING;
model.onFileUpdated(file);
QCOMPARE(model.rowCount(), 1);
file.status = ToxFile::FINISHED;
model.onFileUpdated(file);
QCOMPARE(model.rowCount(), 1);
}
QTEST_GUILESS_MAIN(TestFileTransferList)
#include "filesform_test.moc"

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
width="12"
height="12"
viewBox="0 0 12 12"
id="Layer_1"
xml:space="preserve"
sodipodi:docname="arrow_black.svg"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="69.416667"
inkscape:cx="6"
inkscape:cy="5.9927971"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="1280"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" /><metadata
id="metadata9"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs7" /><polygon
points="3.572,6.187 0,0 7.145,0 "
transform="matrix(0,-1.3815919,1.3815919,0,1.7260455,10.935737)"
id="polygon3"
style="fill:#000000" /></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
width="12"
height="12"
viewBox="0 0 11.999999 12"
id="Layer_1"
xml:space="preserve"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20, custom)"
sodipodi:docname="no_dark.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1011"
id="namedview6"
showgrid="false"
inkscape:zoom="27.812867"
inkscape:cx="7.1369845"
inkscape:cy="6.2201426"
inkscape:window-x="1280"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1"
inkscape:pagecheckerboard="0" /><metadata
id="metadata9"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs7" /><polygon
points="1.693,0.001 0,1.693 3.35,5.043 0,8.394 1.693,10.086 5.043,6.736 8.395,10.087 10.086,8.394 6.734,5.043 10.086,1.692 8.395,0 5.043,3.351 "
transform="matrix(1.1123133,0,0,1.1054869,0.39060355,0.42447682)"
id="polygon3"
style="fill:#000000" /></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
width="12"
height="12"
viewBox="0 0 12.000001 12"
id="Layer_1"
xml:space="preserve"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20, custom)"
sodipodi:docname="pause_dark.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1011"
id="namedview8"
showgrid="false"
inkscape:zoom="39.333333"
inkscape:cx="0.69915255"
inkscape:cy="4.3220339"
inkscape:window-x="1280"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1"
inkscape:pagecheckerboard="0" /><metadata
id="metadata13"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs11" /><g
transform="matrix(0.91525423,0,0,0.91525423,2.1550175,0.69830581)"
id="g3"
style="fill:#000000"><rect
width="2.1851854"
height="10.925926"
x="-0.16937082"
y="0.32962826"
id="rect5"
style="fill:#000000" /><rect
width="2.1851854"
height="10.925926"
x="6.3861852"
y="0.32962897"
id="rect7"
style="fill:#000000" /></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
width="12"
height="12"
viewBox="0 0 12 12"
id="Layer_1"
xml:space="preserve"
sodipodi:docname="arrow_black.svg"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="69.416667"
inkscape:cx="6"
inkscape:cy="5.9927971"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="1280"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" /><metadata
id="metadata9"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs7" /><polygon
points="3.572,6.187 0,0 7.145,0 "
transform="matrix(0,-1.3815919,1.3815919,0,1.7260455,10.935737)"
id="polygon3"
style="fill:#000000" /></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
width="12"
height="12"
viewBox="0 0 11.999999 12"
id="Layer_1"
xml:space="preserve"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20, custom)"
sodipodi:docname="no_dark.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1011"
id="namedview6"
showgrid="false"
inkscape:zoom="27.812867"
inkscape:cx="7.1369845"
inkscape:cy="6.2201426"
inkscape:window-x="1280"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1"
inkscape:pagecheckerboard="0" /><metadata
id="metadata9"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs7" /><polygon
points="1.693,0.001 0,1.693 3.35,5.043 0,8.394 1.693,10.086 5.043,6.736 8.395,10.087 10.086,8.394 6.734,5.043 10.086,1.692 8.395,0 5.043,3.351 "
transform="matrix(1.1123133,0,0,1.1054869,0.39060355,0.42447682)"
id="polygon3"
style="fill:#000000" /></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
width="12"
height="12"
viewBox="0 0 12.000001 12"
id="Layer_1"
xml:space="preserve"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20, custom)"
sodipodi:docname="pause_dark.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1011"
id="namedview8"
showgrid="false"
inkscape:zoom="39.333333"
inkscape:cx="0.69915255"
inkscape:cy="4.3220339"
inkscape:window-x="1280"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1"
inkscape:pagecheckerboard="0" /><metadata
id="metadata13"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs11" /><g
transform="matrix(0.91525423,0,0,0.91525423,2.1550175,0.69830581)"
id="g3"
style="fill:#000000"><rect
width="2.1851854"
height="10.925926"
x="-0.16937082"
y="0.32962826"
id="rect5"
style="fill:#000000" /><rect
width="2.1851854"
height="10.925926"
x="6.3861852"
y="0.32962897"
id="rect7"
style="fill:#000000" /></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -14,13 +14,15 @@
# You should have received a copy of the GNU General Public License
# along with qTox. If not, see <http://www.gnu.org/licenses/>
set(HEADER_LIST
add_library(util_library STATIC
"include/util/compatiblerecursivemutex.h"
"include/util/interface.h"
"include/util/strongtype.h")
add_library(util_library STATIC ${HEADER_LIST})
"include/util/strongtype.h"
"include/util/display.h"
"src/display.cpp")
# We need this directory, and users of our library will need it too
target_include_directories(util_library PUBLIC include/)
target_link_libraries(util_library PRIVATE Qt5::Core)

View File

@ -0,0 +1,26 @@
/*
Copyright © 2021 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <QString>
#include <cstdint>
QString getHumanReadableSize(uint64_t sizeBytes);

34
util/src/display.cpp Normal file
View File

@ -0,0 +1,34 @@
/*
Copyright © 2021 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 <http://www.gnu.org/licenses/>.
*/
#include "util/display.h"
#include <cmath>
QString getHumanReadableSize(uint64_t size)
{
static const char* suffix[] = {"B", "KiB", "MiB", "GiB", "TiB"};
int exp = 0;
if (size > 0) {
exp = std::min(static_cast<int>(log(size) / log(1024)), static_cast<int>(sizeof(suffix) / sizeof(suffix[0]) - 1));
}
return QString().setNum(size / pow(1024, exp), 'f', exp > 1 ? 2 : 0).append(suffix[exp]);
}