forked from qt-creator/qt-creator
QmlProfiler: Improve error and progress handling for loading and saving
* Clear the trace and send the loadingFinished() signal when loading fails or is canceled. loadingFinished() re-enables the UI, which is in fact important. * Check more consistently for whether the operation was canceled and add a separate signal for that. * When saving fails or is canceled, remove the half-written file. * Don't try to guess the number of events for progress reporting when saving. Use the event timestamps and start/end time instead. * Properly initialize the progress range in all cases. * Drop the bool return value from the load methods. Nobody uses that. * Send loadFinished() only after loading a file, not every time we reach the Done state. Change-Id: I507f11c667e20534ea335e84798de11aa06fb6f2 Reviewed-by: Christian Kandeler <christian.kandeler@qt.io> Reviewed-by: Ulf Hermann <ulf.hermann@qt.io>
This commit is contained in:
@@ -139,15 +139,13 @@ void QmlProfilerFileReader::setFuture(QFutureInterface<void> *future)
|
||||
}
|
||||
}
|
||||
|
||||
bool QmlProfilerFileReader::loadQtd(QIODevice *device)
|
||||
void QmlProfilerFileReader::loadQtd(QIODevice *device)
|
||||
{
|
||||
QXmlStreamReader stream(device);
|
||||
|
||||
bool validVersion = true;
|
||||
|
||||
while (validVersion && !stream.atEnd() && !stream.hasError()) {
|
||||
if (isCanceled())
|
||||
return false;
|
||||
while (validVersion && !stream.atEnd() && !stream.hasError() && !isCanceled()) {
|
||||
QXmlStreamReader::TokenType token = stream.readNext();
|
||||
const QStringRef elementName = stream.name();
|
||||
switch (token) {
|
||||
@@ -188,16 +186,15 @@ bool QmlProfilerFileReader::loadQtd(QIODevice *device)
|
||||
}
|
||||
}
|
||||
|
||||
if (stream.hasError()) {
|
||||
if (isCanceled())
|
||||
emit canceled();
|
||||
else if (stream.hasError())
|
||||
emit error(tr("Error while parsing trace data file: %1").arg(stream.errorString()));
|
||||
return false;
|
||||
} else {
|
||||
else
|
||||
emit success();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool QmlProfilerFileReader::loadQzt(QIODevice *device)
|
||||
void QmlProfilerFileReader::loadQzt(QIODevice *device)
|
||||
{
|
||||
QDataStream stream(device);
|
||||
stream.setVersion(QDataStream::Qt_5_5);
|
||||
@@ -206,7 +203,7 @@ bool QmlProfilerFileReader::loadQzt(QIODevice *device)
|
||||
stream >> magic;
|
||||
if (magic != QByteArray("QMLPROFILER")) {
|
||||
emit error(tr("Invalid magic: %1").arg(QLatin1String(magic)));
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
qint32 dataStreamVersion;
|
||||
@@ -214,7 +211,7 @@ bool QmlProfilerFileReader::loadQzt(QIODevice *device)
|
||||
|
||||
if (dataStreamVersion > QDataStream::Qt_DefaultCompiledVersion) {
|
||||
emit error(tr("Unknown data stream version: %1").arg(dataStreamVersion));
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
stream.setVersion(dataStreamVersion);
|
||||
|
||||
@@ -226,45 +223,47 @@ bool QmlProfilerFileReader::loadQzt(QIODevice *device)
|
||||
QByteArray data;
|
||||
updateProgress(device);
|
||||
|
||||
stream >> data;
|
||||
buffer.setData(qUncompress(data));
|
||||
buffer.open(QIODevice::ReadOnly);
|
||||
bufferStream >> m_eventTypes;
|
||||
buffer.close();
|
||||
emit typesLoaded(m_eventTypes);
|
||||
updateProgress(device);
|
||||
if (!isCanceled()) {
|
||||
stream >> data;
|
||||
buffer.setData(qUncompress(data));
|
||||
buffer.open(QIODevice::ReadOnly);
|
||||
bufferStream >> m_eventTypes;
|
||||
buffer.close();
|
||||
emit typesLoaded(m_eventTypes);
|
||||
updateProgress(device);
|
||||
}
|
||||
|
||||
stream >> data;
|
||||
buffer.setData(qUncompress(data));
|
||||
buffer.open(QIODevice::ReadOnly);
|
||||
bufferStream >> m_notes;
|
||||
buffer.close();
|
||||
emit notesLoaded(m_notes);
|
||||
updateProgress(device);
|
||||
if (!isCanceled()) {
|
||||
stream >> data;
|
||||
buffer.setData(qUncompress(data));
|
||||
buffer.open(QIODevice::ReadOnly);
|
||||
bufferStream >> m_notes;
|
||||
buffer.close();
|
||||
emit notesLoaded(m_notes);
|
||||
updateProgress(device);
|
||||
}
|
||||
|
||||
const int eventBufferLength = 1024;
|
||||
QVector<QmlEvent> eventBuffer(eventBufferLength);
|
||||
int eventBufferIndex = 0;
|
||||
while (!stream.atEnd()) {
|
||||
while (!stream.atEnd() && !isCanceled()) {
|
||||
stream >> data;
|
||||
buffer.setData(qUncompress(data));
|
||||
buffer.open(QIODevice::ReadOnly);
|
||||
while (!buffer.atEnd()) {
|
||||
if (isCanceled())
|
||||
return false;
|
||||
while (!buffer.atEnd() && !isCanceled()) {
|
||||
QmlEvent &event = eventBuffer[eventBufferIndex];
|
||||
bufferStream >> event;
|
||||
if (bufferStream.status() == QDataStream::Ok) {
|
||||
if (event.typeIndex() >= m_eventTypes.length()) {
|
||||
emit error(tr("Invalid type index %1").arg(event.typeIndex()));
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
m_loadedFeatures |= (1ULL << m_eventTypes[event.typeIndex()].feature());
|
||||
} else if (bufferStream.status() == QDataStream::ReadPastEnd) {
|
||||
break; // Apparently EOF is a character so we end up here after the last event.
|
||||
} else if (bufferStream.status() == QDataStream::ReadCorruptData) {
|
||||
emit error(tr("Corrupt data before position %1.").arg(device->pos()));
|
||||
return false;
|
||||
return;
|
||||
} else {
|
||||
Q_UNREACHABLE();
|
||||
}
|
||||
@@ -276,10 +275,14 @@ bool QmlProfilerFileReader::loadQzt(QIODevice *device)
|
||||
buffer.close();
|
||||
updateProgress(device);
|
||||
}
|
||||
eventBuffer.resize(eventBufferIndex);
|
||||
emit qmlEventsLoaded(eventBuffer);
|
||||
emit success();
|
||||
return true;
|
||||
|
||||
if (isCanceled()) {
|
||||
emit canceled();
|
||||
} else {
|
||||
eventBuffer.resize(eventBufferIndex);
|
||||
emit qmlEventsLoaded(eventBuffer);
|
||||
emit success();
|
||||
}
|
||||
}
|
||||
|
||||
quint64 QmlProfilerFileReader::loadedFeatures() const
|
||||
@@ -309,9 +312,7 @@ void QmlProfilerFileReader::loadEventTypes(QXmlStreamReader &stream)
|
||||
line = column = 0;
|
||||
};
|
||||
|
||||
while (!stream.atEnd() && !stream.hasError()) {
|
||||
if (isCanceled())
|
||||
return;
|
||||
while (!stream.atEnd() && !stream.hasError() && !isCanceled()) {
|
||||
|
||||
QXmlStreamReader::TokenType token = stream.readNext();
|
||||
const QStringRef elementName = stream.name();
|
||||
@@ -424,9 +425,7 @@ void QmlProfilerFileReader::loadEvents(QXmlStreamReader &stream)
|
||||
QTC_ASSERT(stream.name() == _("profilerDataModel"), return);
|
||||
QVector<QmlEvent> events;
|
||||
|
||||
while (!stream.atEnd() && !stream.hasError()) {
|
||||
if (isCanceled())
|
||||
return;
|
||||
while (!stream.atEnd() && !stream.hasError() && !isCanceled()) {
|
||||
|
||||
QXmlStreamReader::TokenType token = stream.readNext();
|
||||
const QStringRef elementName = stream.name();
|
||||
@@ -514,9 +513,7 @@ void QmlProfilerFileReader::loadEvents(QXmlStreamReader &stream)
|
||||
void QmlProfilerFileReader::loadNotes(QXmlStreamReader &stream)
|
||||
{
|
||||
QmlNote currentNote;
|
||||
while (!stream.atEnd() && !stream.hasError()) {
|
||||
if (isCanceled())
|
||||
return;
|
||||
while (!stream.atEnd() && !stream.hasError() && !isCanceled()) {
|
||||
|
||||
QXmlStreamReader::TokenType token = stream.readNext();
|
||||
const QStringRef elementName = stream.name();
|
||||
@@ -596,16 +593,14 @@ void QmlProfilerFileWriter::setNotes(const QVector<QmlNote> ¬es)
|
||||
void QmlProfilerFileWriter::setFuture(QFutureInterface<void> *future)
|
||||
{
|
||||
m_future = future;
|
||||
if (m_future) {
|
||||
m_future->setProgressRange(0, ProgressTotal);
|
||||
m_future->setProgressValue(0);
|
||||
}
|
||||
}
|
||||
|
||||
void QmlProfilerFileWriter::saveQtd(QIODevice *device)
|
||||
{
|
||||
if (m_future) {
|
||||
m_future->setProgressRange(0, qMax(m_model->eventTypes().size() + m_notes.size(), 1));
|
||||
m_future->setProgressValue(0);
|
||||
m_newProgressValue = 0;
|
||||
}
|
||||
|
||||
QXmlStreamWriter stream(device);
|
||||
|
||||
stream.setAutoFormatting(true);
|
||||
@@ -619,11 +614,9 @@ void QmlProfilerFileWriter::saveQtd(QIODevice *device)
|
||||
|
||||
stream.writeStartElement(_("eventData"));
|
||||
stream.writeAttribute(_("totalTime"), QString::number(m_measuredTime));
|
||||
|
||||
const QVector<QmlEventType> &eventTypes = m_model->eventTypes();
|
||||
for (int typeIndex = 0, end = eventTypes.length(); typeIndex < end; ++typeIndex) {
|
||||
if (isCanceled())
|
||||
return;
|
||||
for (int typeIndex = 0, end = eventTypes.length(); typeIndex < end && !isCanceled();
|
||||
++typeIndex) {
|
||||
|
||||
const QmlEventType &type = eventTypes[typeIndex];
|
||||
|
||||
@@ -667,107 +660,118 @@ void QmlProfilerFileWriter::saveQtd(QIODevice *device)
|
||||
stream.writeTextElement(_("level"), QString::number(type.detailType()));
|
||||
}
|
||||
stream.writeEndElement();
|
||||
incrementProgress();
|
||||
}
|
||||
updateProgress(ProgressTypes);
|
||||
stream.writeEndElement(); // eventData
|
||||
|
||||
stream.writeStartElement(_("profilerDataModel"));
|
||||
if (!isCanceled()) {
|
||||
stream.writeStartElement(_("profilerDataModel"));
|
||||
|
||||
QStack<QmlEvent> stack;
|
||||
m_model->replayEvents(-1, -1, [this, &stack, &stream](const QmlEvent &event,
|
||||
const QmlEventType &type) {
|
||||
if (isCanceled())
|
||||
return;
|
||||
QStack<QmlEvent> stack;
|
||||
m_model->replayEvents(-1, -1, [this, &stack, &stream](const QmlEvent &event,
|
||||
const QmlEventType &type) {
|
||||
if (isCanceled())
|
||||
return;
|
||||
|
||||
if (type.rangeType() != MaximumRangeType && event.rangeStage() == RangeStart) {
|
||||
stack.push(event);
|
||||
return;
|
||||
}
|
||||
|
||||
stream.writeStartElement(_("range"));
|
||||
if (type.rangeType() != MaximumRangeType && event.rangeStage() == RangeEnd) {
|
||||
QmlEvent start = stack.pop();
|
||||
stream.writeAttribute(_("startTime"), QString::number(start.timestamp()));
|
||||
stream.writeAttribute(_("duration"),
|
||||
QString::number(event.timestamp() - start.timestamp()));
|
||||
} else {
|
||||
stream.writeAttribute(_("startTime"), QString::number(event.timestamp()));
|
||||
}
|
||||
|
||||
stream.writeAttribute(_("eventIndex"), QString::number(event.typeIndex()));
|
||||
|
||||
if (type.message() == Event) {
|
||||
if (type.detailType() == AnimationFrame) {
|
||||
// special: animation event
|
||||
stream.writeAttribute(_("framerate"), QString::number(event.number<qint32>(0)));
|
||||
stream.writeAttribute(_("animationcount"),
|
||||
QString::number(event.number<qint32>(1)));
|
||||
stream.writeAttribute(_("thread"), QString::number(event.number<qint32>(2)));
|
||||
} else if (type.detailType() == Key || type.detailType() == Mouse) {
|
||||
// special: input event
|
||||
stream.writeAttribute(_("type"), QString::number(event.number<qint32>(0)));
|
||||
stream.writeAttribute(_("data1"), QString::number(event.number<qint32>(1)));
|
||||
stream.writeAttribute(_("data2"), QString::number(event.number<qint32>(2)));
|
||||
}
|
||||
}
|
||||
|
||||
// special: pixmap cache event
|
||||
if (type.message() == PixmapCacheEvent) {
|
||||
if (type.detailType() == PixmapSizeKnown) {
|
||||
stream.writeAttribute(_("width"), QString::number(event.number<qint32>(0)));
|
||||
stream.writeAttribute(_("height"), QString::number(event.number<qint32>(1)));
|
||||
if (type.rangeType() != MaximumRangeType && event.rangeStage() == RangeStart) {
|
||||
stack.push(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.detailType() == PixmapReferenceCountChanged
|
||||
|| type.detailType() == PixmapCacheCountChanged)
|
||||
stream.writeAttribute(_("refCount"), QString::number(event.number<qint32>(2)));
|
||||
}
|
||||
|
||||
if (type.message() == SceneGraphFrame) {
|
||||
// special: scenegraph frame events
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
qint64 number = event.number<qint64>(i);
|
||||
if (number <= 0)
|
||||
continue;
|
||||
stream.writeAttribute(QString::fromLatin1("timing%1").arg(i + 1),
|
||||
QString::number(number));
|
||||
stream.writeStartElement(_("range"));
|
||||
if (type.rangeType() != MaximumRangeType && event.rangeStage() == RangeEnd) {
|
||||
QmlEvent start = stack.pop();
|
||||
stream.writeAttribute(_("startTime"), QString::number(start.timestamp()));
|
||||
stream.writeAttribute(_("duration"),
|
||||
QString::number(event.timestamp() - start.timestamp()));
|
||||
} else {
|
||||
stream.writeAttribute(_("startTime"), QString::number(event.timestamp()));
|
||||
}
|
||||
}
|
||||
|
||||
// special: memory allocation event
|
||||
if (type.message() == MemoryAllocation)
|
||||
stream.writeAttribute(_("amount"), QString::number(event.number<qint64>(0)));
|
||||
stream.writeAttribute(_("eventIndex"), QString::number(event.typeIndex()));
|
||||
|
||||
if (type.message() == DebugMessage)
|
||||
stream.writeAttribute(_("text"), event.string());
|
||||
if (type.message() == Event) {
|
||||
if (type.detailType() == AnimationFrame) {
|
||||
// special: animation event
|
||||
stream.writeAttribute(_("framerate"), QString::number(event.number<qint32>(0)));
|
||||
stream.writeAttribute(_("animationcount"),
|
||||
QString::number(event.number<qint32>(1)));
|
||||
stream.writeAttribute(_("thread"), QString::number(event.number<qint32>(2)));
|
||||
} else if (type.detailType() == Key || type.detailType() == Mouse) {
|
||||
// special: input event
|
||||
stream.writeAttribute(_("type"), QString::number(event.number<qint32>(0)));
|
||||
stream.writeAttribute(_("data1"), QString::number(event.number<qint32>(1)));
|
||||
stream.writeAttribute(_("data2"), QString::number(event.number<qint32>(2)));
|
||||
}
|
||||
}
|
||||
|
||||
stream.writeEndElement();
|
||||
incrementProgress();
|
||||
});
|
||||
stream.writeEndElement(); // profilerDataModel
|
||||
// special: pixmap cache event
|
||||
if (type.message() == PixmapCacheEvent) {
|
||||
if (type.detailType() == PixmapSizeKnown) {
|
||||
stream.writeAttribute(_("width"), QString::number(event.number<qint32>(0)));
|
||||
stream.writeAttribute(_("height"), QString::number(event.number<qint32>(1)));
|
||||
}
|
||||
|
||||
stream.writeStartElement(_("noteData"));
|
||||
for (int noteIndex = 0; noteIndex < m_notes.size(); ++noteIndex) {
|
||||
if (isCanceled())
|
||||
return;
|
||||
if (type.detailType() == PixmapReferenceCountChanged
|
||||
|| type.detailType() == PixmapCacheCountChanged)
|
||||
stream.writeAttribute(_("refCount"), QString::number(event.number<qint32>(2)));
|
||||
}
|
||||
|
||||
const QmlNote ¬e = m_notes[noteIndex];
|
||||
stream.writeStartElement(_("note"));
|
||||
stream.writeAttribute(_("startTime"), QString::number(note.startTime()));
|
||||
stream.writeAttribute(_("duration"), QString::number(note.duration()));
|
||||
stream.writeAttribute(_("eventIndex"), QString::number(note.typeIndex()));
|
||||
stream.writeAttribute(_("collapsedRow"), QString::number(note.collapsedRow()));
|
||||
stream.writeCharacters(note.text());
|
||||
stream.writeEndElement(); // note
|
||||
incrementProgress();
|
||||
if (type.message() == SceneGraphFrame) {
|
||||
// special: scenegraph frame events
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
qint64 number = event.number<qint64>(i);
|
||||
if (number <= 0)
|
||||
continue;
|
||||
stream.writeAttribute(QString::fromLatin1("timing%1").arg(i + 1),
|
||||
QString::number(number));
|
||||
}
|
||||
}
|
||||
|
||||
// special: memory allocation event
|
||||
if (type.message() == MemoryAllocation)
|
||||
stream.writeAttribute(_("amount"), QString::number(event.number<qint64>(0)));
|
||||
|
||||
if (type.message() == DebugMessage)
|
||||
stream.writeAttribute(_("text"), event.string());
|
||||
|
||||
stream.writeEndElement();
|
||||
|
||||
// Update the progress roughly every 4k events. It doesn't have to be precise.
|
||||
if ((event.timestamp() & 0xfff) == 0)
|
||||
updateProgress(event.timestamp());
|
||||
});
|
||||
|
||||
stream.writeEndElement(); // profilerDataModel
|
||||
}
|
||||
stream.writeEndElement(); // noteData
|
||||
|
||||
if (isCanceled())
|
||||
return;
|
||||
if (!isCanceled()) {
|
||||
stream.writeStartElement(_("noteData"));
|
||||
for (int noteIndex = 0; noteIndex < m_notes.size() && !isCanceled(); ++noteIndex) {
|
||||
|
||||
const QmlNote ¬e = m_notes[noteIndex];
|
||||
stream.writeStartElement(_("note"));
|
||||
stream.writeAttribute(_("startTime"), QString::number(note.startTime()));
|
||||
stream.writeAttribute(_("duration"), QString::number(note.duration()));
|
||||
stream.writeAttribute(_("eventIndex"), QString::number(note.typeIndex()));
|
||||
stream.writeAttribute(_("collapsedRow"), QString::number(note.collapsedRow()));
|
||||
stream.writeCharacters(note.text());
|
||||
stream.writeEndElement(); // note
|
||||
}
|
||||
stream.writeEndElement(); // noteData
|
||||
updateProgress(ProgressNotes);
|
||||
}
|
||||
|
||||
stream.writeEndElement(); // trace
|
||||
stream.writeEndDocument();
|
||||
|
||||
if (isCanceled()) {
|
||||
emit canceled();
|
||||
} else if (stream.hasError()) {
|
||||
emit error(tr("Error writing trace file."));
|
||||
} else {
|
||||
emit success();
|
||||
}
|
||||
}
|
||||
|
||||
void QmlProfilerFileWriter::saveQzt(QFile *file)
|
||||
@@ -784,47 +788,64 @@ void QmlProfilerFileWriter::saveQzt(QFile *file)
|
||||
QDataStream bufferStream(&buffer);
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
|
||||
incrementProgress();
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
bufferStream << m_model->eventTypes();
|
||||
stream << qCompress(buffer.data());
|
||||
buffer.close();
|
||||
buffer.buffer().clear();
|
||||
incrementProgress();
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
bufferStream << m_notes;
|
||||
stream << qCompress(buffer.data());
|
||||
buffer.close();
|
||||
buffer.buffer().clear();
|
||||
incrementProgress();
|
||||
if (!isCanceled()) {
|
||||
bufferStream << m_model->eventTypes();
|
||||
stream << qCompress(buffer.data());
|
||||
buffer.close();
|
||||
buffer.buffer().clear();
|
||||
updateProgress(ProgressTypes);
|
||||
}
|
||||
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
m_model->replayEvents(-1, -1, [&stream, &buffer, &bufferStream](const QmlEvent &event,
|
||||
const QmlEventType &type) {
|
||||
Q_UNUSED(type);
|
||||
bufferStream << event;
|
||||
// 32MB buffer should be plenty for efficient compression
|
||||
if (buffer.data().length() > (1 << 25)) {
|
||||
stream << qCompress(buffer.data());
|
||||
buffer.close();
|
||||
buffer.buffer().clear();
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
}
|
||||
});
|
||||
stream << qCompress(buffer.data());
|
||||
buffer.close();
|
||||
buffer.buffer().clear();
|
||||
if (!isCanceled()) {
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
bufferStream << m_notes;
|
||||
stream << qCompress(buffer.data());
|
||||
buffer.close();
|
||||
buffer.buffer().clear();
|
||||
updateProgress(ProgressNotes);
|
||||
}
|
||||
|
||||
if (!isCanceled()) {
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
m_model->replayEvents(-1, -1, [this, &stream, &buffer, &bufferStream](
|
||||
const QmlEvent &event, const QmlEventType &type) {
|
||||
Q_UNUSED(type);
|
||||
bufferStream << event;
|
||||
// 32MB buffer should be plenty for efficient compression
|
||||
if (buffer.data().length() > (1 << 25)) {
|
||||
stream << qCompress(buffer.data());
|
||||
buffer.close();
|
||||
buffer.buffer().clear();
|
||||
if (isCanceled())
|
||||
return;
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
updateProgress(event.timestamp());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isCanceled()) {
|
||||
emit canceled();
|
||||
} else {
|
||||
stream << qCompress(buffer.data());
|
||||
buffer.close();
|
||||
buffer.buffer().clear();
|
||||
updateProgress(m_endTime);
|
||||
emit success();
|
||||
}
|
||||
}
|
||||
|
||||
void QmlProfilerFileWriter::incrementProgress()
|
||||
void QmlProfilerFileWriter::updateProgress(qint64 timestamp)
|
||||
{
|
||||
if (!m_future)
|
||||
return;
|
||||
|
||||
m_newProgressValue++;
|
||||
if (float(m_newProgressValue - m_future->progressValue())
|
||||
/ float(m_future->progressMaximum() - m_future->progressMinimum()) >= 0.01) {
|
||||
m_future->setProgressValue(m_newProgressValue);
|
||||
if (timestamp < 0) {
|
||||
m_future->setProgressValue(m_future->progressValue() - timestamp);
|
||||
} else {
|
||||
m_future->setProgressValue(m_future->progressValue()
|
||||
+ float(m_endTime - timestamp) / float(m_endTime - m_startTime)
|
||||
* ProgressEvents);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user