Use the local file APIs to save/load files on WASM
QFileDialog::saveFileContent, QFileDialog::getOpenFileContent are now using local file APIs to access files on any browser that passes a feature check. The feature is thoroughly tested using sinon and a new mock library. Task-number: QTBUG-99611 Change-Id: I3dd27a9d21eb143c71ea7db0563f70ac7db3a3ac Reviewed-by: Tor Arne Vestbø <tor.arne.vestbo@qt.io> Reviewed-by: Morten Johan Sørvig <morten.sorvig@qt.io>
This commit is contained in:
parent
742ae9ea1c
commit
f8e460b915
@ -451,7 +451,8 @@ File FileList::operator[](int index) const
|
||||
return item(index);
|
||||
}
|
||||
|
||||
emscripten::val FileList::val() {
|
||||
emscripten::val FileList::val() const
|
||||
{
|
||||
return m_fileList;
|
||||
}
|
||||
|
||||
|
@ -91,7 +91,7 @@ namespace qstdweb {
|
||||
int length() const;
|
||||
File item(int index) const;
|
||||
File operator[](int index) const;
|
||||
emscripten::val val();
|
||||
emscripten::val val() const;
|
||||
|
||||
private:
|
||||
emscripten::val m_fileList = emscripten::val::undefined();
|
||||
@ -183,6 +183,12 @@ namespace qstdweb {
|
||||
|
||||
void all(std::vector<emscripten::val> promises, PromiseCallbacks callbacks);
|
||||
};
|
||||
|
||||
inline emscripten::val window()
|
||||
{
|
||||
static emscripten::val savedWindow = emscripten::val::global("window");
|
||||
return savedWindow;
|
||||
}
|
||||
}
|
||||
|
||||
QT_END_NAMESPACE
|
||||
|
@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
|
||||
|
||||
#include "qwasmlocalfileaccess_p.h"
|
||||
#include "qlocalfileapi_p.h"
|
||||
#include <private/qstdweb_p.h>
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/bind.h>
|
||||
@ -11,10 +12,98 @@
|
||||
QT_BEGIN_NAMESPACE
|
||||
|
||||
namespace QWasmLocalFileAccess {
|
||||
namespace FileDialog {
|
||||
namespace {
|
||||
bool hasLocalFilesApi()
|
||||
{
|
||||
return !qstdweb::window()["showOpenFilePicker"].isUndefined();
|
||||
}
|
||||
|
||||
void showOpenViaHTMLPolyfill(const QStringList &accept, FileSelectMode fileSelectMode,
|
||||
qstdweb::PromiseCallbacks onFilesSelected)
|
||||
{
|
||||
// Create file input html element which will display a native file dialog
|
||||
// and call back to our onchange handler once the user has selected
|
||||
// one or more files.
|
||||
emscripten::val document = emscripten::val::global("document");
|
||||
emscripten::val input = document.call<emscripten::val>("createElement", std::string("input"));
|
||||
input.set("type", "file");
|
||||
input.set("style", "display:none");
|
||||
// input.set("accept", emscripten::val(accept));
|
||||
Q_UNUSED(accept);
|
||||
input.set("multiple", emscripten::val(fileSelectMode == FileSelectMode::MultipleFiles));
|
||||
|
||||
// Note: there is no event in case the user cancels the file dialog.
|
||||
static std::unique_ptr<qstdweb::EventCallback> changeEvent;
|
||||
auto callback = [=](emscripten::val) { onFilesSelected.thenFunc(input["files"]); };
|
||||
changeEvent = std::make_unique<qstdweb::EventCallback>(input, "change", callback);
|
||||
|
||||
// Activate file input
|
||||
emscripten::val body = document["body"];
|
||||
body.call<void>("appendChild", input);
|
||||
input.call<void>("click");
|
||||
body.call<void>("removeChild", input);
|
||||
}
|
||||
|
||||
void showOpenViaLocalFileApi(const QStringList &accept, FileSelectMode fileSelectMode,
|
||||
qstdweb::PromiseCallbacks callbacks)
|
||||
{
|
||||
using namespace qstdweb;
|
||||
|
||||
auto options = LocalFileApi::makeOpenFileOptions(accept, fileSelectMode == FileSelectMode::MultipleFiles);
|
||||
|
||||
Promise::make(
|
||||
window(), QStringLiteral("showOpenFilePicker"),
|
||||
{
|
||||
.thenFunc = [=](emscripten::val fileHandles) mutable {
|
||||
std::vector<emscripten::val> filePromises;
|
||||
filePromises.reserve(fileHandles["length"].as<int>());
|
||||
for (int i = 0; i < fileHandles["length"].as<int>(); ++i)
|
||||
filePromises.push_back(fileHandles[i].call<emscripten::val>("getFile"));
|
||||
Promise::all(std::move(filePromises), callbacks);
|
||||
},
|
||||
.catchFunc = callbacks.catchFunc,
|
||||
.finallyFunc = callbacks.finallyFunc,
|
||||
}, std::move(options));
|
||||
}
|
||||
|
||||
void showSaveViaLocalFileApi(const std::string &fileNameHint, qstdweb::PromiseCallbacks callbacks)
|
||||
{
|
||||
using namespace qstdweb;
|
||||
using namespace emscripten;
|
||||
|
||||
auto options = LocalFileApi::makeSaveFileOptions(QStringList(), fileNameHint);
|
||||
|
||||
Promise::make(
|
||||
window(), QStringLiteral("showSaveFilePicker"),
|
||||
std::move(callbacks), std::move(options));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void showOpen(const QStringList &accept, FileSelectMode fileSelectMode,
|
||||
qstdweb::PromiseCallbacks callbacks)
|
||||
{
|
||||
hasLocalFilesApi() ?
|
||||
showOpenViaLocalFileApi(accept, fileSelectMode, std::move(callbacks)) :
|
||||
showOpenViaHTMLPolyfill(accept, fileSelectMode, std::move(callbacks));
|
||||
}
|
||||
|
||||
bool canShowSave()
|
||||
{
|
||||
return hasLocalFilesApi();
|
||||
}
|
||||
|
||||
void showSave(const std::string &fileNameHint, qstdweb::PromiseCallbacks callbacks)
|
||||
{
|
||||
Q_ASSERT(canShowSave());
|
||||
showSaveViaLocalFileApi(fileNameHint, std::move(callbacks));
|
||||
}
|
||||
} // namespace FileDialog
|
||||
|
||||
namespace {
|
||||
void readFiles(const qstdweb::FileList &fileList,
|
||||
const std::function<char *(uint64_t size, const std::string name)> &acceptFile,
|
||||
const std::function<void ()> &fileDataReady)
|
||||
const std::function<char *(uint64_t size, const std::string name)> &acceptFile,
|
||||
const std::function<void ()> &fileDataReady)
|
||||
{
|
||||
auto readFile = std::make_shared<std::function<void(int)>>();
|
||||
|
||||
@ -25,7 +114,7 @@ void readFiles(const qstdweb::FileList &fileList,
|
||||
return;
|
||||
}
|
||||
|
||||
const qstdweb::File file = fileList[fileIndex];
|
||||
const qstdweb::File file = qstdweb::File(fileList[fileIndex]);
|
||||
|
||||
// Ask caller if the file should be accepted
|
||||
char *buffer = acceptFile(file.size(), file.name());
|
||||
@ -43,62 +132,17 @@ void readFiles(const qstdweb::FileList &fileList,
|
||||
|
||||
(*readFile)(0);
|
||||
}
|
||||
|
||||
typedef std::function<void (const qstdweb::FileList &fileList)> OpenFileDialogCallback;
|
||||
void openFileDialog(const std::string &accept, FileSelectMode fileSelectMode,
|
||||
const OpenFileDialogCallback &filesSelected)
|
||||
{
|
||||
// Create file input html element which will display a native file dialog
|
||||
// and call back to our onchange handler once the user has selected
|
||||
// one or more files.
|
||||
emscripten::val document = emscripten::val::global("document");
|
||||
emscripten::val input = document.call<emscripten::val>("createElement", std::string("input"));
|
||||
input.set("type", "file");
|
||||
input.set("style", "display:none");
|
||||
input.set("accept", emscripten::val(accept));
|
||||
input.set("multiple", emscripten::val(fileSelectMode == MultipleFiles));
|
||||
|
||||
// Note: there is no event in case the user cancels the file dialog.
|
||||
static std::unique_ptr<qstdweb::EventCallback> changeEvent;
|
||||
auto callback = [=](emscripten::val) { filesSelected(qstdweb::FileList(input["files"])); };
|
||||
changeEvent.reset(new qstdweb::EventCallback(input, "change", callback));
|
||||
|
||||
// Activate file input
|
||||
emscripten::val body = document["body"];
|
||||
body.call<void>("appendChild", input);
|
||||
input.call<void>("click");
|
||||
body.call<void>("removeChild", input);
|
||||
}
|
||||
|
||||
void openFiles(const std::string &accept, FileSelectMode fileSelectMode,
|
||||
const std::function<void (int fileCount)> &fileDialogClosed,
|
||||
const std::function<char *(uint64_t size, const std::string name)> &acceptFile,
|
||||
const std::function<void()> &fileDataReady)
|
||||
{
|
||||
openFileDialog(accept, fileSelectMode, [=](const qstdweb::FileList &files) {
|
||||
fileDialogClosed(files.length());
|
||||
readFiles(files, acceptFile, fileDataReady);
|
||||
});
|
||||
}
|
||||
|
||||
void openFile(const std::string &accept,
|
||||
const std::function<void (bool fileSelected)> &fileDialogClosed,
|
||||
const std::function<char *(uint64_t size, const std::string name)> &acceptFile,
|
||||
const std::function<void()> &fileDataReady)
|
||||
{
|
||||
auto fileDialogClosedWithInt = [=](int fileCount) { fileDialogClosed(fileCount != 0); };
|
||||
openFiles(accept, FileSelectMode::SingleFile, fileDialogClosedWithInt, acceptFile, fileDataReady);
|
||||
}
|
||||
|
||||
void saveFile(const char *content, size_t size, const std::string &fileNameHint)
|
||||
void downloadDataAsFile(const QByteArray &data, const std::string &fileNameHint)
|
||||
{
|
||||
// Save a file by creating programmatically clicking a download
|
||||
// link to an object url to a Blob containing a copy of the file
|
||||
// content. The copy is made so that the passed in content buffer
|
||||
// can be released as soon as this function returns.
|
||||
qstdweb::Blob contentBlob = qstdweb::Blob::copyFrom(content, size);
|
||||
qstdweb::Blob contentBlob = qstdweb::Blob::copyFrom(data.constData(), data.size());
|
||||
emscripten::val document = emscripten::val::global("document");
|
||||
emscripten::val window = emscripten::val::global("window");
|
||||
emscripten::val window = qstdweb::window();
|
||||
emscripten::val contentUrl = window["URL"].call<emscripten::val>("createObjectURL", contentBlob.val());
|
||||
emscripten::val contentLink = document.call<emscripten::val>("createElement", std::string("a"));
|
||||
contentLink.set("href", contentUrl);
|
||||
@ -113,6 +157,80 @@ void saveFile(const char *content, size_t size, const std::string &fileNameHint)
|
||||
window["URL"].call<emscripten::val>("revokeObjectURL", contentUrl);
|
||||
}
|
||||
|
||||
void openFiles(const QStringList &accept, FileSelectMode fileSelectMode,
|
||||
const std::function<void (int fileCount)> &fileDialogClosed,
|
||||
const std::function<char *(uint64_t size, const std::string& name)> &acceptFile,
|
||||
const std::function<void()> &fileDataReady)
|
||||
{
|
||||
FileDialog::showOpen(accept, fileSelectMode, {
|
||||
.thenFunc = [=](emscripten::val result) {
|
||||
auto files = qstdweb::FileList(result);
|
||||
fileDialogClosed(files.length());
|
||||
readFiles(files, acceptFile, fileDataReady);
|
||||
},
|
||||
.catchFunc = [=](emscripten::val) {
|
||||
fileDialogClosed(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void openFile(const QStringList &accept,
|
||||
const std::function<void (bool fileSelected)> &fileDialogClosed,
|
||||
const std::function<char *(uint64_t size, const std::string& name)> &acceptFile,
|
||||
const std::function<void()> &fileDataReady)
|
||||
{
|
||||
auto fileDialogClosedWithInt = [=](int fileCount) { fileDialogClosed(fileCount != 0); };
|
||||
openFiles(accept, FileSelectMode::SingleFile, fileDialogClosedWithInt, acceptFile, fileDataReady);
|
||||
}
|
||||
|
||||
void saveDataToFileInChunks(emscripten::val fileHandle, const QByteArray &data)
|
||||
{
|
||||
using namespace emscripten;
|
||||
using namespace qstdweb;
|
||||
|
||||
Promise::make(fileHandle, QStringLiteral("createWritable"), {
|
||||
.thenFunc = [=](val writable) {
|
||||
struct State {
|
||||
size_t written;
|
||||
std::function<void(val result)> continuation;
|
||||
};
|
||||
|
||||
auto state = std::make_shared<State>();
|
||||
state->written = 0u;
|
||||
state->continuation = [=](val) mutable {
|
||||
const size_t remaining = data.size() - state->written;
|
||||
if (remaining == 0) {
|
||||
Promise::make(writable, QStringLiteral("close"), { .thenFunc = [=](val) {} });
|
||||
state.reset();
|
||||
return;
|
||||
}
|
||||
static constexpr size_t desiredChunkSize = 1024u;
|
||||
const auto currentChunkSize = std::min(remaining, desiredChunkSize);
|
||||
Promise::make(writable, QStringLiteral("write"), {
|
||||
.thenFunc = state->continuation,
|
||||
}, val(typed_memory_view(currentChunkSize, data.constData() + state->written)));
|
||||
state->written += currentChunkSize;
|
||||
};
|
||||
|
||||
state->continuation(val::undefined());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
void saveFile(const QByteArray &data, const std::string &fileNameHint)
|
||||
{
|
||||
if (!FileDialog::canShowSave()) {
|
||||
downloadDataAsFile(data, fileNameHint);
|
||||
return;
|
||||
}
|
||||
|
||||
FileDialog::showSave(fileNameHint, {
|
||||
.thenFunc = [=](emscripten::val result) {
|
||||
saveDataToFileInChunks(result, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace QWasmLocalFileAccess
|
||||
|
||||
QT_END_NAMESPACE
|
||||
|
@ -23,19 +23,19 @@ QT_BEGIN_NAMESPACE
|
||||
|
||||
namespace QWasmLocalFileAccess {
|
||||
|
||||
enum FileSelectMode { SingleFile, MultipleFiles };
|
||||
enum class FileSelectMode { SingleFile, MultipleFiles };
|
||||
|
||||
Q_CORE_EXPORT void openFiles(const std::string &accept, FileSelectMode fileSelectMode,
|
||||
Q_CORE_EXPORT void openFiles(const QStringList &accept, FileSelectMode fileSelectMode,
|
||||
const std::function<void (int fileCount)> &fileDialogClosed,
|
||||
const std::function<char *(uint64_t size, const std::string name)> &acceptFile,
|
||||
const std::function<char *(uint64_t size, const std::string& name)> &acceptFile,
|
||||
const std::function<void()> &fileDataReady);
|
||||
|
||||
Q_CORE_EXPORT void openFile(const std::string &accept,
|
||||
Q_CORE_EXPORT void openFile(const QStringList &accept,
|
||||
const std::function<void (bool fileSelected)> &fileDialogClosed,
|
||||
const std::function<char *(uint64_t size, const std::string name)> &acceptFile,
|
||||
const std::function<char *(uint64_t size, const std::string& name)> &acceptFile,
|
||||
const std::function<void()> &fileDataReady);
|
||||
|
||||
Q_CORE_EXPORT void saveFile(const char *content, size_t size, const std::string &fileNameHint);
|
||||
Q_CORE_EXPORT void saveFile(const QByteArray &data, const std::string &fileNameHint);
|
||||
|
||||
} // namespace QWasmLocalFileAccess
|
||||
|
||||
|
@ -2304,13 +2304,7 @@ void QFileDialog::getOpenFileContent(const QString &nameFilter, const std::funct
|
||||
openFileImpl.reset();
|
||||
};
|
||||
|
||||
auto qtFilterStringToWebAcceptString = [](const QString &qtString) {
|
||||
// The Qt and Web name filter string formats are similar, but
|
||||
// not identical.
|
||||
return qtString.toStdString(); // ### TODO
|
||||
};
|
||||
|
||||
QWasmLocalFileAccess::openFile(qtFilterStringToWebAcceptString(nameFilter), fileDialogClosed, acceptFile, fileContentReady);
|
||||
QWasmLocalFileAccess::openFile(qt_make_filter_list(nameFilter), fileDialogClosed, acceptFile, fileContentReady);
|
||||
};
|
||||
|
||||
(*openFileImpl)();
|
||||
@ -2359,7 +2353,7 @@ void QFileDialog::getOpenFileContent(const QString &nameFilter, const std::funct
|
||||
void QFileDialog::saveFileContent(const QByteArray &fileContent, const QString &fileNameHint)
|
||||
{
|
||||
#ifdef Q_OS_WASM
|
||||
QWasmLocalFileAccess::saveFile(fileContent.constData(), fileContent.size(), fileNameHint.toStdString());
|
||||
QWasmLocalFileAccess::saveFile(fileContent, fileNameHint.toStdString());
|
||||
#else
|
||||
QFileDialog *dialog = new QFileDialog();
|
||||
dialog->setAcceptMode(QFileDialog::AcceptSave);
|
||||
|
@ -2,52 +2,136 @@
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
#include <QtWidgets/QtWidgets>
|
||||
|
||||
#include <emscripten/val.h>
|
||||
#include <emscripten.h>
|
||||
|
||||
class AppWindow : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
AppWindow() : m_layout(new QVBoxLayout(&m_loadFileUi)),
|
||||
m_window(emscripten::val::global("window")),
|
||||
m_showOpenFilePickerFunction(m_window["showOpenFilePicker"]),
|
||||
m_showSaveFilePickerFunction(m_window["showSaveFilePicker"])
|
||||
{
|
||||
addWidget<QLabel>("Filename filter");
|
||||
|
||||
const bool localFileApiAvailable =
|
||||
!m_showOpenFilePickerFunction.isUndefined() && !m_showSaveFilePickerFunction.isUndefined();
|
||||
|
||||
m_useLocalFileApisCheckbox = addWidget<QCheckBox>("Use the window.showXFilePicker APIs");
|
||||
m_useLocalFileApisCheckbox->setEnabled(localFileApiAvailable);
|
||||
m_useLocalFileApisCheckbox->setChecked(localFileApiAvailable);
|
||||
|
||||
m_filterEdit = addWidget<QLineEdit>("Images (*.png *.jpg);;PDF (*.pdf);;*.txt");
|
||||
|
||||
auto* loadFile = addWidget<QPushButton>("Load File");
|
||||
|
||||
m_fileInfo = addWidget<QLabel>("Opened file:");
|
||||
m_fileInfo->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
|
||||
m_fileHash = addWidget<QLabel>("Sha256:");
|
||||
m_fileHash->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
|
||||
addWidget<QLabel>("Saved file name");
|
||||
m_savedFileNameEdit = addWidget<QLineEdit>("qttestresult");
|
||||
|
||||
m_saveFile = addWidget<QPushButton>("Save File");
|
||||
m_saveFile->setEnabled(false);
|
||||
|
||||
m_layout->addStretch();
|
||||
|
||||
m_loadFileUi.setLayout(m_layout);
|
||||
|
||||
QObject::connect(m_useLocalFileApisCheckbox, &QCheckBox::toggled, std::bind(&AppWindow::onUseLocalFileApisCheckboxToggled, this));
|
||||
|
||||
QObject::connect(loadFile, &QPushButton::clicked, this, &AppWindow::onLoadClicked);
|
||||
|
||||
QObject::connect(m_saveFile, &QPushButton::clicked, std::bind(&AppWindow::onSaveClicked, this));
|
||||
}
|
||||
|
||||
void show() {
|
||||
m_loadFileUi.show();
|
||||
}
|
||||
|
||||
~AppWindow() = default;
|
||||
|
||||
private Q_SLOTS:
|
||||
void onUseLocalFileApisCheckboxToggled()
|
||||
{
|
||||
m_window.set("showOpenFilePicker",
|
||||
m_useLocalFileApisCheckbox->isChecked() ?
|
||||
m_showOpenFilePickerFunction : emscripten::val::undefined());
|
||||
m_window.set("showSaveFilePicker",
|
||||
m_useLocalFileApisCheckbox->isChecked() ?
|
||||
m_showSaveFilePickerFunction : emscripten::val::undefined());
|
||||
}
|
||||
|
||||
void onFileContentReady(const QString &fileName, const QByteArray &fileContents)
|
||||
{
|
||||
m_fileContent = fileContents;
|
||||
m_fileInfo->setText(QString("Opened file: %1 size: %2").arg(fileName).arg(fileContents.size()));
|
||||
m_saveFile->setEnabled(true);
|
||||
|
||||
QTimer::singleShot(100, this, &AppWindow::computeAndDisplayFileHash); // update UI before computing hash
|
||||
}
|
||||
|
||||
void computeAndDisplayFileHash()
|
||||
{
|
||||
QByteArray hash = QCryptographicHash::hash(m_fileContent, QCryptographicHash::Sha256);
|
||||
m_fileHash->setText(QString("Sha256: %1").arg(QString(hash.toHex())));
|
||||
}
|
||||
|
||||
void onFileSaved(bool success)
|
||||
{
|
||||
m_fileInfo->setText(QString("File save result: %1").arg(success ? "success" : "failed"));
|
||||
}
|
||||
|
||||
void onLoadClicked()
|
||||
{
|
||||
QFileDialog::getOpenFileContent(
|
||||
m_filterEdit->text(),
|
||||
std::bind(&AppWindow::onFileContentReady, this, std::placeholders::_1, std::placeholders::_2));
|
||||
}
|
||||
|
||||
void onSaveClicked()
|
||||
{
|
||||
m_fileInfo->setText("Saving file... (no result information with current API)");
|
||||
QFileDialog::saveFileContent(m_fileContent, m_savedFileNameEdit->text());
|
||||
}
|
||||
|
||||
private:
|
||||
template <class T, class... Args>
|
||||
T* addWidget(Args... args)
|
||||
{
|
||||
T* widget = new T(std::forward<Args>(args)..., &m_loadFileUi);
|
||||
m_layout->addWidget(widget);
|
||||
return widget;
|
||||
}
|
||||
|
||||
QWidget m_loadFileUi;
|
||||
|
||||
QCheckBox* m_useLocalFileApisCheckbox;
|
||||
QLineEdit* m_filterEdit;
|
||||
QVBoxLayout *m_layout;
|
||||
QLabel* m_fileInfo;
|
||||
QLabel* m_fileHash;
|
||||
QLineEdit* m_savedFileNameEdit;
|
||||
QPushButton* m_saveFile;
|
||||
|
||||
emscripten::val m_window;
|
||||
emscripten::val m_showOpenFilePickerFunction;
|
||||
emscripten::val m_showSaveFilePickerFunction;
|
||||
|
||||
QByteArray m_fileContent;
|
||||
};
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
|
||||
QByteArray content;
|
||||
|
||||
QWidget loadFileUi;
|
||||
QVBoxLayout *layout = new QVBoxLayout();
|
||||
QPushButton *loadFile = new QPushButton("Load File");
|
||||
QLabel *fileInfo = new QLabel("Opened file:");
|
||||
fileInfo->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
QLabel *fileHash = new QLabel("Sha256:");
|
||||
fileHash->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
QPushButton *saveFile = new QPushButton("Save File");
|
||||
saveFile->setEnabled(false);
|
||||
|
||||
auto onFileReady = [=, &content](const QString &fileName, const QByteArray &fileContents) {
|
||||
content = fileContents;
|
||||
fileInfo->setText(QString("Opened file: %1 size: %2").arg(fileName).arg(fileContents.size()));
|
||||
saveFile->setEnabled(true);
|
||||
|
||||
auto computeDisplayFileHash = [=](){
|
||||
QByteArray hash = QCryptographicHash::hash(fileContents, QCryptographicHash::Sha256);
|
||||
fileHash->setText(QString("Sha256: %1").arg(QString(hash.toHex())));
|
||||
};
|
||||
|
||||
QTimer::singleShot(100, computeDisplayFileHash); // update UI before computing hash
|
||||
};
|
||||
auto onLoadClicked = [=](){
|
||||
QFileDialog::getOpenFileContent("*.*", onFileReady);
|
||||
};
|
||||
QObject::connect(loadFile, &QPushButton::clicked, onLoadClicked);
|
||||
|
||||
auto onSaveClicked = [=, &content]() {
|
||||
QFileDialog::saveFileContent(content, "qtsavefiletest.dat");
|
||||
};
|
||||
QObject::connect(saveFile, &QPushButton::clicked, onSaveClicked);
|
||||
|
||||
layout->addWidget(loadFile);
|
||||
layout->addWidget(fileInfo);
|
||||
layout->addWidget(fileHash);
|
||||
layout->addWidget(saveFile);
|
||||
layout->addStretch();
|
||||
|
||||
loadFileUi.setLayout(layout);
|
||||
loadFileUi.show();
|
||||
|
||||
return app.exec();
|
||||
QApplication application(argc, argv);
|
||||
AppWindow window;
|
||||
window.show();
|
||||
return application.exec();
|
||||
}
|
||||
|
||||
#include "main.moc"
|
||||
|
@ -20,3 +20,27 @@ add_custom_command(
|
||||
COMMAND ${CMAKE_COMMAND} -E copy
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../qtwasmtestlib/qtwasmtestlib.js
|
||||
${CMAKE_CURRENT_BINARY_DIR}/qtwasmtestlib.js)
|
||||
|
||||
qt_internal_add_manual_test(files_auto
|
||||
SOURCES
|
||||
files_main.cpp
|
||||
../qtwasmtestlib/qtwasmtestlib.cpp
|
||||
PUBLIC_LIBRARIES
|
||||
Qt::Core
|
||||
Qt::CorePrivate
|
||||
Qt::GuiPrivate
|
||||
)
|
||||
|
||||
include_directories(../qtwasmtestlib/)
|
||||
|
||||
add_custom_command(
|
||||
TARGET files_auto POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/files_auto.html
|
||||
${CMAKE_CURRENT_BINARY_DIR}/files_auto.html)
|
||||
|
||||
add_custom_command(
|
||||
TARGET files_auto POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../qtwasmtestlib/qtwasmtestlib.js
|
||||
${CMAKE_CURRENT_BINARY_DIR}/qtwasmtestlib.js)
|
||||
|
13
tests/manual/wasm/qstdweb/files_auto.html
Normal file
13
tests/manual/wasm/qstdweb/files_auto.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<script type="text/javascript" src="https://sinonjs.org/releases/sinon-14.0.0.js"
|
||||
integrity="sha384-z8J4N1s2hPDn6ClmFXDQkKD/e738VOWcR8JmhztPRa+PgezxQupgZu3LzoBO4Jw8"
|
||||
crossorigin="anonymous"></script>
|
||||
<script type="text/javascript" src="qtwasmtestlib.js"></script>
|
||||
<script type="text/javascript" src="files_auto.js"></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
runTestCase(document.getElementById("log"));
|
||||
};
|
||||
</script>
|
||||
<p>Running files auto test.</p>
|
||||
<div id="log"></div>
|
471
tests/manual/wasm/qstdweb/files_main.cpp
Normal file
471
tests/manual/wasm/qstdweb/files_main.cpp
Normal file
@ -0,0 +1,471 @@
|
||||
// Copyright (C) 2022 The Qt Company Ltd.
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
|
||||
|
||||
#include <QtCore/QCoreApplication>
|
||||
#include <QtCore/QEvent>
|
||||
#include <QtCore/QMutex>
|
||||
#include <QtCore/QObject>
|
||||
#include <QtCore/QTimer>
|
||||
#include <QtGui/private/qwasmlocalfileaccess_p.h>
|
||||
|
||||
#include <qtwasmtestlib.h>
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/bind.h>
|
||||
#include <emscripten/val.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
using namespace emscripten;
|
||||
|
||||
class FilesTest : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
FilesTest() : m_window(val::global("window")), m_testSupport(val::object()) {}
|
||||
|
||||
~FilesTest() noexcept {
|
||||
for (auto& cleanup: m_cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
void init() {
|
||||
EM_ASM({
|
||||
window.testSupport = {};
|
||||
|
||||
window.showOpenFilePicker = sinon.stub();
|
||||
|
||||
window.mockOpenFileDialog = (files) => {
|
||||
window.showOpenFilePicker.withArgs(sinon.match.any).callsFake(
|
||||
(options) => Promise.resolve(files.map(file => {
|
||||
const getFile = sinon.stub();
|
||||
getFile.callsFake(() => Promise.resolve({
|
||||
name: file.name,
|
||||
size: file.content.length,
|
||||
slice: () => new Blob([new TextEncoder().encode(file.content)]),
|
||||
}));
|
||||
return {
|
||||
kind: 'file',
|
||||
name: file.name,
|
||||
getFile
|
||||
};
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
window.showSaveFilePicker = sinon.stub();
|
||||
|
||||
window.mockSaveFilePicker = (file) => {
|
||||
window.showSaveFilePicker.withArgs(sinon.match.any).callsFake(
|
||||
(options) => {
|
||||
const createWritable = sinon.stub();
|
||||
createWritable.callsFake(() => {
|
||||
const write = file.writeFn ?? (() => {
|
||||
const write = sinon.stub();
|
||||
write.callsFake((stuff) => {
|
||||
if (file.content !== new TextDecoder().decode(stuff)) {
|
||||
const message = `Bad file content ${file.content} !== ${new TextDecoder().decode(stuff)}`;
|
||||
Module.qtWasmFail(message);
|
||||
return Promise.reject(message);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
return write;
|
||||
})();
|
||||
|
||||
window.testSupport.write = write;
|
||||
|
||||
const close = file.closeFn ?? (() => {
|
||||
const close = sinon.stub();
|
||||
close.callsFake(() => Promise.resolve());
|
||||
return close;
|
||||
})();
|
||||
|
||||
window.testSupport.close = close;
|
||||
|
||||
return Promise.resolve({
|
||||
write,
|
||||
close
|
||||
});
|
||||
});
|
||||
return Promise.resolve({
|
||||
kind: 'file',
|
||||
name: file.name,
|
||||
createWritable
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
template <class T>
|
||||
T* Own(T* plainPtr) {
|
||||
m_cleanup.emplace_back([plainPtr]() mutable {
|
||||
delete plainPtr;
|
||||
});
|
||||
return plainPtr;
|
||||
}
|
||||
|
||||
val m_window;
|
||||
val m_testSupport;
|
||||
|
||||
std::vector<std::function<void()>> m_cleanup;
|
||||
|
||||
private slots:
|
||||
void selectOneFileWithFileDialog();
|
||||
void selectMultipleFilesWithFileDialog();
|
||||
void cancelFileDialog();
|
||||
void rejectFile();
|
||||
void saveFileWithFileDialog();
|
||||
};
|
||||
|
||||
class BarrierCallback {
|
||||
public:
|
||||
BarrierCallback(int number, std::function<void()> onDone)
|
||||
: m_remaining(number), m_onDone(std::move(onDone)) {}
|
||||
|
||||
void operator()() {
|
||||
if (!--m_remaining) {
|
||||
m_onDone();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
int m_remaining;
|
||||
std::function<void()> m_onDone;
|
||||
};
|
||||
|
||||
|
||||
template <class Arg>
|
||||
std::string argToString(std::add_lvalue_reference_t<std::add_const_t<Arg>> arg) {
|
||||
return std::to_string(arg);
|
||||
}
|
||||
|
||||
template <>
|
||||
std::string argToString<bool>(const bool& value) {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
template <>
|
||||
std::string argToString<std::string>(const std::string& arg) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
template <>
|
||||
std::string argToString<const std::string&>(const std::string& arg) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
template<class Type>
|
||||
struct Matcher {
|
||||
virtual ~Matcher() = default;
|
||||
|
||||
virtual bool matches(std::string* explanation, const Type& actual) const = 0;
|
||||
};
|
||||
|
||||
template<class Type>
|
||||
struct AnyMatcher : public Matcher<Type> {
|
||||
bool matches(std::string* explanation, const Type& actual) const final {
|
||||
Q_UNUSED(explanation);
|
||||
Q_UNUSED(actual);
|
||||
return true;
|
||||
}
|
||||
|
||||
Type m_value;
|
||||
};
|
||||
|
||||
template<class Type>
|
||||
struct EqualsMatcher : public Matcher<Type> {
|
||||
EqualsMatcher(Type value) : m_value(std::forward<Type>(value)) {}
|
||||
|
||||
bool matches(std::string* explanation, const Type& actual) const final {
|
||||
const bool ret = actual == m_value;
|
||||
if (!ret)
|
||||
*explanation += argToString<Type>(actual) + " != " + argToString<Type>(m_value);
|
||||
return actual == m_value;
|
||||
}
|
||||
|
||||
// It is crucial to hold a copy, otherwise we lose const refs.
|
||||
std::remove_reference_t<Type> m_value;
|
||||
};
|
||||
|
||||
template<class Type>
|
||||
std::unique_ptr<EqualsMatcher<Type>> equals(Type value) {
|
||||
return std::make_unique<EqualsMatcher<Type>>(value);
|
||||
}
|
||||
|
||||
template<class Type>
|
||||
std::unique_ptr<AnyMatcher<Type>> any(Type value) {
|
||||
return std::make_unique<AnyMatcher<Type>>(value);
|
||||
}
|
||||
|
||||
template <class ...Types>
|
||||
struct Expectation {
|
||||
std::tuple<std::unique_ptr<Matcher<Types>>...> m_argMatchers;
|
||||
int m_callCount = 0;
|
||||
int m_expectedCalls = 1;
|
||||
|
||||
template<std::size_t... Indices>
|
||||
bool match(std::string* explanation, const std::tuple<Types...>& tuple, std::index_sequence<Indices...>) const {
|
||||
return ( ... && (std::get<Indices>(m_argMatchers)->matches(explanation, std::get<Indices>(tuple))));
|
||||
}
|
||||
|
||||
bool matches(std::string* explanation, Types... args) const {
|
||||
if (m_callCount >= m_expectedCalls) {
|
||||
*explanation += "Too many calls\n";
|
||||
return false;
|
||||
}
|
||||
return match(explanation, std::make_tuple(args...), std::make_index_sequence<std::tuple_size_v<std::tuple<Types...>>>());
|
||||
}
|
||||
};
|
||||
|
||||
template <class R, class ...Types>
|
||||
struct Behavior {
|
||||
std::function<R(Types...)> m_callback;
|
||||
|
||||
void call(std::function<R(Types...)> callback) {
|
||||
m_callback = std::move(callback);
|
||||
}
|
||||
};
|
||||
|
||||
template<class... Args>
|
||||
std::string argsToString(Args... args) {
|
||||
return (... + (", " + argToString<Args>(args)));
|
||||
}
|
||||
|
||||
template<>
|
||||
std::string argsToString<>() {
|
||||
return "";
|
||||
}
|
||||
|
||||
template<class R, class ...Types>
|
||||
struct ExpectationToBehaviorMapping {
|
||||
Expectation<Types...> expectation;
|
||||
Behavior<R, Types...> behavior;
|
||||
};
|
||||
|
||||
template<class R, class... Args>
|
||||
class MockCallback {
|
||||
public:
|
||||
std::function<R(Args...)> get() {
|
||||
return [this](Args... result) -> R {
|
||||
return processCall(std::forward<Args>(result)...);
|
||||
};
|
||||
}
|
||||
|
||||
Behavior<R, Args...>& expectCallWith(std::unique_ptr<Matcher<Args>>... matcherArgs) {
|
||||
auto matchers = std::make_tuple(std::move(matcherArgs)...);
|
||||
m_behaviorByExpectation.push_back({Expectation<Args...> {std::move(matchers)}, Behavior<R, Args...> {}});
|
||||
return m_behaviorByExpectation.back().behavior;
|
||||
}
|
||||
|
||||
Behavior<R, Args...>& expectRepeatedCallWith(int times, std::unique_ptr<Matcher<Args>>... matcherArgs) {
|
||||
auto matchers = std::make_tuple(std::move(matcherArgs)...);
|
||||
m_behaviorByExpectation.push_back({Expectation<Args...> {std::move(matchers), 0, times}, Behavior<R, Args...> {}});
|
||||
return m_behaviorByExpectation.back().behavior;
|
||||
}
|
||||
|
||||
private:
|
||||
R processCall(Args... args) {
|
||||
std::string argsAsString = argsToString(args...);
|
||||
std::string triedExpectations;
|
||||
auto it = std::find_if(m_behaviorByExpectation.begin(), m_behaviorByExpectation.end(),
|
||||
[&](const ExpectationToBehaviorMapping<R, Args...>& behavior) {
|
||||
return behavior.expectation.matches(&triedExpectations, std::forward<Args>(args)...);
|
||||
});
|
||||
if (it != m_behaviorByExpectation.end()) {
|
||||
++it->expectation.m_callCount;
|
||||
return it->behavior.m_callback(args...);
|
||||
} else {
|
||||
QWASMFAIL("Unexpected call with " + argsAsString + ". Tried: " + triedExpectations);
|
||||
}
|
||||
return R();
|
||||
}
|
||||
|
||||
std::vector<ExpectationToBehaviorMapping<R, Args...>> m_behaviorByExpectation;
|
||||
};
|
||||
|
||||
void FilesTest::selectOneFileWithFileDialog()
|
||||
{
|
||||
init();
|
||||
|
||||
static constexpr std::string_view testFileContent = "This is a happy case.";
|
||||
|
||||
EM_ASM({
|
||||
mockOpenFileDialog([{
|
||||
name: 'file1.jpg',
|
||||
content: UTF8ToString($0)
|
||||
}]);
|
||||
}, testFileContent.data());
|
||||
|
||||
auto* fileSelectedCallback = Own(new MockCallback<void, bool>());
|
||||
fileSelectedCallback->expectCallWith(equals(true)).call([](bool) mutable {});
|
||||
|
||||
auto* fileBuffer = Own(new QByteArray());
|
||||
|
||||
auto* acceptFileCallback = Own(new MockCallback<char*, uint64_t, const std::string&>());
|
||||
acceptFileCallback->expectCallWith(equals<uint64_t>(testFileContent.size()), equals<const std::string&>("file1.jpg"))
|
||||
.call([fileBuffer](uint64_t, std::string) mutable -> char* {
|
||||
fileBuffer->resize(testFileContent.size());
|
||||
return fileBuffer->data();
|
||||
});
|
||||
|
||||
auto* fileDataReadyCallback = Own(new MockCallback<void>());
|
||||
fileDataReadyCallback->expectCallWith().call([fileBuffer]() mutable {
|
||||
QWASMCOMPARE(fileBuffer->data(), std::string(testFileContent));
|
||||
QWASMSUCCESS();
|
||||
});
|
||||
|
||||
QWasmLocalFileAccess::openFile(
|
||||
{QStringLiteral("*")}, fileSelectedCallback->get(), acceptFileCallback->get(), fileDataReadyCallback->get());
|
||||
}
|
||||
|
||||
void FilesTest::selectMultipleFilesWithFileDialog()
|
||||
{
|
||||
static constexpr std::array<std::string_view, 3> testFileContent =
|
||||
{ "Cont 1", "2s content", "What is hiding in 3?"};
|
||||
|
||||
init();
|
||||
|
||||
EM_ASM({
|
||||
mockOpenFileDialog([{
|
||||
name: 'file1.jpg',
|
||||
content: UTF8ToString($0)
|
||||
}, {
|
||||
name: 'file2.jpg',
|
||||
content: UTF8ToString($1)
|
||||
}, {
|
||||
name: 'file3.jpg',
|
||||
content: UTF8ToString($2)
|
||||
}]);
|
||||
}, testFileContent[0].data(), testFileContent[1].data(), testFileContent[2].data());
|
||||
|
||||
auto* fileSelectedCallback = Own(new MockCallback<void, int>());
|
||||
fileSelectedCallback->expectCallWith(equals(3)).call([](int) mutable {});
|
||||
|
||||
auto fileBuffer = std::make_shared<QByteArray>();
|
||||
|
||||
auto* acceptFileCallback = Own(new MockCallback<char*, uint64_t, const std::string&>());
|
||||
acceptFileCallback->expectCallWith(equals<uint64_t>(testFileContent[0].size()), equals<const std::string&>("file1.jpg"))
|
||||
.call([fileBuffer](uint64_t, std::string) mutable -> char* {
|
||||
fileBuffer->resize(testFileContent[0].size());
|
||||
return fileBuffer->data();
|
||||
});
|
||||
acceptFileCallback->expectCallWith(equals<uint64_t>(testFileContent[1].size()), equals<const std::string&>("file2.jpg"))
|
||||
.call([fileBuffer](uint64_t, std::string) mutable -> char* {
|
||||
fileBuffer->resize(testFileContent[1].size());
|
||||
return fileBuffer->data();
|
||||
});
|
||||
acceptFileCallback->expectCallWith(equals<uint64_t>(testFileContent[2].size()), equals<const std::string&>("file3.jpg"))
|
||||
.call([fileBuffer](uint64_t, std::string) mutable -> char* {
|
||||
fileBuffer->resize(testFileContent[2].size());
|
||||
return fileBuffer->data();
|
||||
});
|
||||
|
||||
auto* fileDataReadyCallback = Own(new MockCallback<void>());
|
||||
fileDataReadyCallback->expectRepeatedCallWith(3).call([fileBuffer]() mutable {
|
||||
static int callCount = 0;
|
||||
QWASMCOMPARE(fileBuffer->data(), std::string(testFileContent[callCount]));
|
||||
|
||||
callCount++;
|
||||
if (callCount == 3) {
|
||||
QWASMSUCCESS();
|
||||
}
|
||||
});
|
||||
|
||||
QWasmLocalFileAccess::openFiles(
|
||||
{QStringLiteral("*")}, QWasmLocalFileAccess::FileSelectMode::MultipleFiles,
|
||||
fileSelectedCallback->get(), acceptFileCallback->get(), fileDataReadyCallback->get());
|
||||
}
|
||||
|
||||
void FilesTest::cancelFileDialog()
|
||||
{
|
||||
init();
|
||||
|
||||
EM_ASM({
|
||||
window.showOpenFilePicker.withArgs(sinon.match.any).returns(Promise.reject("The user cancelled the dialog"));
|
||||
});
|
||||
|
||||
auto* fileSelectedCallback = Own(new MockCallback<void, bool>());
|
||||
fileSelectedCallback->expectCallWith(equals(false)).call([](bool) mutable {
|
||||
QWASMSUCCESS();
|
||||
});
|
||||
|
||||
auto* acceptFileCallback = Own(new MockCallback<char*, uint64_t, const std::string&>());
|
||||
auto* fileDataReadyCallback = Own(new MockCallback<void>());
|
||||
|
||||
QWasmLocalFileAccess::openFile(
|
||||
{QStringLiteral("*")}, fileSelectedCallback->get(), acceptFileCallback->get(), fileDataReadyCallback->get());
|
||||
}
|
||||
|
||||
void FilesTest::rejectFile()
|
||||
{
|
||||
init();
|
||||
|
||||
static constexpr std::string_view testFileContent = "We don't want this file.";
|
||||
|
||||
EM_ASM({
|
||||
mockOpenFileDialog([{
|
||||
name: 'dontwant.dat',
|
||||
content: UTF8ToString($0)
|
||||
}]);
|
||||
}, testFileContent.data());
|
||||
|
||||
auto* fileSelectedCallback = Own(new MockCallback<void, bool>());
|
||||
fileSelectedCallback->expectCallWith(equals(true)).call([](bool) mutable {});
|
||||
|
||||
auto* fileDataReadyCallback = Own(new MockCallback<void>());
|
||||
|
||||
auto* acceptFileCallback = Own(new MockCallback<char*, uint64_t, const std::string&>());
|
||||
acceptFileCallback->expectCallWith(equals<uint64_t>(std::string_view(testFileContent).size()), equals<const std::string&>("dontwant.dat"))
|
||||
.call([](uint64_t, const std::string) {
|
||||
QTimer::singleShot(0, []() {
|
||||
// No calls to fileDataReadyCallback
|
||||
QWASMSUCCESS();
|
||||
});
|
||||
return nullptr;
|
||||
});
|
||||
|
||||
QWasmLocalFileAccess::openFile(
|
||||
{QStringLiteral("*")}, fileSelectedCallback->get(), acceptFileCallback->get(), fileDataReadyCallback->get());
|
||||
}
|
||||
|
||||
void FilesTest::saveFileWithFileDialog()
|
||||
{
|
||||
init();
|
||||
|
||||
static constexpr std::string_view testFileContent = "Save this important content";
|
||||
|
||||
EM_ASM({
|
||||
mockSaveFilePicker({
|
||||
name: 'somename',
|
||||
content: UTF8ToString($0),
|
||||
closeFn: (() => {
|
||||
const close = sinon.stub();
|
||||
close.callsFake(() =>
|
||||
new Promise(resolve => {
|
||||
resolve();
|
||||
Module.qtWasmPass();
|
||||
}));
|
||||
return close;
|
||||
})()
|
||||
});
|
||||
}, testFileContent.data());
|
||||
|
||||
QByteArray data;
|
||||
data.prepend(testFileContent);
|
||||
QWasmLocalFileAccess::saveFile(data, "hintie");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
auto testObject = std::make_shared<FilesTest>();
|
||||
QtWasmTest::initTestCase<QCoreApplication>(argc, argv, testObject);
|
||||
return 0;
|
||||
}
|
||||
|
||||
#include "files_main.moc"
|
@ -101,10 +101,22 @@ void runTestFunction(std::string name)
|
||||
QMetaObject::invokeMethod(g_testObject, name.c_str());
|
||||
}
|
||||
|
||||
void failTest(std::string message)
|
||||
{
|
||||
completeTestFunction(QtWasmTest::Fail, std::move(message));
|
||||
}
|
||||
|
||||
void passTest()
|
||||
{
|
||||
completeTestFunction(QtWasmTest::Pass, "");
|
||||
}
|
||||
|
||||
EMSCRIPTEN_BINDINGS(qtwebtestrunner) {
|
||||
emscripten::function("cleanupTestCase", &cleanupTestCase);
|
||||
emscripten::function("getTestFunctions", &getTestFunctions);
|
||||
emscripten::function("runTestFunction", &runTestFunction);
|
||||
emscripten::function("qtWasmFail", &failTest);
|
||||
emscripten::function("qtWasmPass", &passTest);
|
||||
}
|
||||
|
||||
//
|
||||
|
Loading…
x
Reference in New Issue
Block a user