Commit 23939aaa authored by Ronan Abhamon's avatar Ronan Abhamon

feat(core): supports H264 download

Co-authored-by: 's avatarDanmei Chen <danmei.chen@belledonne-communications.com>
parent 7c24e07f
......@@ -145,6 +145,7 @@ set(SOURCES
src/components/core/CoreHandlers.cpp
src/components/core/CoreManager.cpp
src/components/core/messages-count-notifier/AbstractMessagesCountNotifier.cpp
src/components/file/FileDownloader.cpp
src/components/file/FileExtractor.cpp
src/components/notifier/Notifier.cpp
src/components/other/clipboard/Clipboard.cpp
......@@ -163,8 +164,8 @@ set(SOURCES
src/components/timeline/TimelineModel.cpp
src/components/url-handlers/UrlHandlers.cpp
src/utils/LinphoneUtils.cpp
src/utils/Utils.cpp
src/utils/QExifImageHeader.cpp
src/utils/Utils.cpp
)
set(HEADERS
......@@ -202,6 +203,7 @@ set(HEADERS
src/components/core/CoreHandlers.hpp
src/components/core/CoreManager.hpp
src/components/core/messages-count-notifier/AbstractMessagesCountNotifier.hpp
src/components/file/FileDownloader.hpp
src/components/file/FileExtractor.hpp
src/components/notifier/Notifier.hpp
src/components/other/clipboard/Clipboard.hpp
......
......@@ -1003,6 +1003,29 @@ your friend&apos;s SIP address or username.</translation>
<translation>New attachment received!</translation>
</message>
</context>
<context>
<name>OnlineInstallerDialog</name>
<message>
<source>confirm</source>
<translation>CONFIRM</translation>
</message>
<message>
<source>onlineInstallerExtractingDescription</source>
<translation>Extracting %1...</translation>
</message>
<message>
<source>onlineInstallerDownloadingDescription</source>
<translation>Downloading %1...</translation>
</message>
<message>
<source>onlineInstallerFinishedDescription</source>
<translation>%1 is now installed!</translation>
</message>
<message>
<source>onlineInstallerFailedDescription</source>
<translation>Failed to install %1!</translation>
</message>
</context>
<context>
<name>OutgoingMessage</name>
<message>
......@@ -1672,4 +1695,11 @@ your friend&apos;s SIP address or username.</translation>
<translation>CONFIRM</translation>
</message>
</context>
<context>
<name>linphone-utils</name>
<message>
<source>downloadCodecDescription</source>
<translation>Do you want to download %1 (%2)?</translation>
</message>
</context>
</TS>
......@@ -1001,6 +1001,29 @@ Cliquez ici : &lt;a href=&quot;%1&quot;&gt;%1&lt;/a&gt;
<translation>Pièce jointe reçue !</translation>
</message>
</context>
<context>
<name>OnlineInstallerDialog</name>
<message>
<source>confirm</source>
<translation>CONFIRM</translation>
</message>
<message>
<source>onlineInstallerExtractingDescription</source>
<translation>Extraction de %1...</translation>
</message>
<message>
<source>onlineInstallerDownloadingDescription</source>
<translation>Téléchargement de %1...</translation>
</message>
<message>
<source>onlineInstallerFinishedDescription</source>
<translation>Installation de %1 terminée !</translation>
</message>
<message>
<source>onlineInstallerFailedDescription</source>
<translation>L&apos;installation de %1 a échoué !</translation>
</message>
</context>
<context>
<name>OutgoingMessage</name>
<message>
......@@ -1670,4 +1693,11 @@ Cliquez ici : &lt;a href=&quot;%1&quot;&gt;%1&lt;/a&gt;
<translation>CONFIRMER</translation>
</message>
</context>
<context>
<name>linphone-utils</name>
<message>
<source>downloadCodecDescription</source>
<translation>Voulez-vous installer %1 (%2) ?</translation>
</message>
</context>
</TS>
......@@ -1001,6 +1001,29 @@
<translation>Получен новый файл!</translation>
</message>
</context>
<context>
<name>OnlineInstallerDialog</name>
<message>
<source>confirm</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>onlineInstallerExtractingDescription</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>onlineInstallerDownloadingDescription</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>onlineInstallerFinishedDescription</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>onlineInstallerFailedDescription</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>OutgoingMessage</name>
<message>
......@@ -1670,4 +1693,11 @@
<translation>ПОДТВЕРДИТЬ</translation>
</message>
</context>
<context>
<name>linphone-utils</name>
<message>
<source>downloadCodecDescription</source>
<translation type="unfinished"></translation>
</message>
</context>
</TS>
......@@ -1003,6 +1003,29 @@ arkadaşınızın SIP adresini veya kullanıcı adını girin.</translation>
<translation>Yeni ek alındı!</translation>
</message>
</context>
<context>
<name>OnlineInstallerDialog</name>
<message>
<source>confirm</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>onlineInstallerExtractingDescription</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>onlineInstallerDownloadingDescription</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>onlineInstallerFinishedDescription</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>onlineInstallerFailedDescription</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>OutgoingMessage</name>
<message>
......@@ -1672,4 +1695,11 @@ arkadaşınızın SIP adresini veya kullanıcı adını girin.</translation>
<translation>ONAYLA</translation>
</message>
</context>
<context>
<name>linphone-utils</name>
<message>
<source>downloadCodecDescription</source>
<translation type="unfinished"></translation>
</message>
</context>
</TS>
......@@ -332,6 +332,7 @@
<file>ui/modules/Linphone/Contact/ContactDescription.qml</file>
<file>ui/modules/Linphone/Contact/Contact.qml</file>
<file>ui/modules/Linphone/Contact/MessagesCounter.qml</file>
<file>ui/modules/Linphone/Dialog/OnlineInstallerDialog.qml</file>
<file>ui/modules/Linphone/Menus/SipAddressesMenu.qml</file>
<file>ui/modules/Linphone/Notifications/NotificationBasic.qml</file>
<file>ui/modules/Linphone/Notifications/NotificationNewVersionAvailable.qml</file>
......@@ -357,6 +358,7 @@
<file>ui/modules/Linphone/Styles/Contact/ContactDescriptionStyle.qml</file>
<file>ui/modules/Linphone/Styles/Contact/ContactStyle.qml</file>
<file>ui/modules/Linphone/Styles/Contact/MessagesCounterStyle.qml</file>
<file>ui/modules/Linphone/Styles/Dialog/OnlineInstallerDialogStyle.qml</file>
<file>ui/modules/Linphone/Styles/Menus/SipAddressesMenuStyle.qml</file>
<file>ui/modules/Linphone/Styles/Notifications/NotificationBasicStyle.qml</file>
<file>ui/modules/Linphone/Styles/Notifications/NotificationReceivedCallStyle.qml</file>
......
......@@ -176,10 +176,6 @@ void App::initContentApp () {
Cli::executeCommand(command);
});
// Add plugins directory.
addLibraryPath(::Utils::coreStringToAppString(Paths::getPluginsDirPath()));
qInfo() << QStringLiteral("Library paths:") << libraryPaths();
mustBeIconified = mParser->isSet("iconified");
}
......@@ -405,6 +401,7 @@ void App::registerTypes () {
registerType<ConferenceHelperModel>("ConferenceHelperModel");
registerType<ConferenceModel>("ConferenceModel");
registerType<ContactsListProxyModel>("ContactsListProxyModel");
registerType<FileDownloader>("FileDownloader");
registerType<FileExtractor>("FileExtractor");
registerType<SipAddressesProxyModel>("SipAddressesProxyModel");
registerType<SoundPlayer>("SoundPlayer");
......
......@@ -159,7 +159,7 @@ void Logger::log (QtMsgType type, const QMessageLogContext &context, const QStri
contextStr = contextArr.constData();
}
#else
(void)context;
Q_UNUSED(context);
#endif // ifdef QT_MESSAGELOGCONTEXT
QByteArray localMsg = msg.toLocal8Bit();
......
......@@ -40,6 +40,7 @@ namespace {
constexpr char cPathAssistantConfig[] = "/linphone/assistant/";
constexpr char cPathAvatars[] = "/avatars/";
constexpr char cPathCaptures[] = "/Linphone/captures/";
constexpr char cPathCodecs[] = "/codecs/";
constexpr char cPathLogs[] = "/logs/";
constexpr char cPathPlugins[] = "/plugins/";
constexpr char cPathThumbnails[] = "/thumbnails/";
......@@ -181,6 +182,10 @@ string Paths::getCapturesDirPath () {
return getWritableDirPath(QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation) + cPathCaptures);
}
string Paths::getCodecsDirPath () {
return getWritableDirPath(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + cPathCodecs);
}
string Paths::getConfigFilePath (const QString &configPath, bool writable) {
const QString path = configPath.isEmpty()
? getAppConfigFilePath()
......@@ -217,10 +222,6 @@ string Paths::getPackageMsPluginsDirPath () {
return getReadableDirPath(getAppPackageMsPluginsDirPath());
}
string Paths::getPluginsDirPath () {
return getReadableDirPath(getAppPluginsDirPath());
}
string Paths::getRootCaFilePath () {
return getReadableFilePath(getAppRootCaFilePath());
}
......
......@@ -34,15 +34,15 @@ namespace Paths {
std::string getAvatarsDirPath ();
std::string getCallHistoryFilePath ();
std::string getCapturesDirPath ();
std::string getCodecsDirPath ();
std::string getConfigFilePath (const QString &configPath = QString(), bool writable = true);
std::string getDownloadDirPath ();
std::string getFactoryConfigFilePath ();
std::string getFriendsListFilePath ();
std::string getDownloadDirPath ();
std::string getLogsDirPath ();
std::string getMessageHistoryFilePath ();
std::string getPackageDataDirPath ();
std::string getPackageMsPluginsDirPath ();
std::string getPluginsDirPath ();
std::string getRootCaFilePath ();
std::string getThumbnailsDirPath ();
std::string getUserCertificatesDirPath ();
......
......@@ -36,6 +36,7 @@
#include "conference/ConferenceModel.hpp"
#include "contacts/ContactsListProxyModel.hpp"
#include "core/CoreManager.hpp"
#include "file/FileDownloader.hpp"
#include "file/FileExtractor.hpp"
#include "presence/OwnPresenceModel.hpp"
#include "settings/AccountSettingsModel.hpp"
......
......@@ -20,17 +20,18 @@
* Author: Ronan Abhamon
*/
#include "../../app/paths/Paths.hpp"
#include "../../utils/Utils.hpp"
#include "../core/CoreManager.hpp"
#include "AbstractCodecsModel.hpp"
using namespace std;
// =============================================================================
using namespace std;
static inline shared_ptr<linphone::PayloadType> getCodecFromMap (const QVariantMap &map) {
return map.value("__codec").value<shared_ptr<linphone::PayloadType> >();
return map.value("__codec").value<shared_ptr<linphone::PayloadType>>();
}
// -----------------------------------------------------------------------------
......@@ -65,12 +66,12 @@ void AbstractCodecsModel::enableCodec (int id, bool status) {
Q_ASSERT(id >= 0 && id < mCodecs.count());
QVariantMap &map = mCodecs[id];
shared_ptr<linphone::PayloadType> codec = ::getCodecFromMap(map);
shared_ptr<linphone::PayloadType> codec = getCodecFromMap(map);
if (codec) {
codec->enable(status);
map["enabled"] = codec->enabled();
emit dataChanged(index(id, 0), index(id, 0));
}
}
void AbstractCodecsModel::moveCodec (int source, int destination) {
......@@ -81,12 +82,12 @@ void AbstractCodecsModel::setBitrate (int id, int bitrate) {
Q_ASSERT(id >= 0 && id < mCodecs.count());
QVariantMap &map = mCodecs[id];
shared_ptr<linphone::PayloadType> codec = ::getCodecFromMap(map);
shared_ptr<linphone::PayloadType> codec = getCodecFromMap(map);
if (codec) {
codec->setNormalBitrate(bitrate);
map["bitrate"] = codec->getNormalBitrate();
emit dataChanged(index(id, 0), index(id, 0));
}
}
void AbstractCodecsModel::setRecvFmtp (int id, const QString &recvFmtp) {
......@@ -94,11 +95,11 @@ void AbstractCodecsModel::setRecvFmtp (int id, const QString &recvFmtp) {
QVariantMap &map = mCodecs[id];
shared_ptr<linphone::PayloadType> codec = ::getCodecFromMap(map);
codec->setRecvFmtp(::Utils::appStringToCoreString(recvFmtp));
map["recvFmtp"] = ::Utils::coreStringToAppString(codec->getRecvFmtp());
if (codec) {
codec->setRecvFmtp(Utils::appStringToCoreString(recvFmtp));
map["recvFmtp"] = Utils::coreStringToAppString(codec->getRecvFmtp());
emit dataChanged(index(id, 0), index(id, 0));
}
}
// -----------------------------------------------------------------------------
......@@ -110,6 +111,8 @@ bool AbstractCodecsModel::moveRows (
const QModelIndex &destinationParent,
int destinationChild
) {
// TODO: Do not move downloadable codecs.
int limit = sourceRow + count - 1;
{
......@@ -139,9 +142,13 @@ bool AbstractCodecsModel::moveRows (
}
// Update linphone codecs list.
list<shared_ptr<linphone::PayloadType> > codecs;
for (const auto &map : mCodecs)
codecs.push_back(::getCodecFromMap(map));
list<shared_ptr<linphone::PayloadType>> codecs;
for (const auto &map : mCodecs) {
// Do not update downloadable codecs.
shared_ptr<linphone::PayloadType> codec = getCodecFromMap(map);
if (codec)
codecs.push_back(codec);
}
updateCodecs(codecs);
endMoveRows();
......@@ -157,15 +164,40 @@ void AbstractCodecsModel::addCodec (shared_ptr<linphone::PayloadType> &codec) {
map["bitrate"] = codec->getNormalBitrate();
map["channels"] = codec->getChannels();
map["clockRate"] = codec->getClockRate();
map["description"] = ::Utils::coreStringToAppString(codec->getDescription());
map["description"] = Utils::coreStringToAppString(codec->getDescription());
map["enabled"] = codec->enabled();
map["encoderDescription"] = ::Utils::coreStringToAppString(codec->getEncoderDescription());
map["encoderDescription"] = Utils::coreStringToAppString(codec->getEncoderDescription());
map["isUsable"] = codec->isUsable(); // TODO: Notify in UI when unusable.
map["isVbr"] = codec->isVbr();
map["mime"] = ::Utils::coreStringToAppString(codec->getMimeType());
map["mime"] = Utils::coreStringToAppString(codec->getMimeType());
map["number"] = codec->getNumber();
map["recvFmtp"] = ::Utils::coreStringToAppString(codec->getRecvFmtp());
map["recvFmtp"] = Utils::coreStringToAppString(codec->getRecvFmtp());
map["__codec"] = QVariant::fromValue(codec);
mCodecs << map;
}
void AbstractCodecsModel::addDownloadableCodec (
const QString &mime,
const QString &downloadUrl,
const QString &encoderDescription
) {
QVariantMap map;
map["mime"] = mime;
map["downloadUrl"] = downloadUrl;
map["encoderDescription"] = encoderDescription;
mCodecs << map;
}
QVariantMap AbstractCodecsModel::getCodecInfo (const QString &mime) const {
for (const auto &codec : mCodecs)
if (codec.value("mime") == mime)
return codec;
return QVariantMap();
};
QString AbstractCodecsModel::getCodecsFolder () const {
return Utils::coreStringToAppString(Paths::getCodecsDirPath());
}
......@@ -36,6 +36,8 @@ namespace linphone {
class AbstractCodecsModel : public QAbstractListModel {
Q_OBJECT;
Q_PROPERTY(QString codecsFolder READ getCodecsFolder CONSTANT);
public:
AbstractCodecsModel (QObject *parent = Q_NULLPTR);
virtual ~AbstractCodecsModel () = default;
......@@ -51,6 +53,10 @@ public:
Q_INVOKABLE void setBitrate (int id, int bitrate);
Q_INVOKABLE void setRecvFmtp (int id, const QString &recvFmtp);
Q_INVOKABLE virtual void reload () {};
Q_INVOKABLE QVariantMap getCodecInfo (const QString &mime) const;
protected:
bool moveRows (
const QModelIndex &sourceParent,
......@@ -61,10 +67,12 @@ protected:
) override;
void addCodec (std::shared_ptr<linphone::PayloadType> &codec);
void addDownloadableCodec (const QString &mime, const QString &downloadUrl, const QString &encoderDescription);
QString getCodecsFolder () const;
virtual void updateCodecs (std::list<std::shared_ptr<linphone::PayloadType> > &codecs) = 0;
virtual void updateCodecs (std::list<std::shared_ptr<linphone::PayloadType>> &codecs) = 0;
private:
QList<QVariantMap> mCodecs;
};
......
......@@ -24,15 +24,15 @@
#include "AudioCodecsModel.hpp"
using namespace std;
// =============================================================================
using namespace std;
AudioCodecsModel::AudioCodecsModel (QObject *parent) : AbstractCodecsModel(parent) {
for (auto &codec : CoreManager::getInstance()->getCore()->getAudioPayloadTypes())
addCodec(codec);
}
void AudioCodecsModel::updateCodecs (list<shared_ptr<linphone::PayloadType> > &codecs) {
void AudioCodecsModel::updateCodecs (list<shared_ptr<linphone::PayloadType>> &codecs) {
CoreManager::getInstance()->getCore()->setAudioPayloadTypes(codecs);
}
......@@ -34,8 +34,8 @@ public:
AudioCodecsModel (QObject *parent = Q_NULLPTR);
~AudioCodecsModel () = default;
protected:
void updateCodecs (std::list<std::shared_ptr<linphone::PayloadType> > &codecs) override;
private:
void updateCodecs (std::list<std::shared_ptr<linphone::PayloadType>> &codecs) override;
};
#endif // AUDIO_CODECS_MODEL_H_
......@@ -20,19 +20,79 @@
* Author: Ronan Abhamon
*/
#include <QDirIterator>
#include <QLibrary>
#include "../../app/paths/Paths.hpp"
#include "../../utils/Utils.hpp"
#include "../core/CoreManager.hpp"
#include "VideoCodecsModel.hpp"
// =============================================================================
using namespace std;
// =============================================================================
namespace {
constexpr char cH264Description[] = "Provided by CISCO SYSTEM,INC";
#ifdef Q_OS_LINUX
constexpr char cLibraryExtension[] = "so";
#ifdef Q_PROCESSOR_X86_64
constexpr char cPluginUrlH264[] = "http://ciscobinary.openh264.org/libopenh264-1.7.0-linux64.4.so.bz2";
#else
constexpr char cPluginUrlH264[] = "http://ciscobinary.openh264.org/libopenh264-1.7.0-linux32.4.so.bz2";
#endif // ifdef Q_PROCESSOR_X86_64
#elif defined(Q_OS_WIN)
constexpr char cLibraryExtension[] = "dll";
#ifdef Q_OS_WIN64
constexpr char cPluginUrlH264[] = "http://ciscobinary.openh264.org/openh264-1.7.0-win64.dll.bz2";
#elif defined(Q_OS_WIN32)
constexpr char cPluginUrlH264[] = "http://ciscobinary.openh264.org/openh264-1.7.0-win32.dll.bz2";
#endif // ifdef Q_OS_WIN64
#endif // ifdef Q_OS_LINUX
}
VideoCodecsModel::VideoCodecsModel (QObject *parent) : AbstractCodecsModel(parent) {
for (auto &codec : CoreManager::getInstance()->getCore()->getVideoPayloadTypes())
addCodec(codec);
load();
}
void VideoCodecsModel::updateCodecs (list<shared_ptr<linphone::PayloadType> > &codecs) {
void VideoCodecsModel::updateCodecs (list<shared_ptr<linphone::PayloadType>> &codecs) {
CoreManager::getInstance()->getCore()->setVideoPayloadTypes(codecs);
}
void VideoCodecsModel::load () {
mCodecs.clear();
shared_ptr<linphone::Core> core = CoreManager::getInstance()->getCore();
// Load downloaded codecs like OpenH264.
#if defined(Q_OS_LINUX) || defined(Q_OS_WIN)
QDirIterator it(Utils::coreStringToAppString(Paths::getCodecsDirPath()));
while (it.hasNext()) {
QFileInfo info(it.next());
if (info.suffix() == cLibraryExtension)
QLibrary(info.filePath()).load();
}
core->reloadMsPlugins("");
#endif
// Add codecs.
auto codecs = core->getVideoPayloadTypes();
for (auto &codec : codecs)
addCodec(codec);
// Add downloadable codecs.
#if defined(Q_OS_LINUX) || defined(Q_OS_WIN)
if (find_if(codecs.begin(), codecs.end(), [](const shared_ptr<linphone::PayloadType> &codec) {
return codec->getMimeType() == "H264";
}) == codecs.end())
addDownloadableCodec("H264", cPluginUrlH264, cH264Description);
#endif
}
void VideoCodecsModel::reload () {
beginResetModel();
load();
endResetModel();
}
......@@ -34,8 +34,11 @@ public:
VideoCodecsModel (QObject *parent = Q_NULLPTR);
~VideoCodecsModel () = default;
protected:
void updateCodecs (std::list<std::shared_ptr<linphone::PayloadType> > &codecs) override;
private:
void updateCodecs (std::list<std::shared_ptr<linphone::PayloadType>> &codecs) override;
void load ();
void reload () override;
};
#endif // VIDEO_CODECS_MODEL_H_
......@@ -30,5 +30,5 @@ MessagesCountNotifier::MessagesCountNotifier (QObject *parent) : AbstractMessage
void MessagesCountNotifier::notifyUnreadMessagesCount (int n) {
// TODO.
(void)n;
Q_UNUSED(n);
}
/*
* FileDownloader.cpp
* Copyright (C) 2017-2018 Belledonne Communications, Grenoble, France
*
* This program is free 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 2
* of the License, or (at your option) any later version.
*
* This program 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 this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* Created on: February 6, 2018
* Author: Danmei Chen
*/
#include "../../app/paths/Paths.hpp"
#include "../../utils/Utils.hpp"
#include "../core/CoreManager.hpp"
#include "FileDownloader.hpp"
// =============================================================================
namespace {
constexpr char cDefaultFileName[] = "download";
}
static QString getDownloadFilePath (const QString &folder, const QUrl &url) {
QFileInfo fileInfo(url.path());
QString fileName = fileInfo.fileName();
if (fileName.isEmpty())
fileName = cDefaultFileName;
fileName.prepend(folder);
if (!QFile::exists(fileName))
return fileName;
// Already exists, don't overwrite.
QString baseName = fileInfo.completeBaseName();
if (baseName.isEmpty())
baseName = cDefaultFileName;
QString suffix = fileInfo.suffix();
if (!suffix.isEmpty())
suffix.prepend(".");
for (int i = 1; true; ++i) {
fileName = folder + baseName + "(" + QString::number(i) + ")" + suffix;
if (!QFile::exists(fileName))
break;
}
return fileName;
}
static bool isHttpRedirect (QNetworkReply *reply) {
Q_CHECK_PTR(reply);
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
return statusCode == 301 || statusCode == 302 || statusCode == 303
|| statusCode == 305 || statusCode == 307 || statusCode == 308;
}
// -----------------------------------------------------------------------------
void FileDownloader::download () {
if (mDownloading) {
qWarning() << "Unable to download file. Already downloading!";
return;
}
setDownloading(true);
QNetworkRequest request(mUrl);
mNetworkReply = mManager.get(request);
#if QT_CONFIG(ssl)
QObject::connect(mNetworkReply, &QNetworkReply::sslErrors, this, &FileDownloader::handleSslErrors);
#endif
QObject::connect(mNetworkReply, &QNetworkReply::downloadProgress, this, &FileDownloader::handleDownloadProgress);
QObject::connect(mNetworkReply, &QNetworkReply::readyRead, this, &FileDownloader::handleReadyData);
QObject::connect(mNetworkReply, &QNetworkReply::finished, this, &FileDownloader::handleDownloadFinished);
if (mDownloadFolder.isEmpty()) {
mDownloadFolder = CoreManager::getInstance()->getSettingsModel()->getDownloadFolder();
emit downloadFolderChanged(mDownloadFolder);
}
// TODO: Deal with connection error like timeout.
Q_ASSERT(!mDestinationFile.isOpen());
mDestinationFile.setFileName(getDownloadFilePath(QDir::cleanPath(mDownloadFolder) + QDir::separator(), mUrl));
if (!mDestinationFile.open(QIODevice::WriteOnly))
emitOutputError();
}
bool FileDownloader::remove () {
return mDestinationFile.exists() && !mDestinationFile.isOpen() && mDestinationFile.remove();
}
void FileDownloader::emitOutputError () {
qWarning() << QStringLiteral("Could not write into `%1` (%2).")
.arg(mDestinationFile.fileName()).arg(mDestinationFile.errorString());
mNetworkReply->abort();
}
void FileDownloader::handleReadyData () {
QByteArray data = mNetworkReply->readAll();
if (mDestinationFile.write(data) == -1)
emitOutputError();
}
void FileDownloader::handleDownloadFinished() {
QNetworkReply::NetworkError error = mNetworkReply->error();
if (error != QNetworkReply::NoError) {
if (error != QNetworkReply::OperationCanceledError)
qWarning() << QStringLiteral("Download of %1 failed: %2")
.arg(mUrl.toString()).arg(mNetworkReply->errorString());
mDestinationFile.remove();
emit downloadFailed();
} else {
// TODO: Deal with redirection.
if (isHttpRedirect(mNetworkReply)) {
qWarning() << QStringLiteral("Request was redirected.");
mDestinationFile.remove();
emit downloadFailed();
} else {
mDestinationFile.close();
emit downloadFinished(mDestinationFile.fileName());
}
}
mNetworkReply->deleteLater();
setDownloading(false);
}
void FileDownloader::handleSslErrors (const QList<QSslError> &sslErrors) {
#if QT_CONFIG(ssl)
for (const QSslError &error : sslErrors)
qWarning() << QStringLiteral("SSL error: %1").arg(error.errorString());
#else
Q_UNUSED(sslErrors);
#endif
}
void FileDownloader::handleDownloadProgress (qint64 readBytes, qint64 totalBytes) {
setReadBytes(readBytes);
setTotalBytes(totalBytes);
}
// -----------------------------------------------------------------------------
QUrl FileDownloader::getUrl () const {
return mUrl;
}
void FileDownloader::setUrl (const QUrl &url) {
if (mDownloading) {
qWarning() << QStringLiteral("Unable to set url, a file is downloading.");
return;
}
if (mUrl != url) {
mUrl = url;
emit urlChanged(mUrl);
}
}
QString FileDownloader::getDownloadFolder () const {
return mDownloadFolder;
}
void FileDownloader::setDownloadFolder (const QString &downloadFolder) {
if (mDownloading) {
qWarning() << QStringLiteral("Unable to set download folder, a file is downloading.");
return;
}
if (mDownloadFolder != downloadFolder) {
mDownloadFolder = downloadFolder;
emit downloadFolderChanged(mDownloadFolder);
}
}
qint64 FileDownloader::getReadBytes () const {
return mReadBytes;
}
void FileDownloader::setReadBytes (qint64 readBytes) {
if (mReadBytes != readBytes) {
mReadBytes = readBytes;
emit readBytesChanged(readBytes);
}
}
qint64 FileDownloader::getTotalBytes () const {
return mTotalBytes;
}
void FileDownloader::setTotalBytes (qint64 totalBytes) {
if (mTotalBytes != totalBytes) {
mTotalBytes = totalBytes;
emit totalBytesChanged(totalBytes);
}
}
bool FileDownloader::getDownloading () const {
return mDownloading;
}
void FileDownloader::setDownloading (bool downloading) {
if (mDownloading != downloading) {
mDownloading = downloading;
emit downloadingChanged(downloading);
}
}
/*
* FileDownloader.hpp
* Copyright (C) 2017-2018 Belledonne Communications, Grenoble, France
*
* This program is free 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 2
* of the License, or (at your option) any later version.
*
* This program 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 this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* Created on: February 6, 2018
* Author: Danmei Chen
*/
#include <QObject>
#include <QtNetwork>
// =============================================================================
class QSslError;
class FileDownloader : public QObject {
Q_OBJECT;
// TODO: Add an error property to use in UI.
Q_PROPERTY(QUrl url READ getUrl WRITE setUrl NOTIFY urlChanged);
Q_PROPERTY(QString downloadFolder READ getDownloadFolder WRITE setDownloadFolder NOTIFY downloadFolderChanged);
Q_PROPERTY(qint64 readBytes READ getReadBytes NOTIFY readBytesChanged);
Q_PROPERTY(qint64 totalBytes READ getTotalBytes NOTIFY totalBytesChanged);
Q_PROPERTY(bool downloading READ getDownloading NOTIFY downloadingChanged);
public:
Q_INVOKABLE void download ();
Q_INVOKABLE bool remove();
signals:
void urlChanged (const QUrl &url);
void downloadFolderChanged (const QString &downloadFolder);
void readBytesChanged (qint64 readBytes);
void totalBytesChanged (qint64 totalBytes);
void downloadingChanged (bool downloading);
void downloadFinished (const QString &filePath);
void downloadFailed();
private:
QUrl getUrl () const;
void setUrl (const QUrl &url);
QString getDownloadFolder () const;
void setDownloadFolder (const QString &downloadFolder);
qint64 getReadBytes () const;
void setReadBytes (qint64 readBytes);
qint64 getTotalBytes () const;
void setTotalBytes (qint64 totalBytes);
bool getDownloading () const;
void setDownloading (bool downloading);
void emitOutputError ();
void handleReadyData ();
void handleDownloadFinished ();
void handleSslErrors (const QList<QSslError> &errors);
void handleDownloadProgress (qint64 readBytes, qint64 totalBytes);
QUrl mUrl;
QString mDownloadFolder;
QFile mDestinationFile;
qint64 mReadBytes = 0;
qint64 mTotalBytes = 0;
bool mDownloading = false;
QPointer<QNetworkReply> mNetworkReply;
QNetworkAccessManager mManager;
};
......@@ -20,7 +20,9 @@
* Author: Ronan Abhamon
*/
#include <mz_os.h>
#include <mz_strm_bzip.h>
#include <mz_strm.h>
#include <mz.h>
#include <QDebug>
#include <QDir>
......@@ -31,17 +33,61 @@
using namespace std;
static int openMinizipStream (void **stream, const char *filePath) {
*stream = nullptr;
if (!mz_stream_bzip_create(stream))
class FileExtractor::ExtractStream {
public:
ExtractStream () : mFileStream(nullptr), mBzipStream(nullptr) {}
~ExtractStream () {
if (mBzipStream) {
mz_stream_bzip_close(mBzipStream);
mz_stream_bzip_delete(&mBzipStream);
}
if (mFileStream) {
mz_stream_os_close(mFileStream);
mz_stream_os_delete(&mFileStream);
}
}
void *getInternalStream () const {
return mBzipStream;
}
int load (const char *filePath) {
Q_ASSERT(!mFileStream);
Q_ASSERT(!mBzipStream);
// 1. Open file stream.
if (!mz_stream_os_create(&mFileStream))
return MZ_MEM_ERROR;
Q_CHECK_PTR(*stream);
qInfo() << QStringLiteral("Opening `%1`...").arg(filePath);
return mz_stream_bzip_open(*stream, filePath, MZ_OPEN_MODE_READ);
}
Q_CHECK_PTR(mFileStream);
int error;
if ((error = mz_stream_os_open(mFileStream, filePath, MZ_OPEN_MODE_READ)) != MZ_OK)
return error;
// 2. Open bzip stream.
if (!mz_stream_bzip_create(&mBzipStream))
return MZ_MEM_ERROR;
Q_CHECK_PTR(mBzipStream);
if ((error = mz_stream_bzip_open(mBzipStream, NULL, MZ_OPEN_MODE_READ)) != MZ_OK)
return error;
// 3. Link file stream to bzip stream.
return mz_stream_set_base(mBzipStream, mFileStream);
}
private:
void *mFileStream;
void *mBzipStream;
};
// -----------------------------------------------------------------------------
FileExtractor::FileExtractor (QObject *parent) : QObject(parent) {}
FileExtractor::~FileExtractor () {}
void FileExtractor::extract () {
if (mExtracting) {
qWarning() << "Unable to extract file. Already extracting!";
......@@ -56,7 +102,9 @@ void FileExtractor::extract () {
// 1. Open archive stream.
// TODO: Test extension.
int error = openMinizipStream(&mStream, mFile.toLatin1().constData());
Q_ASSERT(!mStream);
mStream.reset(new ExtractStream());
int error = mStream->load(mFile.toLatin1().constData());
if (error != MZ_OK) {
emitExtractFailed(error);
return;
......@@ -145,10 +193,15 @@ void FileExtractor::setExtracting (bool extracting) {
}
void FileExtractor::clean () {
mz_stream_bzip_delete(&mStream);
mStream.reset(nullptr);
mDestinationFile.close();
if (mTimer) {
mTimer->stop();
mTimer->deleteLater();
mTimer = nullptr;
}
setExtracting(false);
}
......@@ -175,14 +228,19 @@ void FileExtractor::emitOutputError () {
void FileExtractor::handleExtraction () {
char buffer[4096];
int32_t readBytes = mz_stream_bzip_read(mStream, buffer, sizeof buffer);
void *stream = mStream.data()->getInternalStream();
int32_t readBytes = mz_stream_bzip_read(stream, buffer, sizeof buffer);
if (readBytes == 0)
emitExtractFinished();
else if (readBytes < 0)
emitExtractFailed(readBytes);
else {
setReadBytes(mReadBytes + readBytes);
if (mDestinationFile.write(buffer, sizeof buffer) == -1)
int64_t inputReadBytes;
mz_stream_bzip_get_prop_int64(stream, MZ_STREAM_PROP_TOTAL_IN, &inputReadBytes);
setReadBytes(inputReadBytes);
if (mDestinationFile.write(buffer, readBytes) == -1)
emitOutputError();
}
}
......@@ -30,8 +30,12 @@
// Supports only bzip file.
class FileExtractor : public QObject {
class ExtractStream;
Q_OBJECT;
// TODO: Add an error property to use in UI.
Q_PROPERTY(QString file READ getFile WRITE setFile NOTIFY fileChanged);
Q_PROPERTY(QString extractFolder READ getExtractFolder WRITE setExtractFolder NOTIFY extractFolderChanged);
Q_PROPERTY(qint64 readBytes READ getReadBytes NOTIFY readBytesChanged);
......@@ -39,6 +43,9 @@ class FileExtractor : public QObject {
Q_PROPERTY(bool extracting READ getExtracting NOTIFY extractingChanged);
public:
FileExtractor (QObject *parent = nullptr);
~FileExtractor ();
Q_INVOKABLE void extract ();
signals:
......@@ -82,7 +89,7 @@ private:
qint64 mTotalBytes = 0;
bool mExtracting = false;
void *mStream = nullptr;
QScopedPointer<ExtractStream> mStream;
QTimer *mTimer = nullptr;
};
......
......@@ -41,12 +41,12 @@
#endif // ifndef UTILS_NO_BREAK
namespace Utils {
inline QString coreStringToAppString (const std::string &string) {
return QString::fromLocal8Bit(string.c_str(), int(string.size()));
inline QString coreStringToAppString (const std::string &str) {
return QString::fromLocal8Bit(str.c_str(), int(str.size()));
}
inline std::string appStringToCoreString (const QString &string) {
return string.toLocal8Bit().constData();
inline std::string appStringToCoreString (const QString &str) {
return qPrintable(str);
}
// Reverse function of strstr.
......
......@@ -4,7 +4,6 @@ import QtQuick.Layouts 1.3
import Common 1.0
import Common.Styles 1.0
import Linphone 1.0
import Utils 1.0
import 'ComboBox.js' as Logic
......
......@@ -20,10 +20,10 @@ function attachVirtualWindow (component, properties, exitStatusHandler) {
properties: properties
})
object.exitStatus.connect(detachVirtualWindow)
if (exitStatusHandler) {
object.exitStatus.connect(exitStatusHandler)
}
object.exitStatus.connect(detachVirtualWindow)
virtualWindow.setContent(object)
......
......@@ -212,6 +212,8 @@ Row {
color: ChatStyle.entry.message.file.status.bar.contentItem.color
height: parent.height
width: progressBar.visualPosition * parent.width
radius: ChatStyle.entry.message.file.status.bar.radius
}
}
}
......
......@@ -8,8 +8,16 @@ import Linphone.Styles 1.0
// =============================================================================
Column {
id: codecsViewer
// ---------------------------------------------------------------------------
property alias model: view.model
// ---------------------------------------------------------------------------
signal downloadRequested (var codecInfo)
// ---------------------------------------------------------------------------
// Header.
// ---------------------------------------------------------------------------
......@@ -75,9 +83,9 @@ Column {
height: count * CodecsViewerStyle.attribute.height
// -----------------------------------------------------------------------
// -------------------------------------------------------------------------
// One codec.
// -----------------------------------------------------------------------
// -------------------------------------------------------------------------
delegate: MouseArea {
id: dragArea
......@@ -110,6 +118,8 @@ Column {
Rectangle {
id: content
readonly property bool isDownloadable: Boolean($codec.downloadUrl)
Drag.active: dragArea.held
Drag.source: dragArea
Drag.hotSpot.x: width / 2
......@@ -139,25 +149,26 @@ Column {
CodecAttribute {
Layout.preferredWidth: CodecsViewerStyle.column.encoderDescriptionWidth
text: $codec.encoderDescription
text: $codec.encoderDescription || ''
}
CodecAttribute {
Layout.preferredWidth: CodecsViewerStyle.column.clockRateWidth
text: $codec.clockRate
text: $codec.clockRate || ''
}
NumericField {
Layout.preferredWidth: CodecsViewerStyle.column.bitrateWidth
readOnly: !$codec.isVbr
text: $codec.bitrate
readOnly: content.isDownloadable || !$codec.isVbr
text: $codec.bitrate || ''
onEditingFinished: view.model.setBitrate(index, text)
}
TextField {
Layout.preferredWidth: CodecsViewerStyle.column.recvFmtpWidth
text: $codec.recvFmtp
readOnly: content.isDownloadable
text: $codec.recvFmtp || ''
onEditingFinished: view.model.setRecvFmtp(index, text)
}
......@@ -165,9 +176,11 @@ Column {
Switch {
Layout.fillWidth: true
checked: $codec.enabled
checked: Boolean($codec.enabled)
onClicked: view.model.enableCodec(index, !checked)
onClicked: !checked && content.isDownloadable
? downloadRequested($codec)
: view.model.enableCodec(index, !checked)
}
}
}
......
import QtQuick 2.7
import QtQuick.Controls 2.2
import Common 1.0
import Linphone 1.0
import Linphone.Styles 1.0
import Utils 1.0
// =============================================================================
DialogPlus {
id: dialog
// ---------------------------------------------------------------------------
property alias downloadUrl: fileDownloader.url
property alias installFolder: fileDownloader.downloadFolder
property bool extract: false
property string fileName
property bool _installing: false
property int _exitStatus: -1 // Not downloaded for the moment.
// ---------------------------------------------------------------------------
function install () {
dialog._installing = true
fileDownloader.download()
}
function _endInstall (exitStatus) {
if (dialog.extract)
fileDownloader.remove()
dialog._exitStatus = exitStatus
dialog._installing = false
}
// ---------------------------------------------------------------------------
// TODO: Improve one day. Do not launch download directly.
// Provide a download function (window.attachVirtualWindow cannot call
// function after creation at this moment).
Component.onCompleted: dialog.install()
// ---------------------------------------------------------------------------
buttons: [
// TODO: Add a retry button???
TextButtonB {
enabled: !dialog._installing && !fileDownloader.downloading && !fileExtractor.extracting
text: qsTr('confirm')
onClicked: exit(1)
}
]
centeredButtons: true
descriptionText: {
var str
if (dialog.extracting) {
str = qsTr('onlineInstallerExtractingDescription')
} else if (dialog._installing) {
str = qsTr('onlineInstallerDownloadingDescription')
} else if (dialog._exitStatus > 0) {
str = qsTr('onlineInstallerFinishedDescription')
} else {
str = qsTr('onlineInstallerFailedDescription')
}
return str.replace('%1', dialog.fileName)
}
height: OnlineInstallerDialogStyle.height
width: OnlineInstallerDialogStyle.width
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
spacing: OnlineInstallerDialogStyle.column.spacing
ProgressBar {
id: progressBar
property var target: fileDownloader
height: OnlineInstallerDialogStyle.column.bar.height
width: parent.width
to: target.totalBytes
value: target.readBytes
indeterminate : true
background: Rectangle {
color: OnlineInstallerDialogStyle.column.bar.background.color
radius: OnlineInstallerDialogStyle.column.bar.radius
}
contentItem: Item {
Rectangle {
color: dialog._exitStatus
? OnlineInstallerDialogStyle.column.bar.contentItem.color.normal
: OnlineInstallerDialogStyle.column.bar.contentItem.color.failed
height: parent.height
radius: OnlineInstallerDialogStyle.column.bar.radius
width: progressBar.visualPosition * parent.width
}
}
}
Text {
anchors.right: parent.right
color: OnlineInstallerDialogStyle.column.text.color
font.pointSize: OnlineInstallerDialogStyle.column.text.pointSize
text: {
var fileSize = Utils.formatSize(fileDownloader.totalBytes)
return Utils.formatSize(fileDownloader.readBytes) + '/' + fileSize
}
}
FileDownloader {
id: fileDownloader
onDownloadFailed: dialog._endInstall(0)
onDownloadFinished: {
fileExtractor.file = filePath
if (dialog.extract) {
progressBar.target = fileExtractor
fileExtractor.extract()
} else {
dialog._endInstall(1)
}
}
}
FileExtractor {
id: fileExtractor
extractFolder: dialog.installFolder
onExtractFailed: dialog._endInstall(0)
onExtractFinished: dialog._endInstall(1)
}
}
}
pragma Singleton
import QtQml 2.2
import Colors 1.0
import Units 1.0
// =============================================================================
QtObject {
property int height: 200
property int width: 400
property QtObject column: QtObject {
property int spacing: 6
property QtObject bar: QtObject {
property int height: 20
property int radius: 6
property QtObject background: QtObject {
property color color: Colors.f
}
property QtObject contentItem: QtObject {
property QtObject color: QtObject {
property color failed: Colors.error
property color normal: Colors.z
}
}
}
property QtObject text: QtObject {
property color color: Colors.r
property int pointSize: Units.dp * 11
}
}
}
......@@ -23,6 +23,8 @@ singleton ContactDescriptionStyle 1.0 Contact/ContactDescriptionSty
singleton ContactStyle 1.0 Contact/ContactStyle.qml
singleton MessagesCounterStyle 1.0 Contact/MessagesCounterStyle.qml
singleton OnlineInstallerDialogStyle 1.0 Dialog/OnlineInstallerDialogStyle.qml
singleton SipAddressesMenuStyle 1.0 Menus/SipAddressesMenuStyle.qml
singleton NotificationBasicStyle 1.0 Notifications/NotificationBasicStyle.qml
......
......@@ -4,8 +4,12 @@
.pragma library
.import Linphone 1.0 as Linphone
.import 'qrc:/ui/scripts/Utils/utils.js' as Utils
// =============================================================================
// Contact/SIP address helpers.
// =============================================================================
function _getDisplayNameFromQuotedString (str) {
......@@ -84,3 +88,43 @@ function getContactUsername (contact) {
name = _getUsername(object)
return name == null ? 'Bad EGG' : name
}
// =============================================================================
// Codec helpers.
// =============================================================================
function openCodecOnlineInstallerDialog (window, codecInfo, cb) {
var VideoCodecsModel = Linphone.VideoCodecsModel
window.attachVirtualWindow(Utils.buildDialogUri('ConfirmDialog'), {
descriptionText: qsTr('downloadCodecDescription')
.replace('%1', codecInfo.mime)
.replace('%2', codecInfo.encoderDescription)
}, function (status) {
if (status) {
window.attachVirtualWindow(buildDialogUri('OnlineInstallerDialog'), {
downloadUrl: codecInfo.downloadUrl,
extract: true,
fileName: codecInfo.mime,
installFolder: VideoCodecsModel.codecsFolder
}, function (status) {
if (status) {
VideoCodecsModel.reload()
}
if (cb) {
cb(window)
}
})
}
else if (cb) {
cb(window)
}
})
}
// =============================================================================
// QML helpers.
// =============================================================================
function buildDialogUri (component) {
return 'qrc:/ui/modules/Linphone/Dialog/' + component + '.qml'
}
......@@ -2,6 +2,7 @@ import QtQuick 2.7
import Common 1.0
import Linphone 1.0
import LinphoneUtils 1.0
import App.Styles 1.0
......@@ -50,9 +51,17 @@ AssistantAbstractView {
onActivateStatusChanged: {
requestBlock.stop(error)
if (!error.length) {
function quitToHome (window) {
window.unlockView()
window.setView('Home')
}
var codecInfo = VideoCodecsModel.getCodecInfo('H264')
if (codecInfo.downloadUrl) {
LinphoneUtils.openCodecOnlineInstallerDialog(window, codecInfo, quitToHome)
} else {
quitToHome(window)
}
}
}
}
}
......@@ -2,6 +2,7 @@ import QtQuick 2.7
import Common 1.0
import Linphone 1.0
import LinphoneUtils 1.0
import App.Styles 1.0
......@@ -62,9 +63,17 @@ AssistantAbstractView {
onActivateStatusChanged: {
requestBlock.stop(error)
if (!error.length) {
function quitToHome (window) {
window.unlockView()
window.setView('Home')
}
var codecInfo = VideoCodecsModel.getCodecInfo('H264')
if (codecInfo.downloadUrl) {
LinphoneUtils.openCodecOnlineInstallerDialog(window, codecInfo, quitToHome)
} else {
quitToHome(window)
}
}
}
}
}
......@@ -2,6 +2,7 @@ import QtQuick 2.7
import Common 1.0
import Linphone 1.0
import LinphoneUtils 1.0
import App.Styles 1.0
......@@ -17,6 +18,8 @@ AssistantAbstractView {
title: qsTr('useLinphoneSipAccountTitle')
// ---------------------------------------------------------------------------
Column {
......@@ -86,7 +89,14 @@ AssistantAbstractView {
onLoginStatusChanged: {
requestBlock.stop(error)
if (!error.length) {
var codecInfo = VideoCodecsModel.getCodecInfo('H264')
if (codecInfo.downloadUrl) {
LinphoneUtils.openCodecOnlineInstallerDialog(window, codecInfo, function cb (window) {
window.setView('Home')
})
} else {
window.setView('Home')
}
}
}
......
......@@ -4,6 +4,8 @@
.import Linphone 1.0 as Linphone
.import 'qrc:/ui/scripts/LinphoneUtils/linphone-utils.js' as LinphoneUtils
// =============================================================================
function showVideoPreview (account) {
......@@ -23,3 +25,7 @@ function updateVideoPreview () {
function hideVideoPreview () {
window.detachVirtualWindow()
}
function handleCodecDownloadRequested (codecInfo) {
LinphoneUtils.openCodecOnlineInstallerDialog(window, codecInfo)
}
......@@ -142,6 +142,8 @@ TabContainer {
CodecsViewer {
model: VideoCodecsModel
width: parent.width
onDownloadRequested: Logic.handleCodecDownloadRequested(codecInfo)
}
}
}
......
......@@ -12,7 +12,6 @@ import App.Styles 1.0
ApplicationWindow {
id: window
minimumHeight: SettingsWindowStyle.height
minimumWidth: SettingsWindowStyle.width
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment