#include "avatarmanager.h" #include "../../db/db_manager.h" #include "../../transport/gui_bridge.h" #include #include #include #include #include #include #include #include #include #include extern "C" { #include "chat/chat_core.h" #include "chat/chat_member.h" #include "member_files/member_files.h" #include "mem.h" #include "debug_config.h" } static AvatarManager* s_manager = nullptr; static QString text(const char* s) { return QCoreApplication::translate("ChatGui", s); } QString AvatarManager::key(quint64 group, quint64 owner) { return QString::number(group) + ':' + QString::number(owner); } AvatarManager* AvatarManager::instance() { return s_manager; } AvatarManager::AvatarManager(DbManager* db, QObject* parent) : QObject(parent), m_db(db) { s_manager = this; m_pool.setMaxThreadCount(1); m_timer.setInterval(2000); connect(&m_timer, &QTimer::timeout, this, [this] { if (!m_ready) return; for (auto group : m_servers.keys()) scheduleOwn(group); for (const auto& context : m_wanted) if (m_refs.contains(context)) image(QString::number(m_refs[context].group), m_refs[context].owner); pump(); }); m_timer.start(); } AvatarManager::~AvatarManager() { m_timer.stop(); s_manager = nullptr; m_pool.waitForDone(); for (auto id : m_jobs.keys()) { if (!gui_bridge_is_uasync_ready()) break; auto* q = (mf_cancel_request*)u_calloc(1, sizeof(mf_cancel_request)); if (q) { q->inst = gui_bridge_get_inst(); q->id = id; gui_bridge_post_uasync_fn(member_files_gui_cancel, q); } } } void AvatarManager::initialize() { if (!m_db || !m_db->isOpen() || !m_db->myNodeId() || m_ready || !m_directory.isEmpty()) return; const QString base = m_db->dbPath().isEmpty() ? QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) : m_db->dbPath(); m_directory = base + QStringLiteral("/avatar_cache/") + QString::number(m_db->myNodeId(), 16); const QString desired = m_db->getUiState("avatar_default"); m_defaultConfigured = !desired.isEmpty(); if (desired != "-" && !desired.isEmpty()) { const auto parts = desired.split('/'); bool valid = false; quint64 server = parts[0].toULongLong(&valid, 16); if (parts.size() == 2 && valid && server && mf_avatar_manifest_valid(parts[1].toLatin1().constData())) { m_defaultServer = server; m_defaultHash = parts[1].left(64); } else { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: invalid saved default reference"); m_defaultConfigured = false; } } const QString directory = m_directory; const QStringList pinned = m_defaultHash.isEmpty() ? QStringList() : QStringList{m_defaultHash}; m_pool.start(QRunnable::create([this, directory, pinned] { bool ready = AvatarCache::initialize(directory, pinned); QMetaObject::invokeMethod(this, [this, ready] { m_ready = ready; if (!ready) { emit statusChanged(text("Cannot open avatar cache")); return; } DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "avatar: cache ready path=%s", qPrintable(m_directory)); if (!m_defaultHash.isEmpty()) loadCache(m_defaultHash); refreshMembers(); pump(); }, Qt::QueuedConnection); })); } void AvatarManager::refreshMembers() { if (!m_db || !m_db->isOpen() || !gui_bridge_is_uasync_ready()) return; for (const auto& channel : m_db->getChannels()) { DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "avatar: request member snapshot channel=%s", qPrintable(channel.channelId)); auto* q = (chat_member_list_req*)u_calloc(1, sizeof(chat_member_list_req)); if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "avatar: member request allocation failed"); return; } q->inst = gui_bridge_get_inst(); const QByteArray id = channel.channelId.toLatin1(); if (id.size() >= int(sizeof(q->ch_id))) { u_free(q); continue; } memcpy(q->ch_id, id.constData(), id.size()); gui_bridge_post_uasync_fn(chat_core_request_member_list_trampoline, q); } } void AvatarManager::onMembers(const QString& channel, int count, const uint8_t* data, int length, bool full) { const quint64 group = channel.toULongLong(); if (!group || count < 0 || count > length / CHAT_MEMBER_DISPLAY_SIZE) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: malformed member snapshot channel=%s count=%d bytes=%d", qPrintable(channel), count, length); return; } QSet seen; const auto previousServers = m_servers.value(group); bool referencesChanged = false; if (full) m_servers[group].clear(); for (int i = 0; i < count; i++) { chat_member_display member{}; chat_core_deserialize_member(data + i * CHAT_MEMBER_DISPLAY_SIZE, &member); const QString context = key(group, member.node_id); seen.insert(context); if (member.flags & CHAT_MEMBER_FLAG_DELETED) { removeMember(channel, member.node_id); continue; } if (member.storage_role) m_servers[group][member.node_id] = QString::fromUtf8(member.name); else m_servers[group].remove(member.node_id); Ref ref{group, member.node_id, member.avatar_server, QString::fromLatin1(member.avatar_file).left(64)}; if (!ref.server || !mf_avatar_manifest_valid(member.avatar_file)) { ref.server = 0; ref.hash.clear(); } const Ref old = m_refs.value(context); m_refs[context] = ref; if (old.hash != ref.hash || old.server != ref.server) { referencesChanged = true; m_retry.remove(context); m_failures.remove(context); emit changed(channel, ref.owner); } } if (full) for (const auto& context : m_refs.keys()) if (m_refs[context].group == group && !seen.contains(context)) removeMember(channel, m_refs[context].owner); const bool serversChangedNow = previousServers != m_servers.value(group); if (serversChangedNow) emit serversChanged(); scheduleOwn(group); pump(); if (full || referencesChanged || serversChangedNow) DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "avatar: member snapshot channel=%s count=%d full=%d servers=%d refs=%d", qPrintable(channel), count, int(full), m_servers.value(group).size(), m_refs.size()); } void AvatarManager::removeMember(const QString& channel, quint64 owner) { const quint64 group = channel.toULongLong(); const QString context = key(group, owner); m_refs.remove(context); m_wanted.remove(context); if (m_servers[group].remove(owner)) emit serversChanged(); emit changed(channel, owner); } QMap AvatarManager::servers() const { QMap result; for (const auto& group : m_servers) for (auto it = group.begin(); it != group.end(); ++it) result[it.key()] = it.value(); return result; } QImage AvatarManager::image(const QString& channel, quint64 owner) { if (!channel.toULongLong()) { quint64 selected = 0; for (const auto& ref : m_refs) if (ref.owner == owner && ref.server && !ref.hash.isEmpty() && (!selected || ref.group < selected)) selected = ref.group; return selected ? image(QString::number(selected), owner) : QImage(); } const QString context = key(channel.toULongLong(), owner); const Ref ref = m_refs.value(context); if (!m_ready || !ref.server || ref.hash.isEmpty()) return {}; m_wanted.insert(context); return fetch(ref, false, false); } QImage AvatarManager::fetch(const Ref& ref, bool original, bool gallery) { const QString cacheKey = ref.hash + (original ? ":original" : ""); if (auto* image = m_images.object(cacheKey)) return *image; if (!m_missing.contains(cacheKey)) { loadCache(ref.hash, original); return {}; } if (m_retry.value(cacheKey) > QDateTime::currentMSecsSinceEpoch()) return {}; if (!m_servers.value(ref.group).contains(ref.server)) return {}; Job job; job.ref = ref; job.original = original; job.gallery = gallery; job.generation = m_generation; const auto record = m_records.value(ref.hash); job.file = record.original.isEmpty() ? ref.hash + ".json" : (original ? record.original : record.preview); if (record.original.isEmpty()) { for (const auto& active : m_jobs) if (!active.upload && active.ref.hash == ref.hash && active.file == job.file) return {}; for (const auto& queued : m_queue) if (!queued.upload && queued.ref.hash == ref.hash && queued.file == job.file) return {}; } enqueue(job); pump(); return {}; } void AvatarManager::openGallery(const QString& channel, quint64 owner) { closeGallery(); m_galleryRef = m_refs.value(key(channel.toULongLong(), owner)); if (!channel.toULongLong()) for (const auto& ref : m_refs) if (ref.owner == owner && ref.server && (!m_galleryRef.group || ref.group < m_galleryRef.group)) m_galleryRef = ref; if (!m_galleryRef.server || m_galleryRef.hash.isEmpty()) { emit galleryError(text("No avatar")); return; } m_gallery = {m_galleryRef.hash}; emit galleryChanged(); listGallery(); DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "avatar: gallery opened group=%llu owner=%016llx server=%016llx", (unsigned long long)m_galleryRef.group, (unsigned long long)owner, (unsigned long long)m_galleryRef.server); } void AvatarManager::closeGallery() { m_galleryEpoch++; if (m_listId && gui_bridge_is_uasync_ready()) { auto* q = (mf_cancel_request*)u_calloc(1, sizeof(mf_cancel_request)); if (q) { q->inst = gui_bridge_get_inst(); q->id = m_listId; gui_bridge_post_uasync_fn(member_files_gui_cancel, q); } else DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "avatar: gallery cancel allocation failed"); } m_listId = 0; m_galleryRef = {}; m_gallery.clear(); m_catalog.clear(); m_listRetries = 0; auto needed = [this](const Job& job) { const QString context = key(job.ref.group, job.ref.owner); return !job.original && m_wanted.contains(context) && m_refs.value(context).hash == job.ref.hash; }; for (auto id : m_jobs.keys()) { auto& job = m_jobs[id]; if (!job.gallery) continue; if (needed(job)) { job.gallery = false; continue; } if (gui_bridge_is_uasync_ready()) { auto* q = (mf_cancel_request*)u_calloc(1, sizeof(mf_cancel_request)); if (q) { q->inst = gui_bridge_get_inst(); q->id = id; gui_bridge_post_uasync_fn(member_files_gui_cancel, q); } else DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "avatar: cancel allocation failed id=%llu", (unsigned long long)id); } finish(id, MF_CANCELLED); } QQueue keep; while (!m_queue.isEmpty()) { auto job = m_queue.dequeue(); if (job.gallery && needed(job)) job.gallery = false; if (!job.gallery) keep.enqueue(job); } m_queue = keep; } void AvatarManager::listGallery(quint64 cursor, const QByteArray& version) { if (!gui_bridge_is_uasync_ready() || !m_galleryRef.server) return; auto* q = (mf_gui_request*)u_calloc(1, sizeof(mf_gui_request)); if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "avatar: catalog allocation failed"); emit galleryError(text("Cannot load avatars")); return; } do { m_listId = QRandomGenerator::global()->generate64(); } while (!m_listId || m_jobs.contains(m_listId)); m_listCursor = cursor; q->inst = gui_bridge_get_inst(); q->group = m_galleryRef.group; q->server = m_galleryRef.server; q->message.kind = MF_REQUEST; q->message.command = MF_LIST; q->message.request_id = m_listId; q->message.owner = m_galleryRef.owner; q->message.cursor = cursor; memcpy(q->message.path, "avatars", 8); if (version.size() == 16) memcpy(q->message.version, version.constData(), 16); gui_bridge_post_uasync_fn(member_files_gui_request, q); } QImage AvatarManager::galleryImage(const QString& hash, bool original) { if (!m_galleryRef.server || !m_gallery.contains(hash)) return {}; Ref ref = m_galleryRef; ref.hash = hash; return fetch(ref, original, true); } void AvatarManager::retryGallery() { for (const auto& hash : m_gallery) { m_retry.remove(hash); m_retry.remove(hash + ":original"); } if (!m_listId) { m_catalog.clear(); m_listRetries = 0; listGallery(); } emit galleryChanged(); } QImage AvatarManager::defaultImage() const { auto* image = m_images.object(m_defaultHash); return image ? *image : QImage(); } void AvatarManager::notify(const QString& hash) { for (const auto& ref : m_refs) if (ref.hash == hash) emit changed(QString::number(ref.group), ref.owner); emit serversChanged(); if (m_gallery.contains(hash)) emit galleryChanged(); } void AvatarManager::loadCache(const QString& hash, bool original) { const QString cacheKey = hash + (original ? ":original" : ""); if (m_loading.contains(cacheKey)) return; m_loading.insert(cacheKey); const QString path = m_directory + '/' + hash + QStringLiteral(".json"); m_pool.start(QRunnable::create([this, hash, path, cacheKey, original] { AvatarCache::Result result = AvatarCache::load(path, hash); if (original && !result.record.original.isEmpty()) { auto full = AvatarCache::readObject(QFileInfo(path).absolutePath() + '/' + result.record.original, result.record.original); result.image = full.image; result.error = full.error; } if (!result.error.isEmpty() && result.record.original.isEmpty() && QFile::exists(path)) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: invalid cached image hash=%s reason=%s", qPrintable(hash), qPrintable(result.error)); if (!QFile::remove(path)) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: cannot remove invalid cache hash=%s", qPrintable(hash)); } QMetaObject::invokeMethod(this, [this, hash, cacheKey, result] { m_loading.remove(cacheKey); if (!result.record.original.isEmpty()) m_records[hash] = result.record; if (result.image.isNull()) m_missing.insert(cacheKey); else { DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "avatar: disk cache hit hash=%s", qPrintable(hash)); m_images.insert(cacheKey, new QImage(result.image), qMax(1, int(result.image.sizeInBytes() / 1024))); m_missing.remove(cacheKey); } notify(hash); }, Qt::QueuedConnection); })); } void AvatarManager::saveDefault() { m_defaultConfigured = true; auto* q = (save_ui_state_arg*)u_calloc(1, sizeof(save_ui_state_arg)); if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "avatar: preference allocation failed"); return; } q->inst = gui_bridge_get_inst(); const QByteArray value = m_defaultServer ? (QString::number(m_defaultServer, 16) + '/' + m_defaultHash + ".json").toLatin1() : QByteArray("-"); const char name[] = "avatar_default"; memcpy(q->data, name, sizeof(name)); memcpy(q->data + sizeof(name), value.constData(), value.size()); gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, q); } void AvatarManager::uploadDefault(const QString& source, quint64 server, const QRectF& crop) { if (!m_ready || m_preparing || !server || !gui_bridge_is_uasync_ready()) return; m_preparing = true; emit statusChanged(text("Preparing avatar…")); const QString directory = m_directory; const quint64 generation = ++m_generation; cancelUploads(); QStringList pinned; if (!m_defaultHash.isEmpty()) pinned << m_defaultHash; for (const auto& job : m_jobs) if (job.upload) pinned << job.ref.hash; m_pool.start(QRunnable::create([this, source, directory, server, generation, pinned, crop] { const auto result = AvatarCache::prepare(source, directory, crop); if (!result.image.isNull()) { auto keep = pinned; keep << result.hash; AvatarCache::prune(directory, keep); } QMetaObject::invokeMethod(this, [this, result, server, generation] { m_preparing = false; if (generation != m_generation) return; if (result.image.isNull()) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: preparation failed reason=%s", qPrintable(result.error)); emit statusChanged(text("Cannot prepare avatar: %1").arg(result.error)); return; } m_defaultHash = result.hash; m_defaultServer = server; saveDefault(); m_records[result.hash] = result.record; m_images.insert(result.hash, new QImage(result.image), qMax(1, int(result.image.sizeInBytes() / 1024))); m_missing.remove(result.hash); emit serversChanged(); emit statusChanged(text("Uploading avatar…")); for (auto group : m_servers.keys()) { m_retry.remove(key(group, m_db->myNodeId())); scheduleOwn(group); } pump(); }, Qt::QueuedConnection); })); } void AvatarManager::cancelUploads() { for (const auto& job : m_jobs) if (job.upload && job.stage != 100 && gui_bridge_is_uasync_ready()) { auto* q = (mf_cancel_request*)u_calloc(1, sizeof(mf_cancel_request)); if (q) { q->inst = gui_bridge_get_inst(); q->id = job.id; gui_bridge_post_uasync_fn(member_files_gui_cancel, q); } } QQueue keep; while (!m_queue.isEmpty()) { auto job = m_queue.dequeue(); if (!job.upload) keep.enqueue(job); } m_queue = keep; } void AvatarManager::clearDefault() { if (!m_ready || !gui_bridge_is_uasync_ready()) return; ++m_generation; cancelUploads(); m_defaultHash.clear(); m_defaultServer = 0; saveDefault(); for (const auto& ref : m_refs) if (ref.owner == m_db->myNodeId()) { Job job; job.ref = ref; job.ref.server = 0; job.ref.hash.clear(); job.upload = true; job.generation = m_generation; enqueue(job); } pump(); emit serversChanged(); } void AvatarManager::scheduleOwn(quint64 group) { const QString context = key(group, m_db->myNodeId()); if (m_ready && m_defaultConfigured && !m_defaultServer && m_refs.value(context).server && m_retry.value(context) <= QDateTime::currentMSecsSinceEpoch()) { Job job; job.ref = {group, m_db->myNodeId(), 0, {}}; job.upload = true; job.generation = m_generation; enqueue(job); return; } if (!m_ready || m_preparing || !m_defaultServer || m_defaultHash.isEmpty() || !m_servers.value(group).contains(m_defaultServer) || m_missing.contains(m_defaultHash)) return; if (!m_images.contains(m_defaultHash)) { loadCache(m_defaultHash); return; } if (!m_refs.contains(context) || m_retry.value(context) > QDateTime::currentMSecsSinceEpoch()) return; const Ref old = m_refs.value(context); if (old.server == m_defaultServer && old.hash == m_defaultHash) return; Job job; job.ref = {group, m_db->myNodeId(), m_defaultServer, m_defaultHash}; job.upload = true; job.generation = m_generation; const auto record = m_records.value(m_defaultHash); if (record.original.isEmpty()) return; job.files = {record.original, record.preview, m_defaultHash + ".json"}; job.file = job.files.first(); enqueue(job); } void AvatarManager::enqueue(const Job& job) { for (const auto& old : m_jobs) if ((old.upload == job.upload && old.ref.group == job.ref.group && old.ref.owner == job.ref.owner && (job.upload || (old.ref.hash == job.ref.hash && old.original == job.original))) || (!job.upload && !old.upload && old.ref.hash == job.ref.hash && old.original == job.original)) return; for (const auto& old : m_queue) if ((old.upload == job.upload && old.ref.group == job.ref.group && old.ref.owner == job.ref.owner && (job.upload || (old.ref.hash == job.ref.hash && old.original == job.original))) || (!job.upload && !old.upload && old.ref.hash == job.ref.hash && old.original == job.original)) return; if (job.upload || job.original) m_queue.prepend(job); else m_queue.enqueue(job); } void AvatarManager::pump() { if (!m_ready || !gui_bridge_is_uasync_ready()) return; while (m_jobs.size() < 2 && !m_queue.isEmpty()) { Job job = m_queue.dequeue(); const QString context = key(job.ref.group, job.ref.owner); if (!m_refs.contains(context) || (job.upload && job.generation != m_generation) || (job.gallery && (job.ref.group != m_galleryRef.group || job.ref.owner != m_galleryRef.owner || job.ref.server != m_galleryRef.server)) || (!job.upload && !job.gallery && (m_refs[context].hash != job.ref.hash || m_refs[context].server != job.ref.server))) continue; do { job.id = QRandomGenerator::global()->generate64(); } while (!job.id || m_jobs.contains(job.id)); job.local = m_directory + '/' + (job.upload ? job.file : QString(".download-%1").arg(job.id)); m_jobs.insert(job.id, job); auto& stored = m_jobs[job.id]; if (job.upload) { if (job.ref.server) post(stored, MF_MKDIR); else publish(stored); } else transfer(stored); } } bool AvatarManager::post(Job& job, int command) { auto* q = (mf_gui_request*)u_calloc(1, sizeof(mf_gui_request)); if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "avatar: request allocation failed"); finish(job.id, MF_IO_ERROR); return false; } q->inst = gui_bridge_get_inst(); q->group = job.ref.group; q->server = job.ref.server; q->message.kind = MF_REQUEST; q->message.command = command; q->message.owner = job.ref.owner; // Каждый новый шаг имеет новый ID: сервер хранит квитанции мутаций по request_id. quint64 id; do { id = QRandomGenerator::global()->generate64(); } while (!id || m_jobs.contains(id)); Job next = job; m_jobs.remove(job.id); next.id = id; next.stage = command; m_jobs.insert(id, next); q->message.request_id = id; const QByteArray path = command == MF_MKDIR ? QByteArray("avatars") : QByteArray("avatars/") + next.file.toLatin1(); memcpy(q->message.path, path.constData(), path.size()); gui_bridge_post_uasync_fn(member_files_gui_request, q); return true; } void AvatarManager::transfer(Job& job) { auto* q = (mf_transfer_request*)u_calloc(1, sizeof(mf_transfer_request)); if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "avatar: transfer allocation failed"); finish(job.id, MF_IO_ERROR); return; } q->inst = gui_bridge_get_inst(); q->id = job.id; q->group = job.ref.group; q->server = job.ref.server; q->owner = job.ref.owner; q->upload = job.upload; q->max_bytes = job.file.endsWith(".json") ? MF_AVATAR_MANIFEST_MAX_BYTES : (!job.upload && !job.original ? AvatarCache::MaxBytes : AvatarCache::OriginalMaxBytes); job.local = m_directory + '/' + (job.upload ? job.file : QString(".download-%1").arg(job.id)); const QByteArray local = job.local.toUtf8(), remote = "avatars/" + job.file.toLatin1(); if (local.size() > MF_PATH_MAX) { u_free(q); finish(job.id, MF_INVALID_PATH); return; } memcpy(q->local, local.constData(), local.size()); memcpy(q->remote, remote.constData(), remote.size()); job.stage = 99; DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "avatar: %s group=%llu owner=%016llx server=%016llx hash=%s", job.upload ? "upload" : "download", (unsigned long long)job.ref.group, (unsigned long long)job.ref.owner, (unsigned long long)job.ref.server, qPrintable(job.ref.hash)); gui_bridge_post_uasync_fn(member_files_gui_transfer, q); } void AvatarManager::publish(Job& job) { if (job.generation != m_generation) { finish(job.id, MF_CANCELLED); return; } auto* q = (chat_avatar_req*)u_calloc(1, sizeof(chat_avatar_req)); if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "avatar: publication allocation failed"); finish(job.id, MF_IO_ERROR); return; } q->inst = gui_bridge_get_inst(); q->id = job.id; q->group = job.ref.group; q->server = job.ref.server; if (job.ref.server) { const QByteArray file = job.ref.hash.toLatin1() + ".json"; memcpy(q->file, file.constData(), file.size()); } job.stage = 100; gui_bridge_post_uasync_fn(chat_core_set_my_avatar_trampoline, q); } void AvatarManager::onResponse(const uint8_t* data, int length) { if (length < 16 + MF_WIRE_HEADER) return; mf_message response{}; if (mf_decode(data + 16, length - 16, &response) < 0) return; if (response.request_id == m_listId) { if (mf_get64(data) != m_galleryRef.group || mf_get64(data + 8) != m_galleryRef.server || response.owner != m_galleryRef.owner || response.command != MF_LIST || strcmp(response.path, "avatars")) return; m_listId = 0; if ((response.status == MF_STALE || response.status == MF_BUSY) && m_listRetries++ < 3) { m_catalog.clear(); const quint64 epoch = m_galleryEpoch; QTimer::singleShot(1000, this, [this, epoch] { if (epoch == m_galleryEpoch) listGallery(); }); return; } if (response.status) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: catalog failed status=%s", mf_status_text(response.status)); emit galleryError(QString::fromUtf8(mf_status_text(response.status))); return; } for (unsigned at = 0; at < response.data_len;) { const uint8_t* p = response.data + at; if (response.data_len - at < MF_ENTRY_HEADER) { emit galleryError(text("Invalid directory response")); return; } const unsigned n = p[0] | (unsigned(p[1]) << 8); if (!n || n > MF_NAME_MAX || n > response.data_len - at - MF_ENTRY_HEADER) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: malformed catalog entry offset=%u", at); emit galleryError(text("Invalid directory response")); return; } const QByteArray file((const char*)p + MF_ENTRY_HEADER, n); if (p[2] == MF_FILE && mf_avatar_manifest_valid(file.constData()) && mf_get64(p + 3) <= MF_AVATAR_MANIFEST_MAX_BYTES) m_catalog[QString::fromLatin1(file).left(64)] = mf_get64(p + 11); at += MF_ENTRY_HEADER + n; } if (response.more) { if (response.cursor <= m_listCursor) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: catalog cursor stalled=%llu", (unsigned long long)response.cursor); emit galleryError(text("Invalid directory response")); return; } listGallery(response.cursor, QByteArray((const char*)response.version, 16)); return; } m_gallery = m_catalog.keys(); std::sort(m_gallery.begin(), m_gallery.end(), [this](const QString& a, const QString& b) { return m_catalog[a] != m_catalog[b] ? m_catalog[a] > m_catalog[b] : a < b; }); if (!m_gallery.contains(m_galleryRef.hash)) m_gallery.prepend(m_galleryRef.hash); DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "avatar: catalog ready entries=%d", m_gallery.size()); emit galleryChanged(); return; } if (!m_jobs.contains(response.request_id)) return; auto& job = m_jobs[response.request_id]; if (job.ref.group != mf_get64(data) || job.ref.server != mf_get64(data + 8) || response.command != job.stage) return; if (job.upload && job.generation != m_generation) { finish(job.id, MF_CANCELLED); return; } if (job.stage == MF_MKDIR && (response.status == MF_OK || response.status == MF_EXISTS)) { post(job, MF_STAT); return; } if (job.stage == MF_STAT && response.status == MF_NOT_FOUND) { transfer(job); return; } if (job.stage == MF_STAT && response.status == MF_OK && response.type == MF_FILE && response.size == uint64_t(QFileInfo(m_directory + '/' + job.file).size())) { nextUpload(job); return; } finish(job.id, response.status ? response.status : MF_INVALID_RANGE); } void AvatarManager::onTransfer(const uint8_t* data, int length) { if (length != 51 || data[50] != 2) return; const quint64 id = mf_get64(data); if (!m_jobs.contains(id)) return; auto& job = m_jobs[id]; const int status = data[48] | (int(data[49]) << 8); if (job.stage != 99 || job.ref.group != mf_get64(data + 8) || job.ref.server != mf_get64(data + 16) || job.ref.owner != mf_get64(data + 24)) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: stale or mismatched transfer completion id=%llu", (unsigned long long)id); return; } if (status) { finish(id, status); return; } if (job.upload) { nextUpload(job); return; } const Job snapshot = job; const QString directory = m_directory; job.stage = 101; QStringList pinned; if (!m_defaultHash.isEmpty()) pinned << m_defaultHash; for (const auto& active : m_jobs) if (active.upload) pinned << active.ref.hash; m_pool.start(QRunnable::create([this, snapshot, directory, pinned] { const auto result = AvatarCache::publish(snapshot.local, directory, snapshot.file); AvatarCache::prune(directory, pinned); QMetaObject::invokeMethod(this, [this, snapshot, result] { if (!m_jobs.contains(snapshot.id)) return; if (!result.error.isEmpty() || (!snapshot.file.endsWith(".json") && !snapshot.original && result.image.size() != QSize(256, 256))) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: validation failed hash=%s reason=%s", qPrintable(snapshot.ref.hash), qPrintable(result.error)); finish(snapshot.id, MF_HASH_MISMATCH); return; } if (snapshot.file.endsWith(".json")) { m_records[snapshot.ref.hash] = result.record; auto& job = m_jobs[snapshot.id]; job.file = snapshot.original ? result.record.original : result.record.preview; transfer(job); notify(snapshot.ref.hash); return; } const QString cacheKey = snapshot.ref.hash + (snapshot.original ? ":original" : ""); m_images.insert(cacheKey, new QImage(result.image), qMax(1, int(result.image.sizeInBytes() / 1024))); m_missing.remove(cacheKey); finish(snapshot.id, MF_OK); notify(snapshot.ref.hash); }, Qt::QueuedConnection); })); } void AvatarManager::nextUpload(Job& job) { if (++job.object == job.files.size()) { publish(job); return; } job.file = job.files[job.object]; post(job, MF_STAT); } void AvatarManager::onPublished(const uint8_t* data, int length) { if (length != 17 || !m_jobs.contains(mf_get64(data))) return; auto& job = m_jobs[mf_get64(data)]; if (job.stage != 100 || job.ref.group != mf_get64(data + 8)) return; finish(job.id, data[16] ? MF_IO_ERROR : MF_OK); } void AvatarManager::finish(quint64 id, int status) { const Job job = m_jobs.take(id); const QString context = key(job.ref.group, job.ref.owner); const Ref current = m_refs.value(context); if ((job.upload && job.generation != m_generation) || (!job.upload && !job.gallery && (current.hash != job.ref.hash || current.server != job.ref.server))) { QTimer::singleShot(0, this, &AvatarManager::pump); return; } if (status && status != MF_CANCELLED) { unsigned attempt = qMin(6u, m_failures.value(context) + 1); m_failures[context] = attempt; const QString retryKey = job.upload ? context : job.ref.hash + (job.original ? ":original" : ""); m_retry[retryKey] = QDateTime::currentMSecsSinceEpoch() + (5000LL << (attempt - 1)); DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: operation failed group=%llu owner=%016llx hash=%s status=%s retry_ms=%lld", (unsigned long long)job.ref.group, (unsigned long long)job.ref.owner, qPrintable(job.ref.hash), mf_status_text(status), (long long)(5000LL << (attempt - 1))); if (job.upload && job.generation == m_generation) emit statusChanged(text("Avatar upload failed: %1").arg(mf_status_text(status))); if (job.gallery) emit galleryError(QString::fromUtf8(mf_status_text(status))); } else if (!status) { m_retry.remove(job.upload ? context : job.ref.hash + (job.original ? ":original" : "")); m_failures.remove(context); if (job.upload && job.generation == m_generation) emit statusChanged(text("Avatar updated")); } QTimer::singleShot(0, this, &AvatarManager::pump); }