Project 'savoirfairelinux/ring-daemon' was moved to 'savoirfairelinux/jami-daemon'. Please update any links and bookmarks that may still have the old path.
Select Git revision
chatview.html
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
storagehelper.cpp 47.12 KiB
/****************************************************************************
* Copyright (C) 2017-2019 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/datatransfer.h"
#include "uri.h"
#include "vcard.h"
#include <account_const.h>
#include <datatransfer_interface.h>
#include <QImage>
#include <QByteArray>
#include <QBuffer>
#include <QJsonObject>
#include <QJsonDocument>
#include <fstream>
#include <thread>
#include <cstring>
namespace lrc
{
namespace authority
{
namespace storage
{
QString getPath()
{
QDir dataDir(QStandardPaths::writableLocation(
QStandardPaths::AppLocalDataLocation));
// Avoid to depends on the client name.
dataDir.cdUp();
return dataDir.absolutePath() + "/jami/";
}
std::string
prepareUri(const std::string& uri, api::profile::Type type)
{
URI uriObject(QString::fromStdString(uri));
switch (type) {
case api::profile::Type::SIP:
return uriObject.format(URI::Section::USER_INFO | URI::Section::HOSTNAME)
.toStdString();
break;
case api::profile::Type::RING:
return uriObject.format(URI::Section::USER_INFO)
.toStdString();
break;
case api::profile::Type::INVALID:
case api::profile::Type::PENDING:
case api::profile::Type::TEMPORARY:
case api::profile::Type::COUNT__:
default:
return uri;
}
}
std::string
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 formattedString;
}
std::string
getCallInteractionString(const std::string& authorUri,
const std::time_t& duration)
{
if (duration < 0) {
if (authorUri.empty()) {
return "📞 " + QObject::tr("Outgoing call").toStdString();
} else {
return "📞 " + QObject::tr("Incoming call").toStdString();
}
} else if (authorUri.empty()) {
if (duration) {
return "📞 " + QObject::tr("Outgoing call").toStdString()
+ " - " + getFormattedCallDuration(duration);
} else {
return "🕽 " + QObject::tr("Missed outgoing call").toStdString();
}
} else {
if (duration) {
return "📞 " + QObject::tr("Incoming call").toStdString()
+ " - " + getFormattedCallDuration(duration);
} else {
return "🕽 " + QObject::tr("Missed incoming call").toStdString();
}
}
}
std::string
getContactInteractionString(const std::string& authorUri,
const api::interaction::Status& status)
{
if (authorUri.empty()) {
return QObject::tr("Contact added").toStdString();
} else {
if (status == api::interaction::Status::UNKNOWN) {
return QObject::tr("Invitation received").toStdString();
} else if (status == api::interaction::Status::SUCCESS) {
return QObject::tr("Invitation accepted").toStdString();
}
}
return {};
}
namespace vcard
{
std::string compressedAvatar(const std::string& image);
void setProfile(const std::string& accountId,
const api::profile::Info& profileInfo,
const bool overwrite,
const bool isPeer);
std::string
compressedAvatar(const std::string& image)
{
QImage qimage;
const bool ret = qimage.loadFromData(QByteArray::fromBase64(image.c_str()), 0);
if (!ret) {
qDebug() << "vCard image loading failed";
return image;
}
QByteArray bArray;
QBuffer buffer(&bArray);
buffer.open(QIODevice::WriteOnly);
qimage.scaled({ 128,128 }).save(&buffer, "JPEG", 90);
auto b64Img = bArray.toBase64().trimmed();
return std::string(b64Img.constData(), b64Img.length());
}
std::string
profileToVcard(const api::profile::Info& profileInfo,
bool compressImage)
{
using namespace api;
bool compressedImage = std::strncmp(profileInfo.avatar.c_str(), "/9j/", 4) == 0;
if (compressedImage && !compressImage) {
compressImage = false;
}
std::string 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::RING) {
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 std::string& accountId,
const api::profile::Info& profileInfo,
const bool isPeer)
{
auto vcard = vcard::profileToVcard(profileInfo);
auto accountLocalPath = getPath() + QString::fromStdString(accountId) + "/";
QString filePath;
QFile file;
if (isPeer) {
filePath = accountLocalPath + "profiles/" +
QString(QByteArray::fromStdString(profileInfo.uri).toBase64()) + ".vcf";
file.setFileName(filePath);
} else {
filePath = accountLocalPath + "profile" + ".vcf";
file.setFileName(filePath);
}
if (!file.open(QIODevice::WriteOnly)) {
qWarning().noquote() << "Can't open file for writing: " << filePath;
return;
}
QTextStream in(&file);
in.setCodec("UTF-8");
in << QString::fromStdString(vcard);
}
} // namespace vcard
std::vector<std::string>
getConversationsWithPeer(Database& db, const std::string& participant_uri)
{
return db.select("id",
"conversations",
"participant=:participant",
{{":participant", participant_uri}}).payloads;
}
std::vector<std::string>
getPeerParticipantsForConversation(Database& db, const std::string& conversationId)
{
return db.select("participant",
"conversations",
"id=:id",
{ {":id", conversationId} }).payloads;
}
void
createOrUpdateProfile(const std::string & accountId,
const api::profile::Info & profileInfo,
const bool isPeer)
{
if (isPeer) {
auto contact = storage::buildContactFromProfile(accountId, profileInfo.uri, profileInfo.type);
if (!profileInfo.alias.empty()) contact.profileInfo.alias = profileInfo.alias;
if (!profileInfo.avatar.empty()) contact.profileInfo.avatar = profileInfo.avatar;
vcard::setProfile(accountId, contact.profileInfo, isPeer);
return;
}
vcard::setProfile(accountId, profileInfo, isPeer);
}
std::string
getAccountAvatar(const std::string& accountId)
{
auto accountLocalPath = getPath() + QString::fromStdString(accountId) + "/";
QString filePath;
filePath = accountLocalPath + "profile.vcf";
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "Can't open file: " << filePath;
return {};
}
QTextStream in(&file);
in.setCodec("UTF-8");
const auto vCard = lrc::vCard::utils::toHashMap(in.readAll().toUtf8());
const auto photo = (vCard.find(vCard::Property::PHOTO_PNG) == vCard.end()) ?
vCard[vCard::Property::PHOTO_JPEG] : vCard[vCard::Property::PHOTO_PNG];
return photo.toStdString();
}
api::contact::Info
buildContactFromProfile(const std::string & accountId,
const std::string& peer_uri,
const api::profile::Type& type)
{
lrc::api::profile::Info profileInfo;
profileInfo.uri = peer_uri;
profileInfo.type = type;
auto accountLocalPath = getPath() + QString::fromStdString(accountId) + "/";
QString b64filePath;
b64filePath = accountLocalPath + "profiles/" + QString(QByteArray::fromStdString(peer_uri).toBase64()) + ".vcf";
QFile file(b64filePath);
if (!file.open(QIODevice::ReadOnly)) {
// try non-base64 path
QString filePath = accountLocalPath + "profiles/" + QString::fromStdString(peer_uri) + ".vcf";
file.setFileName(filePath);
if (!file.open(QIODevice::ReadOnly)) {
qWarning().noquote() << "Can't open file: " << filePath;
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 };
}
}
QTextStream in(&file);
in.setCodec("UTF-8");
QByteArray vcard = in.readAll().toUtf8();
const auto vCard = lrc::vCard::utils::toHashMap(vcard);
const auto alias = vCard[vCard::Property::FORMATTED_NAME];
const auto photo = (vCard.find(vCard::Property::PHOTO_PNG) == vCard.end()) ?
vCard[vCard::Property::PHOTO_JPEG] : vCard[vCard::Property::PHOTO_PNG];
profileInfo.avatar = photo.toStdString();
profileInfo.alias = alias.toStdString();
return { profileInfo, "", true, false };
}
std::vector<std::string> getAllConversations(Database & db)
{
return db.select("id", "conversations", {}, {}).payloads;
}
std::vector<std::string>
getConversationsBetween(Database& db, const std::string& peer1_uri, const std::string& 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());
std::vector<std::string> common;
std::set_intersection(conversationsForPeer1.begin(), conversationsForPeer1.end(),
conversationsForPeer2.begin(), conversationsForPeer2.end(),
std::back_inserter(common));
return common;
}
std::string
beginConversationWithPeer(Database& db, const std::string& peer_uri, const bool isOutgoing)
{
// 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,
{},
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) {
std::string durationString;
auto extra_data_str = QString::fromStdString(payloads[i + 7]);
if (!extra_data_str.isEmpty()) {
auto jsonData = JSONFromString(extra_data_str);
durationString = readJSONValue(jsonData, "duration").toStdString();
}
auto body = payloads[i + 2];
auto type = api::interaction::to_type(payloads[i + 4]);
std::time_t duration = durationString.empty() ? 0 : std::stoi(durationString);
auto status = api::interaction::to_status(payloads[i + 5]);
if (type == api::interaction::Type::CALL) {
body = getCallInteractionString(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]),
duration,
type,
status,
(payloads[i + 6] == "1" ? true : false)
});
conversation.interactions.emplace(std::stoull(payloads[i]), std::move(msg));
conversation.lastMessageUid = std::stoull(payloads[i]);
}
}
}
int
addMessageToConversation(Database& db,
const std::string& 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", std::to_string(msg.timestamp)},
{ ":body", msg.body},
{ ":type", to_string(msg.type)},
{ ":status", to_string(msg.status)},
{ ":is_read", msg.isRead ? "1" : "0" }
});
}
int
addOrUpdateMessage(Database& db,
const std::string& conversationId,
const api::interaction::Info& msg,
const std::string& 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()) {
return db.insertInto("interactions", {
{":author", "author"},
{":conversation", "conversation"},
{":timestamp", "timestamp"},
{":body", "body"},
{":type", "type"},
{":status", "status"},
{":daemon_id", "daemon_id"}
}, {
{":author", msg.authorUri.empty() ? "" : msg.authorUri},
{":conversation", conversationId},
{":timestamp", std::to_string(msg.timestamp)},
{msg.body.empty() ? "" : ":body", msg.body},
{":type", to_string(msg.type)},
{daemonId.empty() ? "" : ":daemon_id", daemonId},
{":status", to_string(msg.status)}
});
} else {
// already exists @ id(msgAlreadyExists[0])
auto id = msgAlreadyExists[0];
std::string 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(QString::fromStdString(extra_data_str));
writeJSONValue(extra_data_JSON, "duration", QString::number(duration));
extra_data = stringFromJSON(extra_data_JSON).toStdString();
}
db.update("interactions",
{ "body=:body, extra_data=:extra_data" },
{ {msg.body.empty() ? "" : ":body", msg.body},
{ extra_data.empty() ? "" : ":extra_data", extra_data } },
"id=:id", { {":id", id} });
return std::stoi(id);
}
}
int
addDataTransferToConversation(Database& db,
const std::string& conversationId,
const api::datatransfer::Info& infoFromDaemon)
{
return db.insertInto("interactions", {
{":author", "author"},
{":conversation", "conversation"},
{":timestamp", "timestamp"},
{":body", "body"},
{":type", "type"},
{":status", "status"},
{":is_read", "is_read"}
}, {
{":author", infoFromDaemon.isOutgoing ? "" : infoFromDaemon.peerUri},
{":conversation", conversationId},
{":timestamp", std::to_string(std::time(nullptr))},
{":body", infoFromDaemon.path},
{":type", infoFromDaemon.isOutgoing ?
"DATA_TRANSFER" :
"DATA_TRANSFER"},
{":status", "TRANSFER_CREATED"},
{":is_read", "0"}
});
}
void addDaemonMsgId(Database& db,
const std::string& interactionId,
const std::string& daemonId)
{
db.update("interactions",
"daemon_id=:daemon_id",
{{":daemon_id", daemonId}},
"id=:id", {{":id", interactionId}});
}
std::string getDaemonIdByInteractionId(Database& db, const std::string& id)
{
auto ids = db.select("daemon_id",
"interactions",
"id=:id",
{{":id", id}}).payloads;
return ids.empty() ? "" : ids[0];
}
std::string getInteractionIdByDaemonId(Database& db, const std::string& daemon_id)
{
auto ids = db.select("id",
"interactions",
"daemon_id=:daemon_id",
{{":daemon_id", daemon_id}}).payloads;
return ids.empty() ? "" : ids[0];
}
std::string getInteractionExtraDataById(Database& db, const std::string& id,
const std::string& key)
{
auto extra_datas = db.select("extra_data",
"interactions",
"id=:id",
{ {":id", id} }).payloads;
if (key.empty()) {
return extra_datas.empty() ? "" : extra_datas[0];
}
std::string value;
auto extra_data_str = QString::fromStdString(extra_datas[0]);
if (!extra_data_str.isEmpty()) {
value = readJSONValue(JSONFromString(extra_data_str), QString::fromStdString(key))
.toStdString();
}
return value;
}
void updateInteractionBody(Database& db, unsigned int id,
const std::string& newBody)
{
db.update("interactions", "body=:body",
{{":body", newBody}},
"id=:id", {{":id", std::to_string(id)}});
}
void updateInteractionStatus(Database& db, unsigned int id,
api::interaction::Status newStatus)
{
db.update("interactions",
{ "status=:status" },
{{":status", api::interaction::to_string(newStatus)}},
"id=:id", {{":id", std::to_string(id)}});
}
void setInteractionRead(Database& db, unsigned int id)
{
db.update("interactions",
{ "is_read=:is_read" },
{ {":is_read", "1"} },
"id=:id", { {":id", std::to_string(id)} });
}
std::string
conversationIdFromInteractionId(Database& db, unsigned int interactionId)
{
auto result = db.select("conversation",
"interactions",
"id=:id",
{{":id", std::to_string(interactionId)}});
if (result.nbrOfCols == 1 && result.payloads.size()) {
return result.payloads[0];
}
return {};
}
void clearHistory(Database& db,
const std::string& conversationId)
{
db.deleteFrom("interactions",
"conversation=:conversation",
{{":conversation", conversationId}});
}
void clearInteractionFromConversation(Database& db,
const std::string& conversationId,
const uint64_t& interactionId)
{
db.deleteFrom("interactions",
"conversation=:conversation AND id=:id",
{{":conversation", conversationId},
{":id", std::to_string(interactionId)}});
}
void clearAllHistory(Database& db)
{
db.truncateTable("interactions");
}
void
deleteObsoleteHistory(Database& db, long int date)
{
db.deleteFrom("interactions", "timestamp<=:date", { {":date", std::to_string(date)} });
}
void
removeContact(Database& db, const std::string& contactUri)
{
// Get common conversations
auto conversations = getConversationsWithPeer(db, contactUri);
// Remove conversations + interactions
for (const auto& conversationId: conversations) {
// Remove conversation
db.deleteFrom("conversations", "id=:id", {{":id", conversationId}});
// clear History
db.deleteFrom("interactions", "conversation=:id", {{":id", conversationId}});
}
}
int
countUnreadFromInteractions(Database& db, const std::string& 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].empty()) {
result = std::stoull(timestamps[0]);
}
} 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;
}
namespace {
QString stringFromJSON(const QJsonObject& json)
{
QJsonDocument doc(json);
return QString::fromLocal8Bit(doc.toJson(QJsonDocument::Compact));
}
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;
}
QString JSONStringFromInitList(const std::initializer_list<QPair<QString, QJsonValue>> args)
{
QJsonObject jsonObject(args);
return stringFromJSON(jsonObject);
}
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 {};
}
void
writeJSONValue(QJsonObject& json, const QString& key, const QString& value)
{
json[key] = value;
}
}
//================================================================================
// 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
};
std::string profileToVcard(const lrc::api::profile::Info&, const std::string&);
uint64_t getTimeFromTimeStr(const std::string&) noexcept;
std::pair<msgFlag, uint64_t> migrateMessageBody(const std::string&,
const lrc::api::interaction::Type&);
std::vector<std::string> getPeerParticipantsForConversationId(lrc::Database&,
const std::string&,
const std::string&);
void migrateAccountDb(const QString&,
std::shared_ptr<lrc::Database>,
std::shared_ptr<lrc::Database>);
namespace interaction {
static inline api::interaction::Type
to_type(const std::string& 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 std::string
to_migrated_status_string(const std::string& 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
std::string
profileToVcard(const api::profile::Info& profileInfo,
const std::string& accountId = {})
{
using namespace api;
bool compressedImage = std::strncmp(profileInfo.avatar.c_str(), "/9g=", 4) == 0;;
std::string 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.empty()) {
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::RING) {
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 std::string& str) noexcept
{
uint64_t minutes = 0, seconds = 0;
std::size_t delimiterPos = str.find(":");
if (delimiterPos != std::string::npos) {
try {
minutes = std::stoull(str.substr(0, delimiterPos));
seconds = std::stoull(str.substr(delimiterPos + 1));
} catch (const std::exception&) {
return 0;
}
}
return minutes * 60 + seconds;
}
std::pair<msgFlag, uint64_t>
migrateMessageBody(const std::string& 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 qstrBody = QString::fromStdString(body);
switch (type) {
case api::interaction::Type::CALL:
{
bool en_missedOut = qstrBody.contains(emo);
bool en_out = qstrBody.contains(eo);
bool loc_missedOut = qstrBody.contains(lmo);
bool loc_out = qstrBody.contains(lo);
bool outgoingCall = en_missedOut || en_out || loc_missedOut || loc_out;
std::size_t dashPos = body.find("-");
if (dashPos != std::string::npos) {
duration = getTimeFromTimeStr(body.substr(dashPos + 2));
}
return std::make_pair(msgFlag(outgoingCall),
duration);
}
break;
case api::interaction::Type::CONTACT:
if (qstrBody.contains(eca) || qstrBody.contains(lca)) {
return std::make_pair(msgFlag::IS_CONTACT_ADDED, 0);
} else if (qstrBody.contains(eir) || qstrBody.contains(lir)) {
return std::make_pair(msgFlag::IS_INVITATION_RECEIVED, 0);
} else if (qstrBody.contains(eia) || qstrBody.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);
}
std::vector<std::string>
getPeerParticipantsForConversationId(Database& db, const std::string& profileId, const std::string& 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 DRing::Account;
MapStringString accountDetails = ConfigurationManager::instance().
getAccountDetails(accountId.toStdString().c_str());
bool isRingAccount = accountDetails[ConfProperties::TYPE] == "RING";
std::map<std::string, std::string> profileIdUriMap;
std::map<std::string, std::string> convIdPeerUriMap;
std::string accountProfileId;
// 1. profiles_accounts
// migrate account's avatar/alias from profiles table to {data_dir}/profile.vcf
std::string accountUri;
if (isRingAccount) {
accountUri = accountDetails[DRing::Account::ConfProperties::USERNAME].contains("ring:") ?
accountDetails[DRing::Account::ConfProperties::USERNAME].toStdString().substr(std::string("ring:").size()) :
accountDetails[DRing::Account::ConfProperties::USERNAME].toStdString();
} else {
accountUri = accountDetails[DRing::Account::ConfProperties::USERNAME].toStdString();
}
auto accountProfileIds = legacyDb->select(
"profile_id", "profiles_accounts",
"account_id=:account_id AND is_account=:is_account",
{ {":account_id", accountId.toStdString()},
{":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::RING : profile::Type::SIP };
}
auto accountVcard = profileToVcard(accountProfileInfo, accountId.toStdString());
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) << QString::fromStdString(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.toStdString()},
{":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(QString::fromStdString(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.toStdString()));
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) << QString::fromStdString(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);
auto 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";
}
std::string extra_data = migratedMsg.second == 0 ? "" :
JSONStringFromInitList({
qMakePair(QString("duration"),
QJsonValue(QString::number(migratedMsg.second)))
})
.toStdString();
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.empty() ? "" : ":daemon_id", daemonId},
{extra_data.empty() ? "" : ":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;
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 database
} // namespace authority
} // namespace lrc