mirror of
https://git.jami.net/savoirfairelinux/jami-client-qt.git
synced 2025-08-08 00:35:50 +02:00

+ Add call buttons to start a new call + React to events from the swarm + call interactions (Join call/Call ended, etc) + active calls area + Add call management logic in LRC + Feature is enabled via the experimental checkbox https://git.jami.net/savoirfairelinux/jami-daemon/-/issues/312 Change-Id: I83fd20b5e772097c0792bdc66feec69b0cb0009a
1404 lines
52 KiB
C++
1404 lines
52 KiB
C++
/****************************************************************************
|
|
* Copyright (C) 2017-2022 Savoir-faire Linux Inc. *
|
|
* Author: Nicolas Jäger <nicolas.jager@savoirfairelinux.com> *
|
|
* Author: Sébastien Blin <sebastien.blin@savoirfairelinux.com> *
|
|
* Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com> *
|
|
* Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> *
|
|
* *
|
|
* This library is free software; you can redistribute it and/or *
|
|
* modify it under the terms of the GNU Lesser General Public *
|
|
* License as published by the Free Software Foundation; either *
|
|
* version 2.1 of the License, or (at your option) any later version. *
|
|
* *
|
|
* This library 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 *
|
|
* Lesser 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 <http://www.gnu.org/licenses/>. *
|
|
***************************************************************************/
|
|
#include "storagehelper.h"
|
|
|
|
#include "api/profile.h"
|
|
#include "api/conversation.h"
|
|
#include "api/datatransfer.h"
|
|
#include "api/lrc.h"
|
|
#include "uri.h"
|
|
#include "vcard.h"
|
|
|
|
#include <account_const.h>
|
|
#include <datatransfer_interface.h>
|
|
|
|
#include <QImage>
|
|
#include <QByteArray>
|
|
#include <QBuffer>
|
|
#include <QLockFile>
|
|
#include <QJsonObject>
|
|
#include <QJsonDocument>
|
|
|
|
#include <fstream>
|
|
#if !defined(Q_OS_LINUX) || __GNUC__ > 8
|
|
#include <filesystem>
|
|
#endif
|
|
#include <thread>
|
|
#include <cstring>
|
|
|
|
namespace lrc {
|
|
|
|
namespace authority {
|
|
|
|
namespace storage {
|
|
|
|
QString
|
|
getPath()
|
|
{
|
|
#ifdef Q_OS_WIN
|
|
auto definedDataDir = qEnvironmentVariable("JAMI_DATA_HOME");
|
|
if (!definedDataDir.isEmpty())
|
|
return QDir(definedDataDir).absolutePath() + "/";
|
|
#endif
|
|
QDir dataDir(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation));
|
|
// Avoid to depends on the client name.
|
|
dataDir.cdUp();
|
|
return dataDir.absolutePath() + "/jami/";
|
|
}
|
|
|
|
static QString
|
|
profileVcardPath(const QString& accountId, const QString& uri)
|
|
{
|
|
auto accountLocalPath = getPath() + accountId + QDir::separator();
|
|
if (uri.isEmpty())
|
|
return accountLocalPath + "profile.vcf";
|
|
|
|
auto fileName = QString(uri.toUtf8().toBase64());
|
|
return accountLocalPath + "profiles" + QDir::separator() + fileName + ".vcf";
|
|
}
|
|
|
|
static QString
|
|
stringFromJSON(const QJsonObject& json)
|
|
{
|
|
QJsonDocument doc(json);
|
|
return QString::fromLocal8Bit(doc.toJson(QJsonDocument::Compact));
|
|
}
|
|
|
|
static QJsonObject
|
|
JSONFromString(const QString& str)
|
|
{
|
|
QJsonObject json;
|
|
QJsonDocument doc = QJsonDocument::fromJson(str.toUtf8());
|
|
|
|
if (!doc.isNull()) {
|
|
if (doc.isObject()) {
|
|
json = doc.object();
|
|
} else {
|
|
qDebug() << "Document is not a JSON object: " << str;
|
|
}
|
|
} else {
|
|
qDebug() << "Invalid JSON: " << str;
|
|
}
|
|
return json;
|
|
}
|
|
|
|
static QString
|
|
JSONStringFromInitList(const std::initializer_list<QPair<QString, QJsonValue>> args)
|
|
{
|
|
QJsonObject jsonObject(args);
|
|
return stringFromJSON(jsonObject);
|
|
}
|
|
|
|
static QString
|
|
readJSONValue(const QJsonObject& json, const QString& key)
|
|
{
|
|
if (!json.isEmpty() && json.contains(key) && json[key].isString()) {
|
|
if (json[key].isString()) {
|
|
return json[key].toString();
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
static void
|
|
writeJSONValue(QJsonObject& json, const QString& key, const QString& value)
|
|
{
|
|
json[key] = value;
|
|
}
|
|
|
|
QString
|
|
prepareUri(const QString& uri, api::profile::Type type)
|
|
{
|
|
URI uriObject(uri);
|
|
switch (type) {
|
|
case api::profile::Type::SIP:
|
|
return uriObject.format(URI::Section::USER_INFO | URI::Section::HOSTNAME);
|
|
break;
|
|
case api::profile::Type::JAMI:
|
|
return uriObject.format(URI::Section::USER_INFO);
|
|
break;
|
|
case api::profile::Type::INVALID:
|
|
case api::profile::Type::PENDING:
|
|
case api::profile::Type::TEMPORARY:
|
|
case api::profile::Type::COUNT__:
|
|
default:
|
|
return uri;
|
|
}
|
|
}
|
|
|
|
QString
|
|
getFormattedCallDuration(const std::time_t duration)
|
|
{
|
|
if (duration == 0)
|
|
return {};
|
|
std::string formattedString;
|
|
auto minutes = duration / 60;
|
|
auto seconds = duration % 60;
|
|
if (minutes > 0) {
|
|
formattedString += std::to_string(minutes) + ":";
|
|
if (formattedString.length() == 2) {
|
|
formattedString = "0" + formattedString;
|
|
}
|
|
} else {
|
|
formattedString += "00:";
|
|
}
|
|
if (seconds < 10)
|
|
formattedString += "0";
|
|
formattedString += std::to_string(seconds);
|
|
return QString::fromStdString(formattedString);
|
|
}
|
|
|
|
QString
|
|
getCallInteractionStringNonSwarm(const QString& authorUri, const std::time_t& duration)
|
|
{
|
|
if (duration < 0) {
|
|
if (authorUri.isEmpty()) {
|
|
return QObject::tr("Outgoing call");
|
|
} else {
|
|
return QObject::tr("Incoming call");
|
|
}
|
|
} else if (authorUri.isEmpty()) {
|
|
if (duration) {
|
|
return QObject::tr("Outgoing call") + " - " + getFormattedCallDuration(duration);
|
|
} else {
|
|
return QObject::tr("Missed outgoing call");
|
|
}
|
|
} else {
|
|
if (duration) {
|
|
return QObject::tr("Incoming call") + " - " + getFormattedCallDuration(duration);
|
|
} else {
|
|
return QObject::tr("Missed incoming call");
|
|
}
|
|
}
|
|
}
|
|
|
|
QString
|
|
getCallInteractionString(const api::interaction::Info& info)
|
|
{
|
|
if (!info.confId.isEmpty()) {
|
|
if (info.duration <= 0) {
|
|
return QObject::tr("Join call");
|
|
}
|
|
}
|
|
return getCallInteractionStringNonSwarm(info.authorUri, info.duration);
|
|
}
|
|
|
|
QString
|
|
getContactInteractionString(const QString& authorUri, const api::interaction::Status& status)
|
|
{
|
|
if (authorUri.isEmpty()) {
|
|
return QObject::tr("Contact added");
|
|
} else {
|
|
if (status == api::interaction::Status::UNKNOWN) {
|
|
return QObject::tr("Invitation received");
|
|
} else if (status == api::interaction::Status::SUCCESS) {
|
|
return QObject::tr("Invitation accepted");
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
namespace vcard {
|
|
QString
|
|
compressedAvatar(const QString& image)
|
|
{
|
|
QImage qimage;
|
|
// Avoid to use all formats. Some seems bugguy, like libpbf, asking
|
|
// for a QGuiApplication for QFontDatabase
|
|
auto ret = qimage.loadFromData(QByteArray::fromBase64(image.toUtf8()), "JPEG");
|
|
if (!ret)
|
|
ret = qimage.loadFromData(QByteArray::fromBase64(image.toUtf8()), "PNG");
|
|
if (!ret) {
|
|
qDebug() << "vCard image loading failed";
|
|
return "";
|
|
}
|
|
QByteArray bArray;
|
|
QBuffer buffer(&bArray);
|
|
buffer.open(QIODevice::WriteOnly);
|
|
|
|
auto size = qMin(qimage.width(), qimage.height());
|
|
auto rect = QRect((qimage.width() - size) / 2, (qimage.height() - size) / 2, size, size);
|
|
constexpr auto quality = 88; // Same as android, between 80 and 90 jpeg compression changes a lot
|
|
constexpr auto maxSize = 16000 * 8; // Because 16*3 (rgb) = 48k, which is a valid size for the
|
|
// DHT and * 8 because typical jpeg compression
|
|
// divides the size per 8
|
|
while (size * size > maxSize)
|
|
size /= 2;
|
|
qimage.copy(rect).scaled({size, size}, Qt::KeepAspectRatio).save(&buffer, "JPEG", quality);
|
|
auto b64Img = bArray.toBase64().trimmed();
|
|
return QString::fromLocal8Bit(b64Img.constData(), b64Img.length());
|
|
}
|
|
|
|
QString
|
|
profileToVcard(const api::profile::Info& profileInfo, bool compressImage)
|
|
{
|
|
using namespace api;
|
|
bool compressedImage = std::strncmp(profileInfo.avatar.toStdString().c_str(), "/9j/", 4) == 0;
|
|
if (compressedImage && !compressImage) {
|
|
compressImage = false;
|
|
}
|
|
QString vCardStr = vCard::Delimiter::BEGIN_TOKEN;
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
vCardStr += vCard::Property::VERSION;
|
|
vCardStr += ":2.1";
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
vCardStr += vCard::Property::FORMATTED_NAME;
|
|
vCardStr += ":";
|
|
vCardStr += profileInfo.alias;
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
if (profileInfo.type == profile::Type::JAMI) {
|
|
vCardStr += vCard::Property::TELEPHONE;
|
|
vCardStr += vCard::Delimiter::SEPARATOR_TOKEN;
|
|
vCardStr += "other:ring:";
|
|
vCardStr += profileInfo.uri;
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
} else {
|
|
vCardStr += vCard::Property::TELEPHONE;
|
|
vCardStr += ":";
|
|
vCardStr += profileInfo.uri;
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
}
|
|
vCardStr += vCard::Property::PHOTO;
|
|
vCardStr += vCard::Delimiter::SEPARATOR_TOKEN;
|
|
vCardStr += vCard::Property::BASE64;
|
|
vCardStr += vCard::Delimiter::SEPARATOR_TOKEN;
|
|
if (compressImage) {
|
|
vCardStr += vCard::Property::TYPE_JPEG;
|
|
vCardStr += ":";
|
|
vCardStr += compressedImage ? profileInfo.avatar : compressedAvatar(profileInfo.avatar);
|
|
} else {
|
|
vCardStr += compressedImage ? vCard::Property::TYPE_JPEG : vCard::Property::TYPE_PNG;
|
|
vCardStr += ":";
|
|
vCardStr += profileInfo.avatar;
|
|
}
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
vCardStr += vCard::Delimiter::END_TOKEN;
|
|
return vCardStr;
|
|
}
|
|
|
|
void
|
|
setProfile(const QString& accountId, const api::profile::Info& profileInfo, const bool isPeer)
|
|
{
|
|
auto vcard = vcard::profileToVcard(profileInfo);
|
|
auto path = profileVcardPath(accountId, isPeer ? profileInfo.uri : "");
|
|
QLockFile lf(path + ".lock");
|
|
QFile file(path);
|
|
QFileInfo fileInfo(path);
|
|
auto dir = fileInfo.dir();
|
|
if (!dir.exists()) {
|
|
#if !defined(Q_OS_LINUX) || __GNUC__ > 8
|
|
if (!std::filesystem::create_directory(dir.path().toStdString())) {
|
|
#endif
|
|
qWarning() << "Cannot create " << dir.path();
|
|
#if !defined(Q_OS_LINUX) || __GNUC__ > 8
|
|
}
|
|
#endif
|
|
}
|
|
if (!lf.lock()) {
|
|
qWarning().noquote() << "Can't lock file for writing: " << file.fileName();
|
|
return;
|
|
}
|
|
if (!file.open(QIODevice::WriteOnly)) {
|
|
lf.unlock();
|
|
qWarning().noquote() << "Can't open file for writing: " << file.fileName();
|
|
return;
|
|
}
|
|
QTextStream(&file) << vcard;
|
|
file.close();
|
|
lf.unlock();
|
|
}
|
|
} // namespace vcard
|
|
|
|
VectorString
|
|
getConversationsWithPeer(Database& db, const QString& participant_uri)
|
|
{
|
|
return db
|
|
.select("id",
|
|
"conversations",
|
|
"participant=:participant",
|
|
{{":participant", participant_uri}})
|
|
.payloads;
|
|
}
|
|
|
|
VectorString
|
|
getPeerParticipantsForConversation(Database& db, const QString& conversationId)
|
|
{
|
|
return db.select("participant", "conversations", "id=:id", {{":id", conversationId}}).payloads;
|
|
}
|
|
|
|
void
|
|
createOrUpdateProfile(const QString& accountId,
|
|
const api::profile::Info& profileInfo,
|
|
const bool isPeer)
|
|
{
|
|
if (isPeer) {
|
|
auto contact = storage::buildContactFromProfile(accountId,
|
|
profileInfo.uri,
|
|
profileInfo.type);
|
|
if (!profileInfo.alias.isEmpty())
|
|
contact.profileInfo.alias = profileInfo.alias;
|
|
if (!profileInfo.avatar.isEmpty())
|
|
contact.profileInfo.avatar = profileInfo.avatar;
|
|
vcard::setProfile(accountId, contact.profileInfo, isPeer);
|
|
return;
|
|
}
|
|
vcard::setProfile(accountId, profileInfo, isPeer);
|
|
}
|
|
|
|
void
|
|
removeProfile(const QString& accountId, const QString& peerUri)
|
|
{
|
|
auto path = profileVcardPath(accountId, peerUri);
|
|
if (!QFile::remove(path)) {
|
|
qWarning() << "Couldn't remove vcard for" << peerUri << "at" << path;
|
|
}
|
|
}
|
|
|
|
QString
|
|
getAccountAvatar(const QString& accountId)
|
|
{
|
|
auto accountLocalPath = getPath() + accountId + "/";
|
|
QString filePath;
|
|
filePath = accountLocalPath + "profile.vcf";
|
|
QFile file(filePath);
|
|
if (!file.open(QIODevice::ReadOnly))
|
|
return {};
|
|
const auto vCard = lrc::vCard::utils::toHashMap(file.readAll());
|
|
const auto photo = (vCard.find(vCard::Property::PHOTO_PNG) == vCard.end())
|
|
? vCard[vCard::Property::PHOTO_JPEG]
|
|
: vCard[vCard::Property::PHOTO_PNG];
|
|
return photo;
|
|
}
|
|
|
|
api::contact::Info
|
|
buildContactFromProfile(const QString& accountId,
|
|
const QString& peer_uri,
|
|
const api::profile::Type& type)
|
|
{
|
|
lrc::api::profile::Info profileInfo;
|
|
profileInfo.uri = peer_uri;
|
|
profileInfo.type = type;
|
|
auto accountLocalPath = getPath() + accountId + "/";
|
|
QString b64filePath;
|
|
b64filePath = profileVcardPath(accountId, peer_uri);
|
|
QFile file(b64filePath);
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
// try non-base64 path
|
|
QString filePath = accountLocalPath + "profiles/" + peer_uri + ".vcf";
|
|
file.setFileName(filePath);
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
return {profileInfo, "", true, false};
|
|
}
|
|
// rename it
|
|
qWarning().noquote() << "Renaming profile: " << filePath;
|
|
file.rename(b64filePath);
|
|
// reopen it
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
qWarning().noquote() << "Can't open file: " << b64filePath;
|
|
return {profileInfo, "", true, false};
|
|
}
|
|
}
|
|
const auto vCard = lrc::vCard::utils::toHashMap(file.readAll());
|
|
const auto alias = vCard[vCard::Property::FORMATTED_NAME];
|
|
if (lrc::api::Lrc::cacheAvatars.load()) {
|
|
for (const auto& key : vCard.keys()) {
|
|
if (key.contains("PHOTO"))
|
|
profileInfo.avatar = vCard[key];
|
|
}
|
|
}
|
|
profileInfo.alias = alias;
|
|
return {profileInfo, "", type == api::profile::Type::JAMI, false};
|
|
}
|
|
|
|
QString
|
|
avatar(const QString& accountId, const QString& contactId)
|
|
{
|
|
if (contactId.isEmpty())
|
|
return getAccountAvatar(accountId);
|
|
auto accountLocalPath = getPath() + accountId + "/";
|
|
QString b64filePath;
|
|
b64filePath = profileVcardPath(accountId, contactId);
|
|
QFile file(b64filePath);
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
return {};
|
|
}
|
|
const auto vCard = lrc::vCard::utils::toHashMap(file.readAll());
|
|
for (const auto& key : vCard.keys()) {
|
|
if (key.contains("PHOTO"))
|
|
return vCard[key];
|
|
}
|
|
return {};
|
|
}
|
|
|
|
VectorString
|
|
getAllConversations(Database& db)
|
|
{
|
|
return db.select("id", "conversations", {}, {}).payloads;
|
|
}
|
|
|
|
VectorString
|
|
getConversationsBetween(Database& db, const QString& peer1_uri, const QString& peer2_uri)
|
|
{
|
|
auto conversationsForPeer1 = getConversationsWithPeer(db, peer1_uri);
|
|
std::sort(conversationsForPeer1.begin(), conversationsForPeer1.end());
|
|
auto conversationsForPeer2 = getConversationsWithPeer(db, peer2_uri);
|
|
std::sort(conversationsForPeer2.begin(), conversationsForPeer2.end());
|
|
VectorString common;
|
|
|
|
std::set_intersection(conversationsForPeer1.begin(),
|
|
conversationsForPeer1.end(),
|
|
conversationsForPeer2.begin(),
|
|
conversationsForPeer2.end(),
|
|
std::back_inserter(common));
|
|
return common;
|
|
}
|
|
|
|
QString
|
|
beginConversationWithPeer(Database& db,
|
|
const QString& peer_uri,
|
|
const bool isOutgoing,
|
|
time_t timestamp)
|
|
{
|
|
// Add conversation between account and profile
|
|
auto newConversationsId = db.select("IFNULL(MAX(id), 0) + 1", "conversations", "1=1", {})
|
|
.payloads[0];
|
|
db.insertInto("conversations",
|
|
{{":id", "id"}, {":participant", "participant"}},
|
|
{{":id", newConversationsId}, {":participant", peer_uri}});
|
|
api::interaction::Info msg {isOutgoing ? "" : peer_uri,
|
|
{},
|
|
timestamp ? timestamp : std::time(nullptr),
|
|
0,
|
|
api::interaction::Type::CONTACT,
|
|
isOutgoing ? api::interaction::Status::SUCCESS
|
|
: api::interaction::Status::UNKNOWN,
|
|
isOutgoing};
|
|
// Add first interaction
|
|
addMessageToConversation(db, newConversationsId, msg);
|
|
return newConversationsId;
|
|
}
|
|
|
|
void
|
|
getHistory(Database& db, api::conversation::Info& conversation)
|
|
{
|
|
auto interactionsResult
|
|
= db.select("id, author, body, timestamp, type, status, is_read, extra_data",
|
|
"interactions",
|
|
"conversation=:conversation",
|
|
{{":conversation", conversation.uid}});
|
|
auto nCols = 8;
|
|
if (interactionsResult.nbrOfCols == nCols) {
|
|
auto payloads = interactionsResult.payloads;
|
|
for (decltype(payloads.size()) i = 0; i < payloads.size(); i += nCols) {
|
|
QString durationString;
|
|
auto extra_data_str = payloads[i + 7];
|
|
if (!extra_data_str.isEmpty()) {
|
|
auto jsonData = JSONFromString(extra_data_str);
|
|
durationString = readJSONValue(jsonData, "duration");
|
|
}
|
|
auto body = payloads[i + 2];
|
|
auto type = api::interaction::to_type(payloads[i + 4]);
|
|
std::time_t duration = durationString.isEmpty()
|
|
? 0
|
|
: std::stoi(durationString.toStdString());
|
|
auto status = api::interaction::to_status(payloads[i + 5]);
|
|
if (type == api::interaction::Type::CALL) {
|
|
body = getCallInteractionStringNonSwarm(payloads[i + 1], duration);
|
|
} else if (type == api::interaction::Type::CONTACT) {
|
|
body = getContactInteractionString(payloads[i + 1], status);
|
|
}
|
|
auto msg = api::interaction::Info({payloads[i + 1],
|
|
body,
|
|
std::stoi(payloads[i + 3].toStdString()),
|
|
duration,
|
|
type,
|
|
status,
|
|
(payloads[i + 6] == "1" ? true : false)});
|
|
conversation.interactions->emplace(payloads[i], std::move(msg));
|
|
conversation.lastMessageUid = payloads[i];
|
|
if (status != api::interaction::Status::DISPLAYED || !payloads[i + 1].isEmpty()) {
|
|
continue;
|
|
}
|
|
conversation.interactions->setRead(conversation.participants.front().uri, payloads[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
QString
|
|
addMessageToConversation(Database& db,
|
|
const QString& conversationId,
|
|
const api::interaction::Info& msg)
|
|
{
|
|
return db.insertInto("interactions",
|
|
{{":author", "author"},
|
|
{":conversation", "conversation"},
|
|
{":timestamp", "timestamp"},
|
|
{":body", "body"},
|
|
{":type", "type"},
|
|
{":status", "status"},
|
|
{":is_read", "is_read"}},
|
|
{{":author", msg.authorUri},
|
|
{":conversation", conversationId},
|
|
{":timestamp", toQString(msg.timestamp)},
|
|
{":body", msg.body},
|
|
{":type", to_string(msg.type)},
|
|
{":status", to_string(msg.status)},
|
|
{":is_read", msg.isRead ? "1" : "0"}});
|
|
}
|
|
|
|
QString
|
|
addOrUpdateMessage(Database& db,
|
|
const QString& conversationId,
|
|
const api::interaction::Info& msg,
|
|
const QString& daemonId)
|
|
{
|
|
// Check if profile is already present.
|
|
auto msgAlreadyExists = db.select("id",
|
|
"interactions",
|
|
"author=:author AND daemon_id=:daemon_id",
|
|
{{":author", msg.authorUri}, {":daemon_id", daemonId}})
|
|
.payloads;
|
|
if (msgAlreadyExists.empty()) {
|
|
auto extra_data_JSON = JSONFromString("");
|
|
writeJSONValue(extra_data_JSON, "duration", QString::number(msg.duration));
|
|
auto extra_data = stringFromJSON(extra_data_JSON);
|
|
return db.insertInto("interactions",
|
|
{{":author", "author"},
|
|
{":conversation", "conversation"},
|
|
{":timestamp", "timestamp"},
|
|
{":body", "body"},
|
|
{":type", "type"},
|
|
{":status", "status"},
|
|
{":daemon_id", "daemon_id"},
|
|
{":extra_data", "extra_data"}},
|
|
{{":author", msg.authorUri.isEmpty() ? "" : msg.authorUri},
|
|
{":conversation", conversationId},
|
|
{":timestamp", toQString(msg.timestamp)},
|
|
{msg.body.isEmpty() ? "" : ":body", msg.body},
|
|
{":type", to_string(msg.type)},
|
|
{daemonId.isEmpty() ? "" : ":daemon_id", daemonId},
|
|
{":status", to_string(msg.status)},
|
|
{extra_data.isEmpty() ? "" : ":extra_data", extra_data}});
|
|
} else {
|
|
// already exists @ id(msgAlreadyExists[0])
|
|
auto id = msgAlreadyExists[0];
|
|
QString extra_data;
|
|
if (msg.type == api::interaction::Type::CALL) {
|
|
auto duration = std::max(msg.duration, static_cast<std::time_t>(0));
|
|
auto extra_data_str = getInteractionExtraDataById(db, id);
|
|
auto extra_data_JSON = JSONFromString(extra_data_str);
|
|
writeJSONValue(extra_data_JSON, "duration", QString::number(duration));
|
|
extra_data = stringFromJSON(extra_data_JSON);
|
|
}
|
|
db.update("interactions",
|
|
{"body=:body, extra_data=:extra_data"},
|
|
{{msg.body.isEmpty() ? "" : ":body", msg.body},
|
|
{extra_data.isEmpty() ? "" : ":extra_data", extra_data}},
|
|
"id=:id",
|
|
{{":id", id}});
|
|
return id;
|
|
}
|
|
}
|
|
|
|
QString
|
|
addDataTransferToConversation(Database& db,
|
|
const QString& conversationId,
|
|
const api::datatransfer::Info& infoFromDaemon)
|
|
{
|
|
auto convId = conversationId.isEmpty() ? NULL : conversationId;
|
|
return db.insertInto("interactions",
|
|
{{":author", "author"},
|
|
{":conversation", "conversation"},
|
|
{":timestamp", "timestamp"},
|
|
{":body", "body"},
|
|
{":type", "type"},
|
|
{":status", "status"},
|
|
{":is_read", "is_read"},
|
|
{":daemon_id", "daemon_id"}},
|
|
{{":author", infoFromDaemon.isOutgoing ? "" : infoFromDaemon.peerUri},
|
|
{":conversation", convId},
|
|
{":timestamp", toQString(std::time(nullptr))},
|
|
{":body", infoFromDaemon.path},
|
|
{":type", "DATA_TRANSFER"},
|
|
{":status", "TRANSFER_CREATED"},
|
|
{":is_read", "0"},
|
|
{":daemon_id", infoFromDaemon.uid}});
|
|
}
|
|
|
|
void
|
|
addDaemonMsgId(Database& db, const QString& interactionId, const QString& daemonId)
|
|
{
|
|
db.update("interactions",
|
|
"daemon_id=:daemon_id",
|
|
{{":daemon_id", daemonId}},
|
|
"id=:id",
|
|
{{":id", interactionId}});
|
|
}
|
|
|
|
QString
|
|
getDaemonIdByInteractionId(Database& db, const QString& id)
|
|
{
|
|
auto ids = db.select("daemon_id", "interactions", "id=:id", {{":id", id}}).payloads;
|
|
return ids.empty() ? "" : ids[0];
|
|
}
|
|
|
|
QString
|
|
getInteractionIdByDaemonId(Database& db, const QString& daemon_id)
|
|
{
|
|
auto ids = db.select("id", "interactions", "daemon_id=:daemon_id", {{":daemon_id", daemon_id}})
|
|
.payloads;
|
|
return ids.empty() ? "" : ids[0];
|
|
}
|
|
|
|
void
|
|
updateDataTransferInteractionForDaemonId(Database& db,
|
|
const QString& daemonId,
|
|
api::interaction::Info& interaction)
|
|
{
|
|
auto result = db.select("body, status",
|
|
"interactions",
|
|
"daemon_id=:daemon_id",
|
|
{{":daemon_id", daemonId}})
|
|
.payloads;
|
|
if (result.size() < 2) {
|
|
return;
|
|
}
|
|
auto body = result[0];
|
|
auto status = api::interaction::to_status(result[1]);
|
|
interaction.body = body;
|
|
interaction.status = status;
|
|
}
|
|
|
|
QString
|
|
getInteractionExtraDataById(Database& db, const QString& id, const QString& key)
|
|
{
|
|
auto extra_datas = db.select("extra_data", "interactions", "id=:id", {{":id", id}}).payloads;
|
|
if (key.isEmpty()) {
|
|
return extra_datas.empty() ? "" : extra_datas[0];
|
|
}
|
|
QString value;
|
|
if (!extra_datas[0].isEmpty()) {
|
|
value = readJSONValue(JSONFromString(extra_datas[0]), key);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
void
|
|
updateInteractionBody(Database& db, const QString& id, const QString& newBody)
|
|
{
|
|
db.update("interactions", "body=:body", {{":body", newBody}}, "id=:id", {{":id", id}});
|
|
}
|
|
|
|
void
|
|
updateInteractionStatus(Database& db, const QString& id, api::interaction::Status newStatus)
|
|
{
|
|
db.update("interactions",
|
|
{"status=:status"},
|
|
{{":status", api::interaction::to_string(newStatus)}},
|
|
"id=:id",
|
|
{{":id", id}});
|
|
}
|
|
|
|
void
|
|
setInteractionRead(Database& db, const QString& id)
|
|
{
|
|
db.update("interactions", {"is_read=:is_read"}, {{":is_read", "1"}}, "id=:id", {{":id", id}});
|
|
}
|
|
|
|
QString
|
|
conversationIdFromInteractionId(Database& db, const QString& interactionId)
|
|
{
|
|
auto result = db.select("conversation", "interactions", "id=:id", {{":id", interactionId}});
|
|
if (result.nbrOfCols == 1 && result.payloads.size()) {
|
|
return result.payloads[0];
|
|
}
|
|
return {};
|
|
}
|
|
|
|
void
|
|
clearHistory(Database& db, const QString& conversationId)
|
|
{
|
|
try {
|
|
db.deleteFrom("interactions",
|
|
"conversation=:conversation",
|
|
{{":conversation", conversationId}});
|
|
} catch (Database::QueryDeleteError& e) {
|
|
qWarning() << "deleteFrom error: " << e.details();
|
|
}
|
|
}
|
|
|
|
void
|
|
clearInteractionFromConversation(Database& db,
|
|
const QString& conversationId,
|
|
const QString& interactionId)
|
|
{
|
|
try {
|
|
db.deleteFrom("interactions",
|
|
"conversation=:conversation AND id=:id",
|
|
{{":conversation", conversationId}, {":id", interactionId}});
|
|
} catch (Database::QueryDeleteError& e) {
|
|
qWarning() << "deleteFrom error: " << e.details();
|
|
}
|
|
}
|
|
|
|
void
|
|
clearAllHistory(Database& db)
|
|
{
|
|
try {
|
|
db.deleteFrom("interactions", "1=1", {});
|
|
} catch (Database::QueryDeleteError& e) {
|
|
qWarning() << "deleteFrom error: " << e.details();
|
|
}
|
|
}
|
|
|
|
void
|
|
deleteObsoleteHistory(Database& db, long int date)
|
|
{
|
|
try {
|
|
db.deleteFrom("interactions", "timestamp<=:date", {{":date", QString::number(date)}});
|
|
} catch (Database::QueryDeleteError& e) {
|
|
qWarning() << "deleteFrom error: " << e.details();
|
|
}
|
|
}
|
|
|
|
void
|
|
removeContactConversations(Database& db, const QString& contactUri)
|
|
{
|
|
// Get common conversations
|
|
auto conversations = getConversationsWithPeer(db, contactUri);
|
|
// Remove conversations + interactions
|
|
try {
|
|
for (const auto& conversationId : conversations) {
|
|
// Remove conversation
|
|
db.deleteFrom("conversations", "id=:id", {{":id", conversationId}});
|
|
// clear History
|
|
db.deleteFrom("interactions", "conversation=:id", {{":id", conversationId}});
|
|
}
|
|
} catch (Database::QueryDeleteError& e) {
|
|
qWarning() << "deleteFrom error: " << e.details();
|
|
}
|
|
}
|
|
|
|
int
|
|
countUnreadFromInteractions(Database& db, const QString& conversationId)
|
|
{
|
|
return db.count("is_read",
|
|
"interactions",
|
|
"is_read=:is_read AND conversation=:id",
|
|
{{":is_read", "0"}, {":id", conversationId}});
|
|
}
|
|
|
|
uint64_t
|
|
getLastTimestamp(Database& db)
|
|
{
|
|
auto timestamps = db.select("MAX(timestamp)", "interactions", "1=1", {}).payloads;
|
|
auto result = std::time(nullptr);
|
|
try {
|
|
if (!timestamps.empty() && !timestamps[0].isEmpty()) {
|
|
result = std::stoull(timestamps[0].toStdString());
|
|
}
|
|
} catch (const std::out_of_range& e) {
|
|
qDebug() << "storage::getLastTimestamp, stoull throws an out_of_range exception: "
|
|
<< e.what();
|
|
} catch (const std::invalid_argument& e) {
|
|
qDebug() << "storage::getLastTimestamp, stoull throws an invalid_argument exception: "
|
|
<< e.what();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
//================================================================================
|
|
// This section provides migration helpers from ring.db
|
|
// to per-account databases yielding a file structure like:
|
|
//
|
|
// { local_storage } / jami
|
|
// └──{ account_id }
|
|
// ├── config.yml
|
|
// ├── contacts
|
|
// ├── export.gz
|
|
// ├── incomingTrustRequests
|
|
// ├── knownDevicesNames
|
|
// ├── history.db < --conversations and interactions database
|
|
// ├── profile.vcf < --account vcard
|
|
// ├── profiles < --account contact vcards
|
|
// │ │──{ contact_uri }.vcf
|
|
// │ └── ...
|
|
// ├── ring_device.crt
|
|
// └── ring_device.key
|
|
//================================================================================
|
|
namespace migration {
|
|
|
|
enum class msgFlag {
|
|
IS_INCOMING,
|
|
IS_OUTGOING,
|
|
IS_CONTACT_ADDED,
|
|
IS_INVITATION_RECEIVED,
|
|
IS_INVITATION_ACCEPTED,
|
|
IS_TEXT
|
|
};
|
|
|
|
QString profileToVcard(const lrc::api::profile::Info&, const QString&);
|
|
uint64_t getTimeFromTimeStr(const QString&) noexcept;
|
|
std::pair<msgFlag, uint64_t> migrateMessageBody(const QString&, const lrc::api::interaction::Type&);
|
|
VectorString getPeerParticipantsForConversationId(lrc::Database&, const QString&, const QString&);
|
|
void migrateAccountDb(const QString&,
|
|
std::shared_ptr<lrc::Database>,
|
|
std::shared_ptr<lrc::Database>);
|
|
|
|
namespace interaction {
|
|
|
|
static inline api::interaction::Type
|
|
to_type(const QString& type)
|
|
{
|
|
if (type == "TEXT")
|
|
return api::interaction::Type::TEXT;
|
|
else if (type == "CALL")
|
|
return api::interaction::Type::CALL;
|
|
else if (type == "CONTACT")
|
|
return api::interaction::Type::CONTACT;
|
|
else if (type == "OUTGOING_DATA_TRANSFER")
|
|
return api::interaction::Type::DATA_TRANSFER;
|
|
else if (type == "INCOMING_DATA_TRANSFER")
|
|
return api::interaction::Type::DATA_TRANSFER;
|
|
else
|
|
return api::interaction::Type::INVALID;
|
|
}
|
|
|
|
static inline QString
|
|
to_migrated_status_string(const QString& status)
|
|
{
|
|
if (status == "FAILED")
|
|
return "FAILURE";
|
|
else if (status == "SUCCEED")
|
|
return "SUCCESS";
|
|
else if (status == "READ")
|
|
return "SUCCESS";
|
|
else if (status == "UNREAD")
|
|
return "SUCCESS";
|
|
else
|
|
return status;
|
|
}
|
|
|
|
} // namespace interaction
|
|
|
|
QString
|
|
profileToVcard(const api::profile::Info& profileInfo, const QString& accountId = {})
|
|
{
|
|
using namespace api;
|
|
bool compressedImage = std::strncmp(profileInfo.avatar.toStdString().c_str(), "/9g=", 4) == 0;
|
|
;
|
|
QString vCardStr = vCard::Delimiter::BEGIN_TOKEN;
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
vCardStr += vCard::Property::VERSION;
|
|
vCardStr += ":2.1";
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
if (!accountId.isEmpty()) {
|
|
vCardStr += vCard::Property::UID;
|
|
vCardStr += ":";
|
|
vCardStr += accountId;
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
}
|
|
vCardStr += vCard::Property::FORMATTED_NAME;
|
|
vCardStr += ":";
|
|
vCardStr += profileInfo.alias;
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
if (profileInfo.type == profile::Type::JAMI) {
|
|
vCardStr += vCard::Property::TELEPHONE;
|
|
vCardStr += ":";
|
|
vCardStr += vCard::Delimiter::SEPARATOR_TOKEN;
|
|
vCardStr += "other:ring:";
|
|
vCardStr += profileInfo.uri;
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
} else {
|
|
vCardStr += vCard::Property::TELEPHONE;
|
|
vCardStr += profileInfo.uri;
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
}
|
|
vCardStr += vCard::Property::PHOTO;
|
|
vCardStr += vCard::Delimiter::SEPARATOR_TOKEN;
|
|
vCardStr += "ENCODING=BASE64";
|
|
vCardStr += vCard::Delimiter::SEPARATOR_TOKEN;
|
|
vCardStr += compressedImage ? "TYPE=JPEG:" : "TYPE=PNG:";
|
|
vCardStr += profileInfo.avatar;
|
|
vCardStr += vCard::Delimiter::END_LINE_TOKEN;
|
|
vCardStr += vCard::Delimiter::END_TOKEN;
|
|
return vCardStr;
|
|
}
|
|
|
|
uint64_t
|
|
getTimeFromTimeStr(const QString& str) noexcept
|
|
{
|
|
uint64_t minutes = 0, seconds = 0;
|
|
std::string timeStr = str.toStdString();
|
|
std::size_t delimiterPos = timeStr.find(":");
|
|
if (delimiterPos != std::string::npos) {
|
|
try {
|
|
minutes = std::stoull(timeStr.substr(0, delimiterPos));
|
|
seconds = std::stoull(timeStr.substr(delimiterPos + 1));
|
|
} catch (const std::exception&) {
|
|
return 0;
|
|
}
|
|
}
|
|
return minutes * 60 + seconds;
|
|
}
|
|
|
|
std::pair<msgFlag, uint64_t>
|
|
migrateMessageBody(const QString& body, const api::interaction::Type& type)
|
|
{
|
|
uint64_t duration {0};
|
|
// check in english and local to determine the direction of the call
|
|
static QString emo = "Missed outgoing call";
|
|
static QString lmo = QObject::tr("Missed outgoing call");
|
|
static QString eo = "Outgoing call";
|
|
static QString lo = QObject::tr("Outgoing call");
|
|
static QString eca = "Contact added";
|
|
static QString lca = QObject::tr("Contact added");
|
|
static QString eir = "Invitation received";
|
|
static QString lir = QObject::tr("Invitation received");
|
|
static QString eia = "Invitation accepted";
|
|
static QString lia = QObject::tr("Invitation accepted");
|
|
auto strBody = body.toStdString();
|
|
switch (type) {
|
|
case api::interaction::Type::CALL: {
|
|
bool en_missedOut = body.contains(emo);
|
|
bool en_out = body.contains(eo);
|
|
bool loc_missedOut = body.contains(lmo);
|
|
bool loc_out = body.contains(lo);
|
|
bool outgoingCall = en_missedOut || en_out || loc_missedOut || loc_out;
|
|
std::size_t dashPos = strBody.find("-");
|
|
if (dashPos != std::string::npos) {
|
|
duration = getTimeFromTimeStr(toQString(strBody.substr(dashPos + 2)));
|
|
}
|
|
return std::make_pair(msgFlag(outgoingCall), duration);
|
|
} break;
|
|
case api::interaction::Type::CONTACT:
|
|
if (body.contains(eca) || body.contains(lca)) {
|
|
return std::make_pair(msgFlag::IS_CONTACT_ADDED, 0);
|
|
} else if (body.contains(eir) || body.contains(lir)) {
|
|
return std::make_pair(msgFlag::IS_INVITATION_RECEIVED, 0);
|
|
} else if (body.contains(eia) || body.contains(lia)) {
|
|
return std::make_pair(msgFlag::IS_INVITATION_ACCEPTED, 0);
|
|
}
|
|
break;
|
|
case api::interaction::Type::INVALID:
|
|
case api::interaction::Type::TEXT:
|
|
case api::interaction::Type::DATA_TRANSFER:
|
|
case api::interaction::Type::COUNT__:
|
|
default:
|
|
return std::make_pair(msgFlag::IS_TEXT, 0);
|
|
}
|
|
return std::make_pair(msgFlag::IS_OUTGOING, 0);
|
|
}
|
|
|
|
VectorString
|
|
getPeerParticipantsForConversationId(Database& db,
|
|
const QString& profileId,
|
|
const QString& conversationId)
|
|
{
|
|
return db
|
|
.select("participant_id",
|
|
"conversations",
|
|
"id=:id AND participant_id!=:participant_id",
|
|
{{":id", conversationId}, {":participant_id", profileId}})
|
|
.payloads;
|
|
}
|
|
|
|
void
|
|
migrateAccountDb(const QString& accountId,
|
|
std::shared_ptr<Database> db,
|
|
std::shared_ptr<Database> legacyDb)
|
|
{
|
|
using namespace lrc::api;
|
|
using namespace migration;
|
|
|
|
auto accountLocalPath = getPath() + accountId + "/";
|
|
|
|
using namespace libjami::Account;
|
|
MapStringString accountDetails = ConfigurationManager::instance().getAccountDetails(
|
|
accountId.toStdString().c_str());
|
|
bool isRingAccount = accountDetails[ConfProperties::TYPE] == "RING";
|
|
std::map<QString, QString> profileIdUriMap;
|
|
std::map<QString, QString> convIdPeerUriMap;
|
|
QString accountProfileId;
|
|
|
|
// 1. profiles_accounts
|
|
// migrate account's avatar/alias from profiles table to {data_dir}/profile.vcf
|
|
QString accountUri;
|
|
if (isRingAccount) {
|
|
accountUri = accountDetails[libjami::Account::ConfProperties::USERNAME].contains("ring:")
|
|
? QString(accountDetails[libjami::Account::ConfProperties::USERNAME])
|
|
.remove(QString("ring:"))
|
|
: accountDetails[libjami::Account::ConfProperties::USERNAME];
|
|
} else {
|
|
accountUri = accountDetails[libjami::Account::ConfProperties::USERNAME];
|
|
}
|
|
|
|
auto accountProfileIds = legacyDb
|
|
->select("profile_id",
|
|
"profiles_accounts",
|
|
"account_id=:account_id AND is_account=:is_account",
|
|
{{":account_id", accountId}, {":is_account", "true"}})
|
|
.payloads;
|
|
if (accountProfileIds.size() != 1) {
|
|
return;
|
|
}
|
|
accountProfileId = accountProfileIds[0];
|
|
auto accountProfile
|
|
= legacyDb->select("photo, alias", "profiles", "id=:id", {{":id", accountProfileId}})
|
|
.payloads;
|
|
profile::Info accountProfileInfo;
|
|
// if we can not find the uri in the database
|
|
// (in the case of poorly kept SIP account uris),
|
|
// than we cannot migrate the conversations and vcard
|
|
if (!accountProfile.empty()) {
|
|
accountProfileInfo = {accountUri,
|
|
accountProfile[0],
|
|
accountProfile[1],
|
|
isRingAccount ? profile::Type::JAMI : profile::Type::SIP};
|
|
}
|
|
auto accountVcard = profileToVcard(accountProfileInfo, accountId);
|
|
QDir dir;
|
|
if (!dir.exists(accountLocalPath)) {
|
|
dir.mkpath(accountLocalPath);
|
|
}
|
|
auto profileFilePath = accountLocalPath + "profile.vcf";
|
|
QFile file(profileFilePath);
|
|
if (!file.open(QIODevice::WriteOnly)) {
|
|
throw std::runtime_error("Can't open file: " + profileFilePath.toStdString());
|
|
}
|
|
QTextStream(&file) << accountVcard;
|
|
|
|
// 2. profiles
|
|
// migrate profiles from profiles table to {data_dir}/{uri}.vcf
|
|
// - for JAMI, the scheme and the hostname is omitted
|
|
// - for SIP, the uri is must be stripped of prefix and port
|
|
// e.g. 3d1112ab2bb089370c0744a44bbbb0786418d40b.vcf
|
|
// username.vcf or username@hostname.vcf
|
|
|
|
// only select non-account profiles
|
|
auto profileIds = legacyDb
|
|
->select("profile_id",
|
|
"profiles_accounts",
|
|
"account_id=:account_id AND is_account=:is_account",
|
|
{{":account_id", accountId}, {":is_account", "false"}})
|
|
.payloads;
|
|
for (const auto& profileId : profileIds) {
|
|
auto profile = legacyDb
|
|
->select("uri, alias, photo, type",
|
|
"profiles",
|
|
"id=:id",
|
|
{{":id", profileId}})
|
|
.payloads;
|
|
if (profile.empty()) {
|
|
continue;
|
|
}
|
|
profile::Info profileInfo {profile[0], profile[2], profile[1]};
|
|
auto uri = URI(profile[0]);
|
|
auto profileUri = uri.userinfo();
|
|
if (!isRingAccount && uri.hasHostname()) {
|
|
profileUri += "@" + uri.hostname();
|
|
}
|
|
// insert into map for use during the conversations table migration
|
|
profileIdUriMap.insert(std::make_pair(profileId, profileUri));
|
|
auto vcard = profileToVcard(profileInfo);
|
|
// make sure the directory exists
|
|
QDir dir(accountLocalPath + "profiles");
|
|
if (!dir.exists())
|
|
dir.mkpath(".");
|
|
profileFilePath = accountLocalPath + "profiles/" + profileUri + ".vcf";
|
|
QFile file(profileFilePath);
|
|
// if we catch duplicates here, skip the profile because
|
|
// the previous db structure does not guarantee unique uris
|
|
if (file.exists()) {
|
|
qWarning() << "Profile file already exits: " << profileFilePath;
|
|
continue;
|
|
}
|
|
if (!file.open(QIODevice::WriteOnly)) {
|
|
qWarning() << "Can't open file: " << profileFilePath;
|
|
continue;
|
|
}
|
|
QTextStream(&file) << vcard;
|
|
}
|
|
|
|
// 3. conversations
|
|
// migrate old conversations table ==> new conversations table
|
|
// a) participant_id INTEGER becomes participant TEXT (the uri of the participant)
|
|
// use the selected non-account profiles
|
|
auto conversationIds = legacyDb
|
|
->select("id",
|
|
"conversations",
|
|
"participant_id=:participant_id",
|
|
{{":participant_id", accountProfileId}})
|
|
.payloads;
|
|
if (conversationIds.empty()) {
|
|
return;
|
|
}
|
|
for (auto conversationId : conversationIds) {
|
|
// only one peer pre-groupchat
|
|
auto peerProfileId = getPeerParticipantsForConversationId(*legacyDb,
|
|
accountProfileId,
|
|
conversationId);
|
|
if (peerProfileId.empty()) {
|
|
continue;
|
|
}
|
|
auto it = profileIdUriMap.find(peerProfileId.at(0));
|
|
// we cannot insert in the conversations table without a uri
|
|
if (it == profileIdUriMap.end()) {
|
|
continue;
|
|
}
|
|
convIdPeerUriMap.insert(std::make_pair(conversationId, it->second));
|
|
try {
|
|
db->insertInto("conversations",
|
|
{{":id", "id"}, {":participant", "participant"}},
|
|
{{":id", conversationId}, {":participant", it->second}});
|
|
} catch (const std::runtime_error& e) {
|
|
qWarning() << "Couldn't migrate conversation: " << e.what();
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// 4. interactions
|
|
auto allInteractions = legacyDb->select("account_id, author_id, conversation_id, \
|
|
timestamp, body, type, status, daemon_id",
|
|
"interactions",
|
|
"account_id=:account_id",
|
|
{{":account_id", accountProfileId}});
|
|
auto interactionIt = allInteractions.payloads.begin();
|
|
while (interactionIt != allInteractions.payloads.end()) {
|
|
auto author_id = *(interactionIt + 1);
|
|
auto convId = *(interactionIt + 2);
|
|
auto timestamp = *(interactionIt + 3);
|
|
auto body = *(interactionIt + 4);
|
|
auto type = interaction::to_type(*(interactionIt + 5));
|
|
auto statusStr = *(interactionIt + 6);
|
|
auto daemonId = *(interactionIt + 7);
|
|
|
|
auto it = profileIdUriMap.find(author_id);
|
|
if (it == profileIdUriMap.end() && author_id != accountProfileId) {
|
|
std::advance(interactionIt, allInteractions.nbrOfCols);
|
|
continue;
|
|
}
|
|
// migrate body+type ==> msgFlag+duration
|
|
auto migratedMsg = migrateMessageBody(body, type);
|
|
QString profileUri = it == profileIdUriMap.end() ? "" : it->second;
|
|
// clear author uri if outgoing
|
|
switch (migratedMsg.first) {
|
|
case msgFlag::IS_OUTGOING:
|
|
case msgFlag::IS_CONTACT_ADDED:
|
|
profileUri.clear();
|
|
break;
|
|
case msgFlag::IS_INCOMING:
|
|
case msgFlag::IS_INVITATION_RECEIVED:
|
|
case msgFlag::IS_INVITATION_ACCEPTED: {
|
|
// try to set profile uri using the conversation id
|
|
auto it = convIdPeerUriMap.find(convId);
|
|
if (it == convIdPeerUriMap.end()) {
|
|
std::advance(interactionIt, allInteractions.nbrOfCols);
|
|
continue;
|
|
}
|
|
profileUri = it->second;
|
|
break;
|
|
}
|
|
case msgFlag::IS_TEXT:
|
|
default:
|
|
break;
|
|
}
|
|
// Set all read, call and datatransfer, and contact added
|
|
// interactions to a read state
|
|
bool is_read = statusStr != "UNREAD" || type == api::interaction::Type::CALL
|
|
|| type == api::interaction::Type::CONTACT;
|
|
// migrate status
|
|
if (migratedMsg.first == msgFlag::IS_INVITATION_RECEIVED) {
|
|
statusStr = "UNKNOWN";
|
|
}
|
|
QString extra_data = migratedMsg.second == 0
|
|
? ""
|
|
: JSONStringFromInitList(
|
|
{qMakePair(QString("duration"),
|
|
QJsonValue(QString::number(migratedMsg.second)))});
|
|
if (accountUri == profileUri)
|
|
profileUri.clear();
|
|
auto typeStr = api::interaction::to_string(type);
|
|
try {
|
|
db->insertInto("interactions",
|
|
{{":author", "author"},
|
|
{":conversation", "conversation"},
|
|
{":timestamp", "timestamp"},
|
|
{":body", "body"},
|
|
{":type", "type"},
|
|
{":status", "status"},
|
|
{":is_read", "is_read"},
|
|
{":daemon_id", "daemon_id"},
|
|
{":extra_data", "extra_data"}},
|
|
{{":author", profileUri},
|
|
{":conversation", convId},
|
|
{":timestamp", timestamp},
|
|
{migratedMsg.first != msgFlag::IS_TEXT ? "" : ":body", body},
|
|
{":type", api::interaction::to_string(type)},
|
|
{":status", interaction::to_migrated_status_string(statusStr)},
|
|
{":is_read", is_read ? "1" : "0"},
|
|
{daemonId.isEmpty() ? "" : ":daemon_id", daemonId},
|
|
{extra_data.isEmpty() ? "" : ":extra_data", extra_data}});
|
|
} catch (const std::runtime_error& e) {
|
|
qWarning() << e.what();
|
|
}
|
|
std::advance(interactionIt, allInteractions.nbrOfCols);
|
|
}
|
|
qDebug() << "Done";
|
|
}
|
|
|
|
} // namespace migration
|
|
|
|
std::vector<std::shared_ptr<Database>>
|
|
migrateIfNeeded(const QStringList& accountIds, MigrationCb& willMigrateCb, MigrationCb& didMigrateCb)
|
|
{
|
|
using namespace lrc::api;
|
|
using namespace migration;
|
|
|
|
std::vector<std::shared_ptr<Database>> dbs(accountIds.size());
|
|
|
|
if (!accountIds.size()) {
|
|
qDebug() << "No accounts to migrate";
|
|
return dbs;
|
|
}
|
|
|
|
auto appPath = getPath();
|
|
|
|
// ring -> jami path migration
|
|
QDir dataDir(appPath);
|
|
// create data directory if not created yet
|
|
dataDir.mkpath(appPath);
|
|
QDir oldDataDir(appPath);
|
|
oldDataDir.cdUp();
|
|
oldDataDir = oldDataDir.absolutePath()
|
|
#if defined(_WIN32)
|
|
+ "/Savoir-faire Linux/Ring";
|
|
#elif defined(__APPLE__)
|
|
+ "/ring";
|
|
#else
|
|
+ "/gnome-ring";
|
|
#endif
|
|
QStringList filesList = oldDataDir.entryList();
|
|
QString filename;
|
|
QDir dir;
|
|
bool success = true;
|
|
Q_FOREACH (filename, filesList) {
|
|
qDebug() << "Migrate " << oldDataDir.absolutePath() << "/" << filename << " to "
|
|
<< dataDir.absolutePath() + "/" + filename;
|
|
if (filename != "." && filename != "..") {
|
|
success &= dir.rename(oldDataDir.absolutePath() + "/" + filename,
|
|
dataDir.absolutePath() + "/" + filename);
|
|
}
|
|
}
|
|
if (success) {
|
|
// Remove old directory if the migration is successful.
|
|
#if defined(_WIN32)
|
|
oldDataDir.cdUp();
|
|
#endif
|
|
oldDataDir.removeRecursively();
|
|
}
|
|
|
|
bool needsMigration = false;
|
|
std::map<QString, bool> hasMigratedData;
|
|
for (const auto& accountId : accountIds) {
|
|
auto hasMigratedDb = QFile(appPath + accountId + "/history.db").exists()
|
|
&& !QFile(appPath + accountId + "/history.db-journal").exists();
|
|
hasMigratedData.insert(std::make_pair(accountId, hasMigratedDb));
|
|
needsMigration |= !hasMigratedDb;
|
|
}
|
|
if (!needsMigration) {
|
|
// if there's any lingering pre-migration data, remove it
|
|
QFile(dataDir.absoluteFilePath("ring.db")).remove();
|
|
QDir(dataDir.absoluteFilePath("text/")).removeRecursively();
|
|
QDir(dataDir.absoluteFilePath("profiles/")).removeRecursively();
|
|
QDir(dataDir.absoluteFilePath("peer_profiles/")).removeRecursively();
|
|
qDebug() << "No migration required";
|
|
return dbs;
|
|
}
|
|
|
|
// A fairly long migration may now occur
|
|
std::thread migrateThread(
|
|
[&appPath, &accountIds, &dbs, &didMigrateCb, &dataDir, &hasMigratedData] {
|
|
// 1. migrate old lrc -> new lrc if needed
|
|
// 2. migrate new lrc db version 1 -> db version 1.1 if needed
|
|
// the destructor of LegacyDatabase will remove 'ring.db' and clean out
|
|
// old lrc files
|
|
std::shared_ptr<Database> legacyDb;
|
|
try {
|
|
legacyDb = lrc::DatabaseFactory::create<LegacyDatabase>(appPath);
|
|
} catch (const std::runtime_error& e) {
|
|
qDebug() << "Exception while attempting to load legacy database: " << e.what();
|
|
if (didMigrateCb)
|
|
didMigrateCb();
|
|
return;
|
|
}
|
|
|
|
// attempt to make a backup of ring.db
|
|
{
|
|
QFile dbFile(dataDir.absoluteFilePath("ring.db"));
|
|
if (dbFile.open(QIODevice::ReadOnly)) {
|
|
dbFile.copy(appPath + "ring.db.bak");
|
|
}
|
|
}
|
|
|
|
// 3. migrate db version 1.1 -> per account dbs version 1
|
|
int index = 0;
|
|
for (const auto& accountId : accountIds) {
|
|
if (hasMigratedData.at(accountId)) {
|
|
index++;
|
|
continue;
|
|
}
|
|
qDebug() << "Migrating account: " << accountId << "...";
|
|
// try to remove the transaction journal from a failed migration
|
|
QFile(appPath + accountId + "/history.db-journal").remove();
|
|
try {
|
|
QSqlDatabase::database().transaction();
|
|
auto dbName = QString::fromStdString(accountId.toStdString() + "/history");
|
|
dbs.at(index) = lrc::DatabaseFactory::create<Database>(dbName, appPath);
|
|
auto& db = dbs.at(index++);
|
|
migration::migrateAccountDb(accountId, db, legacyDb);
|
|
QSqlDatabase::database().commit();
|
|
} catch (const std::runtime_error& e) {
|
|
qWarning().noquote() << "Could not migrate database for account: " << accountId
|
|
<< "\n " << e.what();
|
|
QSqlDatabase::database().rollback();
|
|
}
|
|
}
|
|
|
|
// done
|
|
if (didMigrateCb)
|
|
didMigrateCb();
|
|
});
|
|
|
|
// if willMigrateCb blocks, it must be unblocked by didMigrateCb
|
|
if (willMigrateCb)
|
|
willMigrateCb();
|
|
|
|
migrateThread.join();
|
|
|
|
return dbs;
|
|
}
|
|
|
|
} // namespace storage
|
|
|
|
} // namespace authority
|
|
|
|
} // namespace lrc
|