/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-desktop
* (see https://www.linphone.org).
*
* 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 3 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, see .
*/
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "app/App.hpp"
#include "app/paths/Paths.hpp"
#include "app/providers/ThumbnailProvider.hpp"
#include "components/core/CoreHandlers.hpp"
#include "components/core/CoreManager.hpp"
#include "components/notifier/Notifier.hpp"
#include "components/settings/SettingsModel.hpp"
#include "utils/QExifImageHeader.hpp"
#include "utils/Utils.hpp"
#include "ChatModel.hpp"
// =============================================================================
using namespace std;
namespace {
constexpr int ThumbnailImageFileWidth = 100;
constexpr int ThumbnailImageFileHeight = 100;
// In Bytes.
constexpr qint64 FileSizeLimit = 524288000;
}
// MessageAppData is using to parse what's it in Appdata field of a message
class MessageAppData
{
public:
MessageAppData(){}
MessageAppData(const QString&);
QString m_id;
QString m_path;
QString toString()const;
void fromString(const QString& );
static QString toString(const QVector& );
static QVector fromListString(const QString& );
};
MessageAppData::MessageAppData(const QString& p_data)
{
fromString(p_data);
}
QString MessageAppData::toString()const
{
return m_id+':'+m_path;
}
void MessageAppData::fromString(const QString& p_data)
{
QStringList fields = p_data.split(':');
if( fields.size() > 1)
{
m_id = fields[0];
m_path = fields[1];
}
}
QString MessageAppData::toString(const QVector& p_data)
{
QString serialization;
if( p_data.size() > 0)
{
serialization = p_data[0].toString();
for(int i = 1 ; i < p_data.size() ; ++i)
serialization += ';'+p_data[i].toString();
}
return serialization;
}
QVector MessageAppData::fromListString(const QString& p_data)
{
QVector data;
QStringList files = p_data.split(";");
for(int i = 0 ; i < files.size() ; ++i)
data.push_back(MessageAppData(files[i]));
return data;
}
// There is only one file (thumbnail) in appdata
static inline MessageAppData getMessageAppData (const shared_ptr &message) {
return MessageAppData(QString::fromStdString(message->getAppdata()));
}
static inline bool fileWasDownloaded (const shared_ptr &message) {
const MessageAppData appData = getMessageAppData(message);
return !appData.m_path.isEmpty() && QFileInfo(appData.m_path).isFile();
}
// Set the thumbnail as the first content
static inline void fillThumbnailProperty (QVariantMap &dest, const shared_ptr &message) {
if( !dest.contains("thumbnail"))
{
MessageAppData thumbnailData = getMessageAppData(message);
if( thumbnailData.m_id != "")
dest["thumbnail"] = QStringLiteral("image://%1/%2").arg(ThumbnailProvider::ProviderId).arg(thumbnailData.m_id);
}
}
// Create a thumbnail from the first content that have a file and store it in Appdata
static inline void createThumbnail (const shared_ptr &message) {
if (!message->getAppdata().empty())
return;// Already exist : no need to create one
std::list > contents = message->getContents();
if( contents.size() > 0)
{
MessageAppData thumbnailData;
thumbnailData.m_path = QString::fromStdString(contents.front()->getFilePath());
QImage image(thumbnailData.m_path);
if (image.isNull())
return;
int rotation = 0;
QExifImageHeader exifImageHeader;
if (exifImageHeader.loadFromJpeg(thumbnailData.m_path))
rotation = int(exifImageHeader.value(QExifImageHeader::ImageTag::Orientation).toShort());
QImage thumbnail = image.scaled(
ThumbnailImageFileWidth, ThumbnailImageFileHeight,
Qt::KeepAspectRatio, Qt::SmoothTransformation
);
if (rotation != 0) {
QTransform transform;
if (rotation == 3 || rotation == 4)
transform.rotate(180);
else if (rotation == 5 || rotation == 6)
transform.rotate(90);
else if (rotation == 7 || rotation == 8)
transform.rotate(-90);
thumbnail = thumbnail.transformed(transform);
if (rotation == 2 || rotation == 4 || rotation == 5 || rotation == 7)
thumbnail = thumbnail.mirrored(true, false);
}
QString uuid = QUuid::createUuid().toString();
thumbnailData.m_id = QStringLiteral("%1.jpg").arg(uuid.mid(1, uuid.length() - 2));
if (!thumbnail.save(Utils::coreStringToAppString(Paths::getThumbnailsDirPath()) + thumbnailData.m_id , "jpg", 100)) {
qWarning() << QStringLiteral("Unable to create thumbnail of: `%1`.").arg(thumbnailData.m_path);
return;
}
message->setAppdata(thumbnailData.toString().toStdString());
}
}
static inline void removeFileMessageThumbnail (const shared_ptr &message) {
if (message && message->getFileTransferInformation()) {
message->cancelFileTransfer();
MessageAppData thumbnailFile = getMessageAppData(message);
if(thumbnailFile.m_id.size() > 0)
{
QString thumbnailPath = QString::fromStdString(Paths::getThumbnailsDirPath()) + thumbnailFile.m_id;
if (!QFile::remove(thumbnailPath))
qWarning() << QStringLiteral("Unable to remove `%1`.").arg(thumbnailPath);
}
message->setAppdata("");// Remove completly Thumbnail from the message
}
}
// -----------------------------------------------------------------------------
static inline void fillMessageEntry (QVariantMap &dest, const shared_ptr &message) {
dest["content"] = Utils::coreStringToAppString(message->getTextContent());
dest["isOutgoing"] = message->isOutgoing() || message->getState() == linphone::ChatMessage::State::Idle;
// Old workaround.
// It can exist messages with a not delivered status. It's a linphone core bug.
linphone::ChatMessage::State state = message->getState();
if (state == linphone::ChatMessage::State::InProgress)
dest["status"] = ChatModel::MessageStatusNotDelivered;
else
dest["status"] = static_cast(message->getState());
shared_ptr content = message->getFileTransferInformation();
if (content) {
dest["fileSize"] = quint64(content->getFileSize());
dest["fileName"] = Utils::coreStringToAppString(content->getName());
dest["wasDownloaded"] = ::fileWasDownloaded(message);
fillThumbnailProperty(dest, message);
}
}
static inline void fillCallStartEntry (QVariantMap &dest, const shared_ptr &callLog) {
dest["type"] = ChatModel::CallEntry;
dest["timestamp"] = QDateTime::fromMSecsSinceEpoch(callLog->getStartDate() * 1000);
dest["isOutgoing"] = callLog->getDir() == linphone::Call::Dir::Outgoing;
dest["status"] = static_cast(callLog->getStatus());
dest["isStart"] = true;
}
static inline void fillCallEndEntry (QVariantMap &dest, const shared_ptr &callLog) {
dest["type"] = ChatModel::CallEntry;
dest["timestamp"] = QDateTime::fromMSecsSinceEpoch((callLog->getStartDate() + callLog->getDuration()) * 1000);
dest["isOutgoing"] = callLog->getDir() == linphone::Call::Dir::Outgoing;
dest["status"] = static_cast(callLog->getStatus());
dest["isStart"] = false;
}
// -----------------------------------------------------------------------------
class ChatModel::MessageHandlers : public linphone::ChatMessageListener {
friend class ChatModel;
public:
MessageHandlers (ChatModel *chatModel) : mChatModel(chatModel) {}
private:
QList::iterator findMessageEntry (const shared_ptr &message) {
return find_if(mChatModel->mEntries.begin(), mChatModel->mEntries.end(), [&message](const ChatEntryData &entry) {
return entry.second == message;
});
}
void signalDataChanged (const QList::iterator &it) {
int row = int(distance(mChatModel->mEntries.begin(), it));
emit mChatModel->dataChanged(mChatModel->index(row, 0), mChatModel->index(row, 0));
}
shared_ptr onFileTransferSend (
const shared_ptr &,
const shared_ptr &,
size_t,
size_t
) override {
qWarning() << "`onFileTransferSend` called.";
return nullptr;
}
void onFileTransferProgressIndication (
const shared_ptr &message,
const shared_ptr &,
size_t offset,
size_t
) override {
if (!mChatModel)
return;
auto it = findMessageEntry(message);
if (it == mChatModel->mEntries.end())
return;
(*it).first["fileOffset"] = quint64(offset);
signalDataChanged(it);
}
void onMsgStateChanged (const shared_ptr &message, linphone::ChatMessage::State state) override {
if (!mChatModel)
return;
auto it = findMessageEntry(message);
if (it == mChatModel->mEntries.end())
return;
// File message downloaded.
if (state == linphone::ChatMessage::State::FileTransferDone && !message->isOutgoing()) {
createThumbnail(message);
fillThumbnailProperty((*it).first, message);
(*it).first["wasDownloaded"] = true;
App::getInstance()->getNotifier()->notifyReceivedFileMessage(message);
}
(*it).first["status"] = static_cast(state);
signalDataChanged(it);
}
ChatModel *mChatModel;
};
// -----------------------------------------------------------------------------
ChatModel::ChatModel (const QString &peerAddress, const QString &localAddress) {
CoreManager *coreManager = CoreManager::getInstance();
mCoreHandlers = coreManager->getHandlers();
mMessageHandlers = make_shared(this);
setSipAddresses(peerAddress, localAddress);
{
CoreHandlers *coreHandlers = mCoreHandlers.get();
QObject::connect(coreHandlers, &CoreHandlers::messageReceived, this, &ChatModel::handleMessageReceived);
QObject::connect(coreHandlers, &CoreHandlers::callStateChanged, this, &ChatModel::handleCallStateChanged);
QObject::connect(coreHandlers, &CoreHandlers::isComposingChanged, this, &ChatModel::handleIsComposingChanged);
}
}
ChatModel::~ChatModel () {
mMessageHandlers->mChatModel = nullptr;
}
QHash ChatModel::roleNames () const {
QHash roles;
roles[Roles::ChatEntry] = "$chatEntry";
roles[Roles::SectionDate] = "$sectionDate";
return roles;
}
int ChatModel::rowCount (const QModelIndex &) const {
return mEntries.count();
}
QVariant ChatModel::data (const QModelIndex &index, int role) const {
int row = index.row();
if (!index.isValid() || row < 0 || row >= mEntries.count())
return QVariant();
switch (role) {
case Roles::ChatEntry: {
auto &data = mEntries[row].first;
if (!data.contains("status"))
fillMessageEntry(data, static_pointer_cast(mEntries[row].second));
return QVariant::fromValue(data);
}
case Roles::SectionDate:
return QVariant::fromValue(mEntries[row].first["timestamp"].toDate());
}
return QVariant();
}
bool ChatModel::removeRow (int row, const QModelIndex &) {
return removeRows(row, 1);
}
bool ChatModel::removeRows (int row, int count, const QModelIndex &parent) {
int limit = row + count - 1;
if (row < 0 || count < 0 || limit >= mEntries.count())
return false;
beginRemoveRows(parent, row, limit);
for (int i = 0; i < count; ++i) {
removeEntry(mEntries[row]);
mEntries.removeAt(row);
}
endRemoveRows();
if (mEntries.count() == 0)
emit allEntriesRemoved();
else if (limit == mEntries.count())
emit lastEntryRemoved();
return true;
}
QString ChatModel::getPeerAddress () const {
return Utils::coreStringToAppString(
mChatRoom->getPeerAddress()->asStringUriOnly()
);
}
QString ChatModel::getLocalAddress () const {
return Utils::coreStringToAppString(
mChatRoom->getLocalAddress()->asStringUriOnly()
);
}
void ChatModel::setSipAddresses (const QString &peerAddress, const QString &localAddress) {
shared_ptr core = CoreManager::getInstance()->getCore();
shared_ptr factory(linphone::Factory::get());
mChatRoom = core->getChatRoom(
factory->createAddress(Utils::appStringToCoreString(peerAddress)),
factory->createAddress(Utils::appStringToCoreString(localAddress))
);
Q_ASSERT(mChatRoom);
handleIsComposingChanged(mChatRoom);
// Get messages.
mEntries.clear();
QElapsedTimer timer;
timer.start();
for (auto &message : mChatRoom->getHistory(0))
mEntries << qMakePair(
QVariantMap{
{ "type", EntryType::MessageEntry },
{ "timestamp", QDateTime::fromMSecsSinceEpoch(message->getTime() * 1000) }
},
static_pointer_cast(message)
);
// Get calls.
for (auto &callLog : core->getCallHistory(mChatRoom->getPeerAddress(), mChatRoom->getLocalAddress()))
insertCall(callLog);
qInfo() << QStringLiteral("ChatModel (%1, %2) loaded in %3 milliseconds.")
.arg(peerAddress).arg(localAddress).arg(timer.elapsed());
}
bool ChatModel::getIsRemoteComposing () const {
return mIsRemoteComposing;
}
// -----------------------------------------------------------------------------
void ChatModel::removeEntry (int id) {
qInfo() << QStringLiteral("Removing chat entry: %1 of (%2, %3).")
.arg(id).arg(getPeerAddress()).arg(getLocalAddress());
if (!removeRow(id))
qWarning() << QStringLiteral("Unable to remove chat entry: %1").arg(id);
}
void ChatModel::removeAllEntries () {
qInfo() << QStringLiteral("Removing all chat entries of: (%1, %2).")
.arg(getPeerAddress()).arg(getLocalAddress());
beginResetModel();
for (auto &entry : mEntries)
removeEntry(entry);
mEntries.clear();
endResetModel();
emit allEntriesRemoved();
}
// -----------------------------------------------------------------------------
void ChatModel::sendMessage (const QString &message) {
shared_ptr _message = mChatRoom->createMessage(Utils::appStringToCoreString(message));
_message->addListener(mMessageHandlers);
insertMessageAtEnd(_message);
mChatRoom->sendChatMessage(_message);
emit messageSent(_message);
}
void ChatModel::resendMessage (int id) {
if (id < 0 || id > mEntries.count()) {
qWarning() << QStringLiteral("Entry %1 not exists.").arg(id);
return;
}
const ChatEntryData entry = mEntries[id];
const QVariantMap map = entry.first;
if (map["type"] != EntryType::MessageEntry) {
qWarning() << QStringLiteral("Unable to resend entry %1. It's not a message.").arg(id);
return;
}
switch (map["status"].toInt()) {
case MessageStatusFileTransferError:
case MessageStatusNotDelivered: {
shared_ptr message = static_pointer_cast(entry.second);
message->addListener(mMessageHandlers);
message->resend();
break;
}
default:
qWarning() << QStringLiteral("Unable to resend message: %1. Bad state.").arg(id);
}
}
void ChatModel::sendFileMessage (const QString &path) {
QFile file(path);
if (!file.exists())
return;
qint64 fileSize = file.size();
if (fileSize > FileSizeLimit) {
qWarning() << QStringLiteral("Unable to send file. (Size limit=%1)").arg(FileSizeLimit);
return;
}
shared_ptr content = CoreManager::getInstance()->getCore()->createContent();
{
QStringList mimeType = QMimeDatabase().mimeTypeForFile(path).name().split('/');
if (mimeType.length() != 2) {
qWarning() << QStringLiteral("Unable to get supported mime type for: `%1`.").arg(path);
return;
}
content->setType(Utils::appStringToCoreString(mimeType[0]));
content->setSubtype(Utils::appStringToCoreString(mimeType[1]));
}
content->setSize(size_t(fileSize));
content->setName(Utils::appStringToCoreString(QFileInfo(file).fileName()));
shared_ptr message = mChatRoom->createFileTransferMessage(content);
message->getContents().front()->setFilePath(path.toStdString());// Sending only one File Path?
message->addListener(mMessageHandlers);
createThumbnail(message);
insertMessageAtEnd(message);
mChatRoom->sendChatMessage(message);
emit messageSent(message);
}
// -----------------------------------------------------------------------------
void ChatModel::downloadFile (int id) {
const ChatEntryData entry = getFileMessageEntry(id);
if (!entry.second)
return;
shared_ptr message = static_pointer_cast(entry.second);
switch (static_cast(message->getState())) {
case MessageStatusDelivered:
case MessageStatusDeliveredToUser:
case MessageStatusDisplayed:
case MessageStatusFileTransferDone:
break;
default:
qWarning() << QStringLiteral("Unable to download file of entry %1. It was not uploaded.").arg(id);
return;
}
bool soFarSoGood;
const QString safeFilePath = Utils::getSafeFilePath(
QStringLiteral("%1%2")
.arg(CoreManager::getInstance()->getSettingsModel()->getDownloadFolder())
.arg(entry.first["fileName"].toString()),
&soFarSoGood
);
if (!soFarSoGood) {
qWarning() << QStringLiteral("Unable to create safe file path for: %1.").arg(id);
return;
}
message->addListener(mMessageHandlers);
message->getContents().front()->setFilePath(safeFilePath.toStdString());
if( !message->isFileTransfer())
QMessageBox::warning(nullptr, "Download File", "This file was already downloaded and is no more on the server. Your peer have to resend it if you want to get it");
else
{
if (!message->downloadContent(message->getFileTransferInformation()))
qWarning() << QStringLiteral("Unable to download file of entry %1.").arg(id);
}
}
void ChatModel::openFile (int id, bool showDirectory) {
const ChatEntryData entry = getFileMessageEntry(id);
if (!entry.second)
return;
shared_ptr message = static_pointer_cast(entry.second);
if (!::fileWasDownloaded(message)) {
downloadFile(id);
return;
}
QFileInfo info(getMessageAppData(message).m_path);
QDesktopServices::openUrl(
QUrl(QStringLiteral("file:///%1").arg(showDirectory ? info.absolutePath() : info.absoluteFilePath()))
);
}
bool ChatModel::fileWasDownloaded (int id) {
const ChatEntryData entry = getFileMessageEntry(id);
return entry.second && ::fileWasDownloaded(static_pointer_cast(entry.second));
}
void ChatModel::compose () {
mChatRoom->compose();
}
void ChatModel::resetMessageCount () {
if (mChatRoom->getUnreadMessagesCount() > 0) {
mChatRoom->markAsRead();
emit messageCountReset();
}
}
// -----------------------------------------------------------------------------
const ChatModel::ChatEntryData ChatModel::getFileMessageEntry (int id) {
if (id < 0 || id > mEntries.count()) {
qWarning() << QStringLiteral("Entry %1 not exists.").arg(id);
return ChatEntryData();
}
const ChatEntryData entry = mEntries[id];
if (entry.first["type"] != EntryType::MessageEntry) {
qWarning() << QStringLiteral("Unable to download entry %1. It's not a message.").arg(id);
return ChatEntryData();
}
shared_ptr message = static_pointer_cast(entry.second);
if (!message->getFileTransferInformation()) {
qWarning() << QStringLiteral("Entry %1 is not a file message.").arg(id);
return ChatEntryData();
}
return entry;
}
// -----------------------------------------------------------------------------
void ChatModel::removeEntry (ChatEntryData &entry) {
int type = entry.first["type"].toInt();
switch (type) {
case ChatModel::MessageEntry: {
shared_ptr message = static_pointer_cast(entry.second);
removeFileMessageThumbnail(message);
mChatRoom->deleteMessage(message);
break;
}
case ChatModel::CallEntry: {
if (entry.first["status"].toInt() == CallStatusSuccess) {
// WARNING: Unable to remove symmetric call here. (start/end)
// We are between `beginRemoveRows` and `endRemoveRows`.
// A solution is to schedule a `removeEntry` call in the Qt main loop.
shared_ptr linphonePtr = entry.second;
QTimer::singleShot(0, this, [this, linphonePtr]() {
auto it = find_if(mEntries.begin(), mEntries.end(), [linphonePtr](const ChatEntryData &entry) {
return entry.second == linphonePtr;
});
if (it != mEntries.end())
removeEntry(int(distance(mEntries.begin(), it)));
});
}
CoreManager::getInstance()->getCore()->removeCallLog(static_pointer_cast(entry.second));
break;
}
default:
qWarning() << QStringLiteral("Unknown chat entry type: %1.").arg(type);
}
}
void ChatModel::insertCall (const shared_ptr &callLog) {
linphone::Call::Status status = callLog->getStatus();
switch (status) {
case linphone::Call::Status::Aborted:
case linphone::Call::Status::EarlyAborted:
return; // Ignore aborted calls.
case linphone::Call::Status::AcceptedElsewhere:
case linphone::Call::Status::DeclinedElsewhere:
return; // Ignore accepted calls on other device.
case linphone::Call::Status::Success:
case linphone::Call::Status::Missed:
case linphone::Call::Status::Declined:
break;
}
auto insertEntry = [this](
const ChatEntryData &entry,
const QList::iterator *start = nullptr
) {
auto it = lower_bound(start ? *start : mEntries.begin(), mEntries.end(), entry, [](const ChatEntryData &a, const ChatEntryData &b) {
return a.first["timestamp"] < b.first["timestamp"];
});
int row = int(distance(mEntries.begin(), it));
beginInsertRows(QModelIndex(), row, row);
it = mEntries.insert(it, entry);
endInsertRows();
return it;
};
// Add start call.
QVariantMap start;
fillCallStartEntry(start, callLog);
auto it = insertEntry(qMakePair(start, static_pointer_cast(callLog)));
// Add end call. (if necessary)
if (status == linphone::Call::Status::Success) {
QVariantMap end;
fillCallEndEntry(end, callLog);
insertEntry(qMakePair(end, static_pointer_cast(callLog)), &it);
}
}
void ChatModel::insertMessageAtEnd (const shared_ptr &message) {
int row = mEntries.count();
beginInsertRows(QModelIndex(), row, row);
QVariantMap map{
{ "type", EntryType::MessageEntry },
{ "timestamp", QDateTime::fromMSecsSinceEpoch(message->getTime() * 1000) }
};
fillMessageEntry(map, message);
mEntries << qMakePair(map, static_pointer_cast(message));
endInsertRows();
}
// -----------------------------------------------------------------------------
void ChatModel::handleCallStateChanged (const shared_ptr &call, linphone::Call::State state) {
if (
(state == linphone::Call::State::End || state == linphone::Call::State::Error) &&
mChatRoom == CoreManager::getInstance()->getCore()->findChatRoom(call->getRemoteAddress(), mChatRoom->getLocalAddress())
)
insertCall(call->getCallLog());
}
void ChatModel::handleIsComposingChanged (const shared_ptr &chatRoom) {
if (mChatRoom == chatRoom) {
bool isRemoteComposing = mChatRoom->isRemoteComposing();
if (isRemoteComposing != mIsRemoteComposing) {
mIsRemoteComposing = isRemoteComposing;
emit isRemoteComposingChanged(mIsRemoteComposing);
}
}
}
void ChatModel::handleMessageReceived (const shared_ptr &message) {
if (mChatRoom == message->getChatRoom()) {
insertMessageAtEnd(message);
emit messageReceived(message);
}
}