diff options
| author | Erik Kundiman <erik@megapahit.org> | 2026-10-10 13:02:06 +0800 |
|---|---|---|
| committer | Erik Kundiman <erik@megapahit.org> | 2026-10-10 13:02:06 +0800 |
| commit | 7a75f495974c2d6a6be66614fbc526fa666279b5 (patch) | |
| tree | b71db46012b3a6c85bbd457dfba88eeb5a30e284 /indra | |
| parent | a36c78d643d759147b90bdd52753cc506702050c (diff) | |
| parent | db59a05ae2b69b87175ac1da5f87a532f47fce1a (diff) | |
Merge tag 'Second_Life_Release#db59a05a-26.5' into 26.5
Diffstat (limited to 'indra')
40 files changed, 6357 insertions, 227 deletions
diff --git a/indra/llcommon/llfile.cpp b/indra/llcommon/llfile.cpp index 114df29f6e..a33fcdc9c4 100644 --- a/indra/llcommon/llfile.cpp +++ b/indra/llcommon/llfile.cpp @@ -188,7 +188,7 @@ std::string strerr(int errn) #if LL_WINDOWS && 0 // turn on to debug file-locking problems #define PROCESS_LOCKING_CHECK 1 -static void find_locking_process(const std::string& filename) +static void find_locking_process(const std::filesystem::path& filename) { // Only do any of this stuff (before LL_ENDL) if it will be logged. LL_DEBUGS("LLFile") << ""; @@ -200,14 +200,14 @@ static void find_locking_process(const std::string& filename) } else { - std::string tf(TEMP); - tf += "handle.tmp"; + std::filesystem::path tf = fsyspath(TEMP) / "handle.tmp"; + std::string utf8name = fsyspath(filename).string(); // http://technet.microsoft.com/en-us/sysinternals/bb896655 - std::string cmd(STRINGIZE("handle \"" << filename - // "openfiles /query /v | fgrep -i \"" << filename - << "\" > \"" << tf << '"')); + std::string cmd(STRINGIZE("handle \"" << utf8name + // "openfiles /query /v | fgrep -i \"" << utf8name + << "\" > \"" << fsyspath(tf).string() << '"')); LL_CONT << cmd; - if (system(cmd.c_str()) != 0) + if (_wsystem(ll_convert<std::wstring>(cmd).c_str()) != 0) { LL_CONT << "\nDownload 'handle.exe' from http://technet.microsoft.com/en-us/sysinternals/bb896655"; } @@ -238,7 +238,7 @@ static int warnif(std::string_view desc, const std::filesystem::path& filename, // EEXIST. Don't log a warning if caller explicitly says this errno is okay. if (errn != suppress_warning) { - LL_WARNS("LLFile") << "Couldn't " << desc << " '" << filename << "' (errno " << errn << "): " << strerr(errn) << LL_ENDL; + LL_WARNS("LLFile") << "Couldn't " << desc << " '" << fsyspath(filename).string() << "' (errno " << errn << "): " << strerr(errn) << LL_ENDL; } #if PROCESS_LOCKING_CHECK // If the problem is "Permission denied," maybe it's because another @@ -267,7 +267,7 @@ static int warnif(std::string_view desc, const std::filesystem::path& filename, // Don't warn if caller explicitly says this errno is okay. if (errn != suppress_warning) { - LL_WARNS("LLFile") << "Couldn't " << desc << " '" << filename << "' (errno " << errn << "): " << ec.message() << LL_ENDL; + LL_WARNS("LLFile") << "Couldn't " << desc << " '" << fsyspath(filename).string() << "' (errno " << errn << "): " << ec.message() << LL_ENDL; } #if PROCESS_LOCKING_CHECK // Try to detect locked files by other processes @@ -988,7 +988,7 @@ bool LLFile::copy(const std::filesystem::path& source_path, const std::filesyste bool copied = std::filesystem::copy_file(source_path, target_path, options, ec); if (!copied) { - warnif(STRINGIZE("copy failed, to '" << target_path << "' from"), source_path, ec); + warnif(STRINGIZE("copy failed, to '" << fsyspath(target_path).string() << "' from"), source_path, ec); } return copied; } diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 3348c192b3..21e230bd35 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -132,6 +132,8 @@ set(viewer_SOURCE_FILES llchathistory.cpp llchatitemscontainerctrl.cpp llchatmsgbox.cpp + llchatservicehistory.cpp + llchatservicehistorycore.cpp llchiclet.cpp llchicletbar.cpp llclassifiedinfo.cpp @@ -827,6 +829,8 @@ set(viewer_HEADER_FILES llchathistory.h llchatitemscontainerctrl.h llchatmsgbox.h + llchatservicehistory.h + llchatservicehistorycore.h llchiclet.h llchicletbar.h llclassifiedinfo.h @@ -2735,6 +2739,8 @@ if (LL_TESTS) include(LLAddBuildTest) SET(viewer_TEST_SOURCE_FILES llagentaccess.cpp + llchatservicehistorycore.cpp + llconversationlog.cpp lldateutil.cpp # llmediadataclient.cpp lllogininstance.cpp diff --git a/indra/newview/app_settings/settings.xml b/indra/newview/app_settings/settings.xml index 60775625b9..ae8df15d44 100644 --- a/indra/newview/app_settings/settings.xml +++ b/indra/newview/app_settings/settings.xml @@ -17463,6 +17463,17 @@ <key>Value</key> <integer>0</integer> </map> +<key>ChatServiceEnabled</key> +<map> + <key>Comment</key> + <string>Enable accumulated direct-message history from Chat Service for this login.</string> + <key>Persist</key> + <integer>1</integer> + <key>Type</key> + <string>Boolean</string> + <key>Value</key> + <integer>1</integer> +</map> <key>SingleModeDoubleClickOpenWindow</key> <map> <key>Comment</key> diff --git a/indra/newview/llappviewer.cpp b/indra/newview/llappviewer.cpp index cd30ed685a..c7b56c526c 100644 --- a/indra/newview/llappviewer.cpp +++ b/indra/newview/llappviewer.cpp @@ -244,6 +244,7 @@ #include "lldrawpoolbump.h" #include "llvieweraudio.h" #include "llimview.h" +#include "llchatservicehistory.h" #include "llviewerthrottle.h" #include "llparcel.h" #include "llavatariconctrl.h" @@ -5797,6 +5798,8 @@ void LLAppViewer::idleShutdown() // All floaters are closed. Tell server we want to quit. if (!logoutRequestSent()) { + // Fence outstanding history work before logout tears down its account and capability context. + LLChatServiceHistory::stop(); sendLogoutRequest(); // Wait for a LogoutReply message @@ -6213,6 +6216,9 @@ void LLAppViewer::idleNetwork() void LLAppViewer::disconnectViewer() { + // This is idempotent and also covers disconnect paths that bypass the normal logout request. + LLChatServiceHistory::stop(); + if (gDisconnected) { return; @@ -6575,6 +6581,8 @@ void LLAppViewer::handleLoginComplete() gDebugInfo["MainloopTimeoutState"] = LLAppViewer::instance()->mMainloopTimeout->getState(); } + // Start only after login has established the per-account storage and region capability context. + LLChatServiceHistory::start(); mOnLoginCompleted(); writeDebugInfo(); diff --git a/indra/newview/llavataractions.cpp b/indra/newview/llavataractions.cpp index 85d69f0a30..8b6c0ed493 100644 --- a/indra/newview/llavataractions.cpp +++ b/indra/newview/llavataractions.cpp @@ -43,6 +43,7 @@ #include "llconversationlog.h" #include "llfloateravatarpicker.h" // for LLFloaterAvatarPicker #include "llfloaterconversationpreview.h" +#include "llchatservicehistory.h" #include "llfloatergroupinvite.h" #include "llfloatergroups.h" #include "llfloaterreg.h" @@ -1287,6 +1288,12 @@ void LLAvatarActions::inviteToGroup(const LLUUID& id) // static void LLAvatarActions::viewChatHistory(const LLUUID& id) { + // A pending account-wide deletion suppresses both transcript and service sources. + if (LLChatServiceHistory::historySuppressed()) + { + return; + } + const std::vector<LLConversation>& conversations = LLConversationLog::instance().getConversations(); std::vector<LLConversation>::const_iterator iter = conversations.begin(); @@ -1307,6 +1314,7 @@ void LLAvatarActions::viewChatHistory(const LLUUID& id) LLAvatarNameCache::get(id, &avatar_name); extended_id[LL_FCP_COMPLETE_NAME] = avatar_name.getCompleteName(); extended_id[LL_FCP_ACCOUNT_NAME] = avatar_name.getAccountName(); + extended_id[LL_FCP_PARTICIPANT_ID] = id; LLFloaterReg::showInstance("preview_conversation", extended_id, true); } } diff --git a/indra/newview/llchathistory.cpp b/indra/newview/llchathistory.cpp index d5b25deaf5..6f449cdd0c 100644 --- a/indra/newview/llchathistory.cpp +++ b/indra/newview/llchathistory.cpp @@ -1266,6 +1266,16 @@ void LLChatHistory::onClickMoreText() void LLChatHistory::clear() { mLastFromName.clear(); + + // Inline views die on the mortician pass. Detach this transcript's current + // views now so a synchronous replay cannot draw old and replacement panels together. + const LLView* document_view = mEditor->getDocumentView(); + const LLView::child_list_t document_children = *document_view->getChildList(); + for (LLView* child : document_children) + { + mEditor->removeDocumentChild(child); + } + mEditor->clear(); mLastFromID = LLUUID::null; } @@ -1520,14 +1530,18 @@ void LLChatHistory::appendMessage(const LLChat& chat, const LLSD &args, const LL bool create_toast = true; if (notification->getName() == "OfferFriendship") { - // We don't want multiple friendship offers to appear, this code checks if there are previous offers - // by iterating though all panels. - // Note: it might be better to simply add a "pending offer" flag somewhere + // Keep one actionable offer per sender without hiding requests from other residents. + const LLUUID from_id = notification->getPayload()["from_id"].asUUID(); for (auto& panel : LLToastNotifyPanel::instance_snapshot()) { LLIMToastNotifyPanel * imtoastp = dynamic_cast<LLIMToastNotifyPanel *>(&panel); const std::string& notification_name = panel.getNotificationName(); - if (notification_name == "OfferFriendship" + LLNotificationPtr panel_notification = LLNotificationsUtil::find(panel.getID()); + // A synchronous transcript replay replaces panels already queued for deletion. + if (!panel.isDead() + && notification_name == "OfferFriendship" + && panel_notification + && panel_notification->getPayload()["from_id"].asUUID() == from_id && panel.isControlPanelEnabled() && imtoastp) { diff --git a/indra/newview/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp new file mode 100644 index 0000000000..b6f5882bb4 --- /dev/null +++ b/indra/newview/llchatservicehistory.cpp @@ -0,0 +1,2755 @@ +/** + * @file llchatservicehistory.cpp + * @brief ChatService direct-IM history synchronization and stitched reads. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * $/LicenseInfo$ + */ +#include "llviewerprecompiledheaders.h" + +#include "llchatservicehistory.h" + +#include "llagent.h" +#include "llavatarnamecache.h" +#include "llcachename.h" +#include "llcallbacklist.h" +#include "llconversationlog.h" +#include "llcorehttputil.h" +#include "llcoros.h" +#include "lldate.h" +#include "lldir.h" +#include "lldiriterator.h" +#include "lleventcoro.h" +#include "llevents.h" +#include "llfile.h" +#include "fsyspath.h" +#include "llfloaterconversationpreview.h" +#include "llfloaterreg.h" +#include "llfloaterimsessiontab.h" +#include "llfloaterimsession.h" +#include "llimview.h" +#include "lllogchat.h" +#include "llmutelist.h" +#include "llnotificationsutil.h" +#include "llsdserialize.h" +#include "lltimer.h" +#include "llviewercontrol.h" +#include "llviewerregion.h" +#include "workqueue.h" + +#if LL_WINDOWS +#include "llwin32headers.h" +#else +#include <fcntl.h> +#include <sys/stat.h> +#include <unistd.h> +#endif + +#include <algorithm> +#include <boost/date_time/gregorian/gregorian.hpp> +#include <boost/date_time/posix_time/posix_time.hpp> +#include <boost/scope/scope_exit.hpp> +#include <climits> +#include <cstdio> +#include <deque> +#include <filesystem> +#include <map> +#include <optional> +#include <set> +#include <sstream> + +namespace +{ +using namespace LLChatServiceHistoryCore; + +const char* const LIST_CAP = "PersistentChatConversationsRequest"; +const char* const HISTORY_CAP = "PersistentChatHistoryRequest"; +const char* const ENABLED_SETTING = "ChatServiceEnabled"; +const char* const INDEX_NAME = "chat_service_index.csv"; +const char* const STATE_NAME = "chat_service_state.xml"; +const char* const PENDING_NAME = "(name pending)"; +const char* const LEGACY_WALL_TIME = "chat_service_legacy_wall_time"; +const F64 REQUEST_SPACING = 2.1; +const F64 RETRY_DELAY = 5.0; +const F64 ACTIVITY_REFRESH_DELAY = 15.0; +const F64 RATE_LIMIT_DELAY = 120.0; +const F64 LIST_INTERVAL = 3600.0; +const F64 NAME_TIMEOUT = 30.0; +const U64 UUID_EPOCH = 122192928000000000ULL; +const U64 UUID_TICK_LIMIT = (1ULL << 60); +const U32 MAX_PASS_ROWS = 50000; +const size_t MAX_PASS_BYTES = 64 * 1024 * 1024; + +enum EStateSafety +{ + STATE_UNKNOWN, + STATE_SAFE, + STATE_UNSAFE +}; + +enum EMetadata +{ + META_UNREQUESTED, + META_PENDING, + META_RESOLVED, + META_FAILED +}; + +enum EActivityDiscovery +{ + DISCOVERY_NONE, + DISCOVERY_PENDING, + DISCOVERY_CONFIRMING +}; + +struct CapabilityContext +{ + LLUUID region_id; + std::string list_url; + std::string history_url; + + bool complete() const + { + return region_id.notNull() && !list_url.empty() && !history_url.empty(); + } + + bool operator==(const CapabilityContext& rhs) const + { + return region_id == rhs.region_id && list_url == rhs.list_url && + history_url == rhs.history_url; + } + + bool operator!=(const CapabilityContext& rhs) const + { + return !(*this == rhs); + } +}; + +struct Metadata +{ + EMetadata state = META_UNREQUESTED; + F64 deadline = 0.0; + LLAvatarName name; + boost::signals2::connection connection; +}; + +struct Resident +{ + // Discovery identity and the durable service range already covered. + std::string advertised_token; + std::string covered_token; + U32 archive_serial = 0; + + // Failed or unprepared archives require a scan before reuse. + ArchiveScan summary{ARCHIVE_FAILED}; + Metadata metadata; + + // Coalesced scheduler state for this resident's current or next pass. + bool listed = false; + bool forced_followup = false; + bool first_request_started = false; + bool retry_used = false; + bool metadata_waiting = false; + + // Incoming and outgoing IM bursts share one quiet deadline; unknown + // conversations retain one bounded discovery confirmation. + F64 activity_refresh_due = 0.0; + EActivityDiscovery activity_discovery = DISCOVERY_NONE; + + LLChatServiceHistory::Snapshot snapshot; +}; + +struct StateResult +{ + EStateSafety safety = STATE_UNSAFE; + U64 boundary = 0; + bool cleanup_pending = false; +}; + +struct HttpResult +{ + S32 status = 0; + LLSD body; +}; + +struct Runtime +{ + // Account lifecycle and manager wake state. + U32 epoch = 0; + bool running = false; + bool rollout = false; + bool wake_pending = false; + + // Discovery and archive-maintenance state. + bool initialized_archives = false; + bool list_needed = true; + bool list_retry_used = false; + + // Deletion command and storage-maintenance state. + bool delete_requested = false; + bool delete_active = false; + bool index_dirty = false; + bool local_content_exists = false; + + // Durable deletion and privacy gates. + U64 delete_click_ticks = 0; + U64 deleted_before_ticks = 0; + EStateSafety state_safety = STATE_UNKNOWN; + bool cleanup_pending = false; + + // Manager-wide network deadlines. + F64 network_not_before = 0.0; + F64 next_list = 0.0; + + // Login-scoped capability, storage, and identity inputs. + CapabilityContext context; + // LLDir supplies a trailing separator for captured account filenames. + std::string account_dir; + LLUUID agent_id; + + // One active resident plus a coalesced priority/background queue. + LLUUID active_resident; + LLUUID priority_resident; + std::deque<LLUUID> queue; + std::map<LLUUID, Resident> residents; + + // Account-scoped completions and settings observers. + LLChatServiceHistory::delete_callback_t delete_callback; + boost::signals2::connection consent_connection; + boost::signals2::connection show_history_connection; +}; + +// The main-thread manager owns all mutable scheduling and resident state for one +// login. Epoch checks fence coroutine and callback work across account changes. +Runtime sRuntime; + +// General-queue filesystem jobs serialize with view scans and transcript deletion. +LLMutex sStorageMutex; +std::unique_ptr<LLEventMailDrop> sWake; +boost::signals2::signal<void(const LLUUID&, const LLChatServiceHistory::Snapshot&)> sSnapshotSignal; +boost::signals2::connection sRegionConnection; + +bool ownsRuntime(U32 epoch) +{ + return sRuntime.running && sRuntime.epoch == epoch; +} + +void requireRuntime(U32 epoch) +{ + if (!ownsRuntime(epoch)) + { + LLTHROW(LLCoros::Stopped("Chat Service account ended")); + } +} + +template <typename Work> +auto runStorage(U32 epoch, Work work) -> decltype(work()) +{ + // A storage wait may span logout. Unwind the manager before it can touch the + // replacement account or a resident reference from the previous login. + LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); + auto result = general ? general->waitForResult(work) : decltype(work())(); + requireRuntime(epoch); + return result; +} + +void postPresentation(const nullary_func_t& work) +{ + // All publishers run on the main thread. Idle registration cannot suspend; + // presentation waits until the main-loop UI operation has unwound. + const U32 epoch = sRuntime.epoch; + doOnIdleOneTime([epoch, work]() + { + // Queued UI work belongs only to the login that scheduled it. + if (ownsRuntime(epoch)) + { + work(); + } + }); +} + +std::string archiveName(const LLUUID& resident_id) +{ + return "chat_service_" + resident_id.asString() + ".csv"; +} + +std::string archivePath(const std::string& directory, const LLUUID& resident_id) +{ + return directory + archiveName(resident_id); +} + +bool transcriptConsent() +{ + return gSavedPerAccountSettings.getS32("KeepConversationLogTranscripts") > 1; +} + +CapabilityContext sampleContext() +{ + CapabilityContext result; + LLViewerRegion* region = gAgent.getRegion(); + if (region && region->capabilitiesReceived()) + { + result.region_id = region->getRegionID(); + result.list_url = region->getCapability(LIST_CAP); + result.history_url = region->getCapability(HISTORY_CAP); + } + return result; +} + +void wakeManager() +{ + if (sRuntime.running && sWake && !sRuntime.wake_pending) + { + sRuntime.wake_pending = true; + sWake->post(LLSD(true)); + } +} + +void publishSnapshot(const LLUUID& id, Resident& resident) +{ + // Recompute account-wide presentation permission at publication time so views + // cannot retain service rows after consent, state, or deletion gates change. + resident.snapshot.archive_serial = resident.archive_serial; + resident.snapshot.metadata_resolved = resident.metadata.state == META_RESOLVED; + if (resident.snapshot.metadata_resolved) + { + resident.snapshot.metadata = resident.metadata.name; + } + resident.snapshot.service_presentation_allowed = + LLChatServiceHistory::servicePresentationAllowed(); + postPresentation([id]() + { + // Deliver current state, not a captured preview that may have been revoked + // or replaced while this notification waited for the main loop. + auto found = sRuntime.residents.find(id); + if (found != sRuntime.residents.end()) + { + const auto snapshot = found->second.snapshot; + sSnapshotSignal(id, snapshot); + } + }); +} + +void setWorkActive(const LLUUID& id, bool active) +{ + Resident& resident = sRuntime.residents[id]; + if (resident.snapshot.service_work_active != active) + { + resident.snapshot.service_work_active = active; + publishSnapshot(id, resident); + } +} + +void queueResident(const LLUUID& id, bool priority) +{ + // One resident has at most one queued occurrence. A fresh priority trigger moves + // that occurrence to the front without bypassing global request pacing. + sRuntime.queue.erase(std::remove(sRuntime.queue.begin(), sRuntime.queue.end(), id), + sRuntime.queue.end()); + if (priority) + { + sRuntime.queue.push_front(id); + } + else + { + sRuntime.queue.push_back(id); + } +} + +void clearPriority(const LLUUID& id) +{ + if (sRuntime.priority_resident == id) + { + sRuntime.priority_resident.setNull(); + } +} + +bool parseDecimalTicks(const std::string& text, U64& value) +{ + // Privacy cutoffs use one canonical unsigned decimal form bounded to UUIDv1's + // 60-bit timestamp field. + if (text.empty() || (text.size() > 1 && text[0] == '0') || + text.find_first_not_of("0123456789") != std::string::npos) + { + return false; + } + U64 parsed = 0; + for (char ch : text) + { + const U64 digit = static_cast<U64>(ch - '0'); + if (parsed > (UUID_TICK_LIMIT - 1 - digit) / 10) + { + return false; + } + parsed = parsed * 10 + digit; + } + value = parsed; + return true; +} + +bool syncDirectory(const std::string& path) +{ +#if LL_WINDOWS + return true; +#else + const std::string::size_type separator = path.find_last_of("/\\"); + if (separator == std::string::npos) + { + return false; + } + + const std::string directory = path.substr(0, separator); + const int descriptor = ::open(directory.c_str(), O_RDONLY | O_DIRECTORY); + if (descriptor < 0) + { + return false; + } + const bool success = ::fsync(descriptor) == 0; + ::close(descriptor); + return success; +#endif +} + +bool syncFile(const std::string& path) +{ +#if LL_WINDOWS + HANDLE handle = CreateFileW(ll_convert<std::wstring>(path).c_str(), GENERIC_WRITE, + FILE_SHARE_READ, NULL, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, NULL); + if (handle == INVALID_HANDLE_VALUE) + { + return false; + } + const bool success = FlushFileBuffers(handle) != 0; + CloseHandle(handle); +#else + const int descriptor = ::open(path.c_str(), O_RDONLY); + if (descriptor < 0) + { + return false; + } + const bool success = ::fsync(descriptor) == 0; + ::close(descriptor); +#endif + return success; +} + +bool writeFile(const std::string& path, const std::string& contents, bool durable) +{ + // Closing flushes the stream; privacy state also reaches stable storage before + // any directory entry can make these bytes authoritative. + llofstream output(path.c_str(), std::ios::binary | std::ios::trunc); + output.write(contents.data(), contents.size()); + output.close(); + return !output.fail() && (!durable || syncFile(path)); +} + +bool replaceFile(const std::string& from, const std::string& to, bool durable) +{ + // Publish complete bytes atomically, then commit the directory update for privacy state. +#if LL_WINDOWS + if (!MoveFileExW(ll_convert<std::wstring>(from).c_str(), ll_convert<std::wstring>(to).c_str(), + MOVEFILE_REPLACE_EXISTING | (durable ? MOVEFILE_WRITE_THROUGH : 0))) +#else + if (::rename(from.c_str(), to.c_str()) != 0) +#endif + { + return false; + } + return !durable || syncDirectory(to); +} + +bool writeReplace(const std::string& destination, const std::string& contents, bool durable) +{ + // A complete sibling temporary precedes replacement; neither path may alias + // another file or directory. + const std::string temporary = destination + ".tmp"; + bool exists = false; + return inspectRegular(destination, exists) && inspectRegular(temporary, exists) && + writeFile(temporary, contents, durable) && replaceFile(temporary, destination, durable); +} + +bool truncateArchive(const std::string& path, std::streamoff bytes) +{ + // The parser and file stamp identify the valid prefix. Truncation removes only + // the incomplete suffix without copying or rewriting accumulated messages. + bool exists = false; + if (bytes < 0 || !inspectRegular(path, exists) || !exists) + { + return false; + } + std::error_code error; + std::filesystem::resize_file(fsyspath(path), bytes, error); + return !error; +} + +bool readStateFile(const std::string& path, StateResult& state) +{ + bool exists = false; + if (!inspectRegular(path, exists) || !exists) + { + return false; + } + llifstream input(path.c_str(), std::ios::binary); + LLSD data; + if (!input.is_open()) + { + return false; + } + + // Privacy state accepts exactly the two canonical fields and no trailing bytes. + const S32 parsed = LLSDSerialize::fromXMLDocument(data, input, false); + input.clear(); + input >> std::ws; + if (parsed == LLSDParser::PARSE_FAILURE || input.peek() != EOF || + !data.isMap() || data.size() != 2 || + !data["deleted_before_uuid_ticks"].isString() || + !data["cleanup_pending"].isBoolean() || + !parseDecimalTicks(data["deleted_before_uuid_ticks"].asString(), state.boundary)) + { + return false; + } + state.safety = STATE_SAFE; + state.cleanup_pending = data["cleanup_pending"].asBoolean(); + return true; +} + +StateResult loadState(const std::string& path) +{ + // A valid canonical state is authoritative. Any ambiguous canonical/temporary + // combination fails closed until deletion recovery publishes a new state. + StateResult result; + const std::string temporary = path + ".tmp"; + bool canonical_exists = false; + bool temporary_exists = false; + const bool canonical_safe = inspectRegular(path, canonical_exists); + const bool temporary_safe = inspectRegular(temporary, temporary_exists); + if (canonical_safe && canonical_exists && readStateFile(path, result)) + { + // A committed canonical is authoritative; discard any interrupted replacement. + if ((!temporary_safe || temporary_exists) && + (LLFile::remove(temporary) != 0 || !syncDirectory(path))) + { + result.safety = STATE_UNSAFE; + } + return result; + } + if (canonical_safe && temporary_safe && !canonical_exists && !temporary_exists) + { + result.safety = STATE_SAFE; + return result; + } + result.safety = STATE_UNSAFE; + return result; +} + +U64 recoverBoundaryCandidate(const std::string& path) +{ + bool exists = false; + if (!inspectRegular(path, exists) || !exists) + { + return 0; + } + + // Recovery salvages only a canonical prior cutoff; malformed surrounding state + // cannot weaken the newer click-time boundary. + llifstream input(path.c_str(), std::ios::binary); + LLSD data; + U64 boundary = 0; + if (input.is_open() && + LLSDSerialize::fromXML(data, input, false) != LLSDParser::PARSE_FAILURE && + data.isMap() && data["deleted_before_uuid_ticks"].isString()) + { + parseDecimalTicks(data["deleted_before_uuid_ticks"].asString(), boundary); + } + return boundary; +} + +std::string stateContents(U64 boundary, bool pending) +{ + LLSD data; + data["deleted_before_uuid_ticks"] = std::to_string(boundary); + data["cleanup_pending"] = pending; + std::ostringstream serialized; + LLSDSerialize::toPrettyXML(data, serialized); + return serialized.str(); +} + +bool recoverStateForDelete(const std::string& path, U64 boundary) +{ + // Stage through the owned index scratch path. An unsafe canonical remains in + // place until a durable pending cutoff has reached the state temporary. + const std::string temporary = path + ".tmp"; + const std::string::size_type separator = path.find_last_of("/\\"); + const std::string recovery = path.substr(0, separator + 1) + INDEX_NAME + ".tmp"; + if (LLFile::remove(recovery) != 0 || + !writeFile(recovery, stateContents(boundary, true), true) || + !replaceFile(recovery, temporary, true)) + { + return false; + } + if (LLFile::remove(path) != 0) + { + return false; + } + return replaceFile(temporary, path, true); +} + +bool writeState(const std::string& path, U64 boundary, bool pending) +{ + return writeReplace(path, stateContents(boundary, pending), true); +} + +bool clearUnsafeStateTemporary(const std::string& path) +{ + const std::string temporary = path + ".tmp"; + bool exists = false; + if (inspectRegular(temporary, exists)) + { + return true; + } + + // A nonregular temporary must be removed before durable replacement reuses it. + // LLFile reports absence as success; its return value is authoritative. + return LLFile::remove(temporary) == 0 && syncDirectory(path); +} + +bool canonicalArchiveName(const std::string& name, LLUUID& resident_id) +{ + const std::string prefix = "chat_service_"; + const std::string suffix = ".csv"; + if (name.size() != prefix.size() + 36 + suffix.size() || + name.compare(0, prefix.size(), prefix) || + name.compare(name.size() - suffix.size(), suffix.size(), suffix)) + { + return false; + } + return parseCanonicalUuid(name.substr(prefix.size(), 36), resident_id); +} + +bool ownedArtifactName(const std::string& name) +{ + if (name == INDEX_NAME || name == std::string(INDEX_NAME) + ".tmp" || + name == STATE_NAME || name == std::string(STATE_NAME) + ".tmp") + { + return true; + } + const std::string prefix = "chat_service_"; + if (name.size() <= prefix.size() + 36 || name.compare(0, prefix.size(), prefix)) + { + return false; + } + LLUUID id; + if (!parseCanonicalUuid(name.substr(prefix.size(), 36), id)) + { + return false; + } + const std::string suffix = name.substr(prefix.size() + 36); + return suffix == ".csv" || suffix == ".csv.tmp" || suffix == ".csv.corrupt"; +} + +bool prepareArchive(const std::string& path, const LLUUID& resident_id, const LLUUID& agent_id, + U64 boundary, ArchiveScan& summary, bool& bytes_changed) +{ + LLMutexLock lock(&sStorageMutex); + + // Classify the canonical under the same lock used by mutation and view scans. + bool canonical_exists = false; + if (!inspectRegular(path, canonical_exists)) + { + return false; + } + + ArchiveScan scan; + if (scanArchive(path, agent_id, resident_id, boundary, 0, scan)) + { + summary = scan; + return true; + } + if (scan.state == ARCHIVE_TORN) + { + // Repair only when the private physical stamp still matches the classified + // file, then rescan the repaired file before publishing its summary. + U64 current_size = 0; + S64 current_mtime = 0; + if (!archiveStamp(path, current_size, current_mtime) || + current_size != scan.file_size || current_mtime != scan.file_mtime || + !truncateArchive(path, scan.valid_prefix_bytes) || + !scanArchive(path, agent_id, resident_id, boundary, 0, scan)) + { + summary.state = ARCHIVE_FAILED; + return false; + } + bytes_changed = true; + summary = scan; + return true; + } + if (scan.state != ARCHIVE_CORRUPT) + { + summary.state = ARCHIVE_FAILED; + return false; + } + + // Preserve one structurally unusable canonical before a later full-window rebuild. + const std::string corrupt = path + ".corrupt"; + if (LLFile::remove(corrupt) != 0) + { + summary.state = ARCHIVE_FAILED; + return false; + } + U64 current_size = 0; + S64 current_mtime = 0; + if (!archiveStamp(path, current_size, current_mtime) || + current_size != scan.file_size || current_mtime != scan.file_mtime || + LLFile::rename(path, corrupt) != 0) + { + summary.state = ARCHIVE_FAILED; + return false; + } + bytes_changed = true; + summary = ArchiveScan(); + return true; +} + +bool publishRows(const std::string& path, const std::vector<Row>& rows, + bool append, ArchiveScan& resulting) +{ + LLMutexLock lock(&sStorageMutex); + if (rows.empty()) + { + return true; + } + + // The captured summary is a private physical identity check. External changes + // abort publication instead of reconciling unknown bytes. + bool exists = false; + if (!inspectRegular(path, exists) || exists != (resulting.state != ARCHIVE_ABSENT)) + { + return false; + } + if (exists) + { + U64 file_size = 0; + S64 file_mtime = 0; + if (!archiveStamp(path, file_size, file_mtime) || + file_size != resulting.file_size || file_mtime != resulting.file_mtime) + { + return false; + } + } + + // New and rebuilt archives publish one complete ascending CSV. Existing valid + // archives append only the fully validated newer stage. + if (!append) + { + std::ostringstream output; + output << CSV_HEADER; + for (const Row& row : rows) + { + writeCsvRow(output, row); + } + if (!writeReplace(path, output.str(), false)) + { + return false; + } + } + else + { + llstat status; + bool needs_separator = false; + if (LLFile::stat(path, &status) != 0 || !LLFile::isfile(path)) + { + return false; + } + if (status.st_size) + { + llifstream input(path.c_str(), std::ios::binary); + input.seekg(-1, std::ios::end); + needs_separator = input.get() != '\n'; + } + llofstream output(path.c_str(), std::ios::binary | std::ios::app); + if (!output.is_open()) + { + return false; + } + if (needs_separator) + { + output << '\n'; + } + for (const Row& row : rows) + { + writeCsvRow(output, row); + } + output.flush(); + output.close(); + if (output.fail()) + { + return false; + } + } + + // Advance the in-memory summary only after the filesystem mutation succeeds. + resulting.state = ARCHIVE_VALID; + if (!resulting.has_oldest) + { + resulting.has_oldest = true; + resulting.oldest = rows.front().key; + } + resulting.row_count += static_cast<U32>(rows.size()); + resulting.newest = rows.back().key; + if (!archiveStamp(path, resulting.file_size, resulting.file_mtime)) + { + return false; + } + resulting.valid_prefix_bytes = resulting.file_size; + return true; +} + +typedef std::pair<std::vector<LLUUID>, bool> initial_artifacts_t; + +initial_artifacts_t enumerateArchives(const std::string& directory) +{ + // Discover canonical archives and deletable remnants in one directory walk. + // The resident map supplies ordering and uniqueness when these IDs are applied. + initial_artifacts_t result; + LLDirIterator iterator(directory, "chat_service_*"); + std::string name; + while (iterator.next(name)) + { + LLUUID id; + if (canonicalArchiveName(name, id)) + { + result.first.push_back(id); + } + if (ownedArtifactName(name) && name != INDEX_NAME && + name != std::string(INDEX_NAME) + ".tmp" && name != STATE_NAME && + name != std::string(STATE_NAME) + ".tmp") + { + result.second = true; + } + } + return result; +} + +using index_entries_t = std::map<LLUUID, std::optional<LLAvatarName>>; + +bool regenerateIndex(const index_entries_t& archives, + const std::string& directory) +{ + LLMutexLock lock(&sStorageMutex); + + // Remove unsafe index paths before rebuilding the non-authoritative manifest. + const std::string index = directory + INDEX_NAME; + const std::string temporary = index + ".tmp"; + for (const std::string& path : { index, temporary }) + { + bool exists = false; + const bool regular = inspectRegular(path, exists); + if (!regular || (path == temporary && exists)) + { + if (LLFile::remove(path) != 0) + { + return false; + } + } + } + + // The captured entries already identify valid archives. Keep only files still + // present, spelling out names that remain unresolved at this publication. + std::ostringstream output; + output << INDEX_HEADER; + bool has_entries = false; + for (const auto& [id, name] : archives) + { + bool exists = false; + if (!inspectRegular(archivePath(directory, id), exists) || !exists) + { + continue; + } + has_entries = true; + output << quoteCsv(archiveName(id)) << ',' << id.asString() << ',' + << quoteCsv(name ? name->getAccountName() : PENDING_NAME) << ',' + << quoteCsv(name ? name->getDisplayName() : PENDING_NAME) << '\n'; + } + if (!has_entries) + { + return LLFile::remove(index) == 0; + } + return writeReplace(index, output.str(), false); +} + +HttpResult request(const std::string& url, const LLSD* post, U32 epoch) +{ + HttpResult result; + + // Every request advances one manager-wide deadline before suspension, so opens + // and retries cannot bypass the 2.1-second service spacing. + sRuntime.network_not_before = llmax(sRuntime.network_not_before, + F64(LLTimer::getTotalSeconds()) + REQUEST_SPACING); + LLCoreHttpUtil::HttpCoroutineAdapter::ptr_t adapter = + std::make_shared<LLCoreHttpUtil::HttpCoroutineAdapter>( + "ChatServiceHistory", LLCore::HttpRequest::DEFAULT_POLICY_ID); + LLCore::HttpRequest::ptr_t http_request = std::make_shared<LLCore::HttpRequest>(); + LLCore::HttpOptions::ptr_t options = std::make_shared<LLCore::HttpOptions>(); + options->setRetries(0); + options->setTimeout(30); + LLSD response = post ? adapter->postAndSuspend(http_request, url, *post, options) + : adapter->getAndSuspend(http_request, url, options); + requireRuntime(epoch); + const LLCore::HttpStatus status = + LLCoreHttpUtil::HttpCoroutineAdapter::getStatusFromLLSD( + response[LLCoreHttpUtil::HttpCoroutineAdapter::HTTP_RESULTS]); + result.status = status.getType(); + if (status) + { + // Capability adapters may place the response body either in the explicit + // content field or at the response root. + result.body = response.has(LLCoreHttpUtil::HttpCoroutineAdapter::HTTP_RESULTS_CONTENT) + ? response[LLCoreHttpUtil::HttpCoroutineAdapter::HTTP_RESULTS_CONTENT] + : response; + if (result.body.isMap()) + { + result.body.erase(LLCoreHttpUtil::HttpCoroutineAdapter::HTTP_RESULTS); + } + } + return result; +} + +bool retryable(S32 status) +{ + return status < 100 || status == 408 || status >= 500; +} + +bool nameBlocked(const LLUUID& id, const Resident& resident) +{ + // Blocking fails closed until the authoritative mute list and the shared name + // record can evaluate both UUID and legacy username forms. + LLMuteList* mute = LLMuteList::getInstance(); + if (!mute || !mute->isLoadedFromServer() || mute->isMuted(id)) + { + return true; + } + return resident.metadata.state != META_RESOLVED || + mute->isMuted(id, resident.metadata.name.getUserName()); +} + +bool uuidBlocked(const LLUUID& id) +{ + LLMuteList* mute = LLMuteList::getInstance(); + return !mute || !mute->isLoadedFromServer() || mute->isMuted(id); +} + +bool ensureMetadata(const LLUUID& id, Resident& resident) +{ + // A resolved record is stable for this login; view loads need no new publication. + if (resident.metadata.state == META_RESOLVED) + { + return true; + } + + LLAvatarName cached; + if (LLAvatarNameCache::get(id, &cached)) + { + resident.metadata.state = META_RESOLVED; + resident.metadata.name = cached; + sRuntime.index_dirty = true; + publishSnapshot(id, resident); + return true; + } + if (resident.metadata.state == META_PENDING || resident.metadata.state == META_FAILED) + { + return false; + } + + // The name cache owns batching and request deduplication. Keep one subscription + // per resident and a presentation timeout; cache hits may complete synchronously. + resident.metadata.state = META_PENDING; + resident.metadata.deadline = F64(LLTimer::getTotalSeconds()) + NAME_TIMEOUT; + const U32 epoch = sRuntime.epoch; + resident.metadata.connection = LLAvatarNameCache::get( + id, [id, epoch](const LLUUID&, const LLAvatarName& name) + { + // Cache callbacks run on the main loop. Timeout disconnects the slot; + // the epoch also fences logout and account replacement. + if (!sRuntime.running || sRuntime.epoch != epoch) + { + return; + } + auto found = sRuntime.residents.find(id); + if (found == sRuntime.residents.end() || + found->second.metadata.state != META_PENDING) + { + return; + } + found->second.metadata.connection.disconnect(); + found->second.metadata.state = META_RESOLVED; + found->second.metadata.name = name; + sRuntime.index_dirty = true; + if (found->second.metadata_waiting) + { + queueResident(id, sRuntime.priority_resident == id); + found->second.metadata_waiting = false; + } + publishSnapshot(id, found->second); + wakeManager(); + }); + return resident.metadata.state == META_RESOLVED; +} + +bool baseNetworkEligible(const CapabilityContext& context) +{ + // The shared gate is re-evaluated before every request and publication phase. + return sRuntime.running && LLChatServiceHistory::servicePresentationAllowed() && + context.complete() && + LLMuteList::getInstance() && LLMuteList::getInstance()->isLoadedFromServer(); +} + +bool networkEligible(const LLUUID& id, const Resident& resident, + const CapabilityContext& context) +{ + return baseNetworkEligible(context) && id.notNull() && !nameBlocked(id, resident); +} + +void expireMetadata(); +void activateDueActivityRefreshes(); + +void waitForWake(F64 seconds, U32 epoch) +{ + // The login creates the wake pump before launching the manager. Every wait + // ends the old coroutine on logout before it can resume account work. + requireRuntime(epoch); + sRuntime.wake_pending = false; + sWake->discard(); + llcoro::suspendUntilEventOnWithTimeout(*sWake, static_cast<F32>(llmax(0.01, seconds)), + LLSDMap("timeout", true)); + requireRuntime(epoch); + sRuntime.wake_pending = false; +} + +bool pace(const CapabilityContext& context, U32 epoch, + const LLUUID& resident_id = LLUUID::null) +{ + // Pacing remains interruptible so lifecycle, capability, mute, metadata, and + // deletion changes can cancel a queued request before network I/O begins. + requireRuntime(epoch); + for (;;) + { + if (sampleContext() != context || sRuntime.delete_requested) + { + return false; + } + if (resident_id.notNull()) + { + auto found = sRuntime.residents.find(resident_id); + if (found == sRuntime.residents.end() || + !networkEligible(resident_id, found->second, context)) + { + return false; + } + } + + // Record quiet-expiry priority during a global cooldown without allowing it + // to bypass that cooldown or create a separate request path. + activateDueActivityRefreshes(); + const F64 now = F64(LLTimer::getTotalSeconds()); + const F64 remaining = sRuntime.network_not_before - now; + if (remaining <= 0.0) + { + return true; + } + F64 wait = remaining; + for (const auto& pair : sRuntime.residents) + { + if (pair.second.metadata.state == META_PENDING) + { + wait = llmin(wait, llmax(0.01, pair.second.metadata.deadline - now)); + } + if (pair.second.activity_refresh_due > now) + { + wait = llmin(wait, pair.second.activity_refresh_due - now); + } + } + waitForWake(wait, epoch); + expireMetadata(); + } +} + +void handleRequestFailure(const LLUUID& id, Resident& resident, S32 status) +{ + if (status == 429) + { + // One manager-wide cooldown survives repeated throttles and every priority trigger. + sRuntime.network_not_before = llmax(sRuntime.network_not_before, + F64(LLTimer::getTotalSeconds()) + RATE_LIMIT_DELAY); + queueResident(id, sRuntime.priority_resident == id); + return; + } + if (retryable(status) && !resident.retry_used) + { + // Transport, timeout, and server failures receive one delayed retry for this + // resident pass; later ordinary triggers may start a fresh pass. + resident.retry_used = true; + sRuntime.network_not_before = llmax(sRuntime.network_not_before, + F64(LLTimer::getTotalSeconds()) + RETRY_DELAY); + queueResident(id, sRuntime.priority_resident == id); + } +} + +bool processList(const LLSD& body, bool& confirm_activity_discovery) +{ + confirm_activity_discovery = false; + std::vector<ListEntry> entries; + if (!validateConversationList(body, sRuntime.agent_id, entries)) + { + return false; + } + std::set<LLUUID> listed; + for (const ListEntry& entry : entries) + { + listed.insert(entry.resident_id); + } + + // Validate the complete list before preserving priority placeholders and + // appending background work. The first miss after an activity hint retains its + // placeholder for one delayed confirmation; the next valid miss retires it. + for (auto& pair : sRuntime.residents) + { + pair.second.listed = listed.count(pair.first) != 0; + if (!pair.second.listed) + { + if (pair.second.activity_discovery == DISCOVERY_PENDING) + { + pair.second.activity_discovery = DISCOVERY_CONFIRMING; + confirm_activity_discovery = true; + continue; + } + + pair.second.activity_discovery = DISCOVERY_NONE; + sRuntime.queue.erase(std::remove(sRuntime.queue.begin(), sRuntime.queue.end(), pair.first), + sRuntime.queue.end()); + if (sRuntime.priority_resident == pair.first) + { + sRuntime.priority_resident.setNull(); + } + pair.second.metadata_waiting = false; + setWorkActive(pair.first, false); + } + } + for (const ListEntry& entry : entries) + { + Resident& resident = sRuntime.residents[entry.resident_id]; + const bool placeholder = std::find(sRuntime.queue.begin(), sRuntime.queue.end(), + entry.resident_id) != sRuntime.queue.end(); + const bool changed = resident.advertised_token != entry.last_msg_id; + resident.advertised_token = entry.last_msg_id; + resident.listed = true; + resident.activity_discovery = DISCOVERY_NONE; + if (resident.metadata.state == META_FAILED) + { + resident.metadata.state = META_UNREQUESTED; + } + + // Empty coverage queues first discovery. Later lists queue changed or + // uncovered heads; token equality is not a TimeUUID ordering claim. + if (!placeholder && (changed || resident.covered_token != resident.advertised_token)) + { + resident.retry_used = false; + queueResident(entry.resident_id, false); + } + } + return true; +} + +void requestList(const CapabilityContext& context, U32 epoch) +{ + if (!pace(context, epoch)) + { + return; + } + if (!baseNetworkEligible(context)) + { + return; + } + + // Discovery mutates resident scheduling state only after the complete response + // passes strict validation. A pending activity hint remains visible across the + // HTTP suspension and is consumed by processList rather than this request latch. + const HttpResult response = request(context.list_url, NULL, epoch); + LL_INFOS("ChatServiceHistory") + << "Conversation-list response: status=" << response.status << LL_ENDL; + if (sampleContext() != context || sRuntime.delete_requested || + !baseNetworkEligible(context)) + { + return; + } + if (response.status == 429) + { + sRuntime.network_not_before = llmax(sRuntime.network_not_before, + F64(LLTimer::getTotalSeconds()) + RATE_LIMIT_DELAY); + sRuntime.list_needed = true; + return; + } + if (!response.body.isUndefined() && response.status >= 200 && response.status < 300) + { + bool confirm_activity_discovery = false; + if (processList(response.body, confirm_activity_discovery)) + { + const F64 now = F64(LLTimer::getTotalSeconds()); + sRuntime.list_needed = confirm_activity_discovery; + sRuntime.list_retry_used = false; + sRuntime.next_list = now + LIST_INTERVAL; + if (confirm_activity_discovery) + { + sRuntime.network_not_before = llmax(sRuntime.network_not_before, + now + RETRY_DELAY); + } + return; + } + } + if (retryable(response.status) && !sRuntime.list_retry_used) + { + sRuntime.list_retry_used = true; + sRuntime.network_not_before = llmax(sRuntime.network_not_before, + F64(LLTimer::getTotalSeconds()) + RETRY_DELAY); + sRuntime.list_needed = true; + } + else + { + sRuntime.list_needed = false; + sRuntime.next_list = F64(LLTimer::getTotalSeconds()) + LIST_INTERVAL; + } +} + +bool prepareResidentArchive(const LLUUID& id, U32 epoch) +{ + const std::string path = archivePath(sRuntime.account_dir, id); + const LLUUID agent_id = sRuntime.agent_id; + const U64 boundary = sRuntime.deleted_before_ticks; + bool changed = false; + ArchiveScan summary; + const bool prepared = runStorage(epoch, + [path, id, agent_id, boundary, &summary, &changed]() + { + return prepareArchive(path, id, agent_id, boundary, summary, changed); + }); + if (!prepared) + { + return false; + } + + // Only this manager removes residents, so the epoch check preserves their + // identity across the worker wait. Repair invalidates both views and coverage. + Resident& current = sRuntime.residents.at(id); + current.summary = summary; + sRuntime.index_dirty = true; + if (changed) + { + ++current.archive_serial; + current.covered_token.clear(); + sRuntime.local_content_exists = true; + } + publishSnapshot(id, current); + return true; +} + +void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) +{ + auto found = sRuntime.residents.find(id); + if (found == sRuntime.residents.end()) + { + return; + } + Resident& resident = found->second; + + // Resolve prerequisites before starting a pass. Only the manager erases + // residents; its checked waits terminate before a reference can become stale. + if (!resident.listed) + { + resident.activity_discovery = DISCOVERY_NONE; + resident.snapshot.head_preview.clear(); + setWorkActive(id, false); + clearPriority(id); + return; + } + if (uuidBlocked(id)) + { + setWorkActive(id, false); + clearPriority(id); + return; + } + if (!ensureMetadata(id, resident)) + { + resident.metadata_waiting = true; + return; + } + resident.metadata_waiting = false; + if (!networkEligible(id, resident, context)) + { + setWorkActive(id, false); + clearPriority(id); + return; + } + setWorkActive(id, true); + sRuntime.active_resident = id; + resident.first_request_started = false; + + // Every exit retires the transient head and active pass. Queued retries keep + // their work indicator; logout leaves the replacement account untouched. + boost::scope::scope_exit finish([epoch, id, &resident]() + { + if (!ownsRuntime(epoch)) + { + return; + } + resident.snapshot.head_preview.clear(); + if (std::find(sRuntime.queue.begin(), sRuntime.queue.end(), id) == sRuntime.queue.end()) + { + resident.snapshot.service_work_active = false; + clearPriority(id); + } + sRuntime.active_resident.setNull(); + publishSnapshot(id, resident); + }); + + bool needs_prepare = resident.summary.state == ARCHIVE_FAILED; + bool had_boundary = !needs_prepare && resident.summary.state == ARCHIVE_VALID && + resident.summary.has_oldest; + TimeUuidKey stored_newest = resident.summary.newest; + const std::string conversation_id = directConversationId(sRuntime.agent_id, id); + std::vector<Row> staged; + size_t staged_bytes = 0; + U32 returned_rows = 0; + std::string cursor; + bool complete = false; + bool exposed_preview = false; + + // Stage a complete bounded pass before changing canonical bytes. Pacing and + // each awaited page remain cancellation/priority boundaries. + while (!complete) + { + if (!pace(context, epoch, id)) + { + // Capability or account-wide gates retain one queued occurrence; + // a blocked resident stays dormant until a fresh ordinary trigger. + setWorkActive(id, false); + LLMuteList* mute = LLMuteList::getInstance(); + const bool temporarily_ineligible = !sRuntime.delete_requested && + (sampleContext() != context || !baseNetworkEligible(context) || + !mute || !mute->isLoadedFromServer()); + if (temporarily_ineligible && + std::find(sRuntime.queue.begin(), sRuntime.queue.end(), id) == sRuntime.queue.end()) + { + queueResident(id, sRuntime.priority_resident == id); + } + return; + } + if (sRuntime.priority_resident.notNull() && sRuntime.priority_resident != id) + { + queueResident(id, false); + return; + } + + // The first head request covers a burst whose quiet deadline has elapsed; + // continuation pages never consume a newer activity deadline. + if (!resident.first_request_started && cursor.empty() && + resident.activity_refresh_due > 0.0 && + resident.activity_refresh_due <= F64(LLTimer::getTotalSeconds())) + { + resident.activity_refresh_due = 0.0; + } + resident.first_request_started = true; + LLSD post; + post["conversation_id"] = conversation_id; + post["limit"] = 100; + if (!cursor.empty()) + { + post["before_msg_id"] = cursor; + } + + const HttpResult response = request(context.history_url, &post, epoch); + LL_INFOS("ChatServiceHistory") + << "History response: resident_id=" << id << ", status=" << response.status + << ", cursor=" << (cursor.empty() ? "head" : "continuation") << LL_ENDL; + if (sRuntime.delete_requested || sampleContext() != context || + !networkEligible(id, resident, context)) + { + return; + } + if (response.status < 200 || response.status >= 300) + { + handleRequestFailure(id, resident, response.status); + return; + } + Page page; + if (!validateHistoryPage(response.body, sRuntime.agent_id, id, cursor, + sRuntime.deleted_before_ticks, page)) + { + return; + } + + // Apply priority changes only after the complete response validates. + activateDueActivityRefreshes(); + if (sRuntime.priority_resident.notNull() && sRuntime.priority_resident != id) + { + queueResident(id, false); + return; + } + if (!exposed_preview && !page.rows.empty()) + { + resident.snapshot.head_preview = page.rows; + exposed_preview = true; + publishSnapshot(id, resident); + } + + // Present the first page before scanning a potentially years-long archive. + if (needs_prepare) + { + if (!prepareResidentArchive(id, epoch)) + { + return; + } + needs_prepare = false; + had_boundary = resident.summary.state == ARCHIVE_VALID && resident.summary.has_oldest; + stored_newest = resident.summary.newest; + activateDueActivityRefreshes(); + if (sRuntime.priority_resident.notNull() && sRuntime.priority_resident != id) + { + queueResident(id, false); + return; + } + } + + // Strictly descending pages accumulate only above the durable newest key. + // Count every returned row and retained payload against the pass bounds. + for (const Row& row : page.rows) + { + if (++returned_rows > MAX_PASS_ROWS) + { + return; + } + if (had_boundary && row.key <= stored_newest) + { + complete = true; + break; + } + const size_t bytes = row.conversation_id.size() + row.msg_id.size() + 36 + + row.from_name.size() + row.message.size() + std::to_string(row.dialog).size() + + row.created_at.size(); + if (staged_bytes + bytes > MAX_PASS_BYTES) + { + return; + } + staged_bytes += bytes; + staged.push_back(row); + } + complete = complete || page.terminal; + cursor = page.next_cursor; + } + + // Recheck dispatch gates after the final page/scan, then publish oldest-first. + if (!networkEligible(id, resident, context) || sRuntime.delete_requested || + sampleContext() != context) + { + return; + } + std::reverse(staged.begin(), staged.end()); + bool changed = false; + if (!staged.empty()) + { + ArchiveScan resulting = resident.summary; + const bool append = resident.summary.state == ARCHIVE_VALID && resident.summary.has_oldest; + const std::string path = archivePath(sRuntime.account_dir, id); + changed = runStorage(epoch, [path, staged, append, &resulting]() + { + return publishRows(path, staged, append, resulting); + }); + LL_INFOS("ChatServiceHistory") + << "Archive publication: resident_id=" << id << ", rows=" << staged.size() + << ", mode=" << (append ? "append" : "replace") + << ", success=" << changed << LL_ENDL; + + // Success and partial-write failure both invalidate readers and the manifest. + ++resident.archive_serial; + sRuntime.index_dirty = true; + sRuntime.local_content_exists = true; + if (changed) + { + resident.summary = resulting; + if (resident.metadata.state == META_RESOLVED) + { + const F64 archived_seconds = resident.summary.newest.ticks >= UUID_EPOCH + ? static_cast<F64>(resident.summary.newest.ticks - UUID_EPOCH) / 10000000.0 + : static_cast<F64>(time_corrected()); + const LLAvatarName name = resident.metadata.name; + postPresentation([id, name, archived_seconds]() + { + if (!LLChatServiceHistory::historySuppressed()) + { + LLConversationLog::instance().addServiceConversation( + LLIMMgr::computeSessionID(IM_NOTHING_SPECIAL, id), + name.getCompleteName(), + LLCacheName::buildUsername(name.getUserName()), + id, + U64Seconds(LLUnits::Seconds::fromValue(archived_seconds))); + } + }); + } + } + else + { + // A partial write needs a fresh scan before another pass can claim coverage. + resident.summary.state = ARCHIVE_FAILED; + resident.covered_token.clear(); + } + postPresentation(LLLogChat::notifyTranscriptCreated); + } + + // Only a complete pass claims coverage. At most one activity-triggered follow-up + // survives; the scope exit handles preview and work-status publication. + if (sRuntime.delete_requested) + { + return; + } + if (changed || staged.empty()) + { + resident.covered_token = resident.advertised_token; + resident.retry_used = false; + } + if (resident.forced_followup && networkEligible(id, resident, context)) + { + resident.forced_followup = false; + queueResident(id, true); + } + else + { + resident.snapshot.service_work_active = false; + } + clearPriority(id); +} + +bool sweepServiceArtifacts(const std::string& directory, const std::string& state_path) +{ + LLMutexLock lock(&sStorageMutex); + + // Snapshot only integration-owned names while holding the storage boundary; + // canonical privacy state is deliberately excluded from the sweep. + LLDirIterator iterator(directory, "chat_service_*"); + std::string name; + std::vector<std::string> targets; + while (iterator.next(name)) + { + if (ownedArtifactName(name) && name != STATE_NAME) + { + targets.push_back(directory + name); + } + } + + // State temporary is non-authoritative once pending has committed and is swept + // explicitly. + targets.push_back(state_path + ".tmp"); + + bool success = true; + // Attempt every owned path under the storage lock. Missing paths are already + // successful removals; any reported failure keeps cleanup pending. + for (const std::string& path : targets) + { + if (LLFile::remove(path) != 0) + { + success = false; + } + } + + if (!syncDirectory(state_path)) + { + success = false; + } + return success; +} + +void finishDelete(bool success) +{ + LLChatServiceHistory::delete_callback_t callback = sRuntime.delete_callback; + sRuntime.delete_callback.clear(); + sRuntime.delete_active = false; + + if (success) + { + sRuntime.delete_requested = false; + sRuntime.cleanup_pending = false; + sRuntime.state_safety = STATE_SAFE; + sRuntime.list_needed = true; + } + else + { + sRuntime.delete_requested = true; + } + + postPresentation([success, callback]() + { + if (!success) + { + LLNotificationsUtil::add("ChatServiceHistoryDeleteFailed"); + } + if (callback) + { + callback(success); + } + LLLogChat::notifyTranscriptCreated(); + }); +} + +void runDelete(U32 epoch) +{ + const std::string state_path = sRuntime.account_dir + STATE_NAME; + U64 boundary = sRuntime.delete_click_ticks; + + // Publish the inclusive cutoff as pending before invalidating views or removing + // content. This durable ordering keeps every historical source fail-closed after + // a crash at any later point in the sweep. + { + LLMutexLock lock(&sStorageMutex); + boundary = llmax(boundary, llmax(recoverBoundaryCandidate(state_path), + recoverBoundaryCandidate(state_path + ".tmp"))); + StateResult existing; + const bool canonical_valid = readStateFile(state_path, existing); + if ((canonical_valid && + (!clearUnsafeStateTemporary(state_path) || + !writeState(state_path, boundary, true))) || + (!canonical_valid && !recoverStateForDelete(state_path, boundary))) + { + sRuntime.state_safety = STATE_UNSAFE; + finishDelete(false); + return; + } + } + + sRuntime.deleted_before_ticks = boundary; + sRuntime.cleanup_pending = true; + + // Cancel queued synchronization and clear every displayed historical owner only + // after pending state is durable. + sRuntime.queue.clear(); + sRuntime.active_resident.setNull(); + sRuntime.priority_resident.setNull(); + for (auto& pair : sRuntime.residents) + { + pair.second.metadata.connection.disconnect(); + pair.second.snapshot.head_preview.clear(); + pair.second.snapshot.service_work_active = false; + publishSnapshot(pair.first, pair.second); + } + + // Await invalidation on the main loop before sweeping files or accepting new + // history. A late clear must not discard messages received after deletion. + LL::WorkQueue::ptr_t main = LL::WorkQueue::getInstance("mainloop"); + const bool cleared = main && main->waitForResult([epoch]() + { + if (!ownsRuntime(epoch)) + { + return false; + } + + for (auto& pair : LLIMModel::instance().mId2SessionMap) + { + pair.second->clearForHistoryDeletion(); + } + + const LLFloaterReg::const_instance_list_t& previews = + LLFloaterReg::getFloaterList("preview_conversation"); + for (LLFloater* floater : previews) + { + if (LLFloaterConversationPreview* preview = + dynamic_cast<LLFloaterConversationPreview*>(floater)) + { + preview->invalidateHistory(); + preview->closeFloater(); + } + } + + LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); + return true; + }); + requireRuntime(epoch); + if (!cleared) + { + finishDelete(false); + return; + } + + // Sweep legacy transcripts first, then service-owned artifacts, through the + // shared filesystem mutation boundaries on the General queue. + const std::string chat_logs_dir = + gDirUtilp ? gDirUtilp->getPerAccountChatLogsDir() : std::string(); + const bool swept = runStorage(epoch, + [chat_logs_dir, directory = sRuntime.account_dir, state_path]() + { + return !chat_logs_dir.empty() && LLLogChat::deleteTranscriptContent(chat_logs_dir) && + sweepServiceArtifacts(directory, state_path); + }); + if (!swept) + { + finishDelete(false); + return; + } + + // Clear pending only after both sweeps and their required directory syncs succeed. + { + LLMutexLock lock(&sStorageMutex); + if (!writeState(state_path, boundary, false)) + { + sRuntime.state_safety = STATE_UNSAFE; + finishDelete(false); + return; + } + } + + // Reset cached summaries last so subsequent discovery starts from empty storage. + sRuntime.residents.clear(); + sRuntime.initialized_archives = true; + sRuntime.local_content_exists = false; + finishDelete(true); +} + +void initializeArchives(U32 epoch) +{ + // Discover account-local artifacts off the main thread; individual archives are + // prepared incrementally by the manager so startup remains bounded. + const auto artifacts = runStorage(epoch, [directory = sRuntime.account_dir]() + { + return enumerateArchives(directory); + }); + sRuntime.local_content_exists = artifacts.second; + for (const LLUUID& id : artifacts.first) + { + sRuntime.residents[id].summary.state = ARCHIVE_FAILED; + } + sRuntime.initialized_archives = true; + sRuntime.index_dirty = true; +} + +bool prepareOneArchive(U32 epoch) +{ + for (auto& pair : sRuntime.residents) + { + Resident& resident = pair.second; + if (resident.summary.state != ARCHIVE_FAILED) + { + continue; + } + + // Prepare one archive per manager turn so network priority and lifecycle + // events can interleave with storage maintenance. + const LLUUID id = pair.first; + if (!prepareResidentArchive(id, epoch)) + { + return false; + } + Resident& current = sRuntime.residents.at(id); + if (current.summary.has_oldest) + { + ensureMetadata(id, current); + } + postPresentation(LLLogChat::notifyTranscriptCreated); + return true; + } + return false; +} + +bool updateIndex(U32 epoch) +{ + // Clear the dirty latch before suspension so metadata callbacks can request + // another pass while this captured manifest is being written. + sRuntime.index_dirty = false; + index_entries_t archives; + for (const auto& pair : sRuntime.residents) + { + if (pair.second.summary.state == ARCHIVE_VALID) + { + archives[pair.first] = pair.second.metadata.state == META_RESOLVED + ? std::make_optional(pair.second.metadata.name) : std::nullopt; + } + } + const bool updated = runStorage(epoch, + [archives, directory = sRuntime.account_dir]() + { + return regenerateIndex(archives, directory); + }); + sRuntime.index_dirty |= !updated; + return updated; +} + +void expireMetadata() +{ + // A timed-out lookup leaves the resident dormant until a later ordinary trigger. + const F64 now = LLTimer::getTotalSeconds(); + for (auto& pair : sRuntime.residents) + { + Metadata& metadata = pair.second.metadata; + if (metadata.state == META_PENDING && metadata.deadline <= now) + { + metadata.connection.disconnect(); + metadata.state = META_FAILED; + pair.second.metadata_waiting = false; + setWorkActive(pair.first, false); + clearPriority(pair.first); + } + } +} + +void activateDueActivityRefreshes() +{ + // Revoked account gates retire pending activity before it can remain + // a wake source or schedule service work. + if (!LLChatServiceHistory::servicePresentationAllowed()) + { + for (auto& pair : sRuntime.residents) + { + pair.second.activity_refresh_due = 0.0; + } + return; + } + + const F64 now = LLTimer::getTotalSeconds(); + for (auto& pair : sRuntime.residents) + { + Resident& resident = pair.second; + if (resident.activity_refresh_due <= 0.0 || resident.activity_refresh_due > now) + { + continue; + } + + // Consume the deadline before reusing the existing priority/discovery path + // so an expired value cannot create a rapid manager wake loop. + resident.activity_refresh_due = 0.0; + LLMuteList* mute = LLMuteList::getInstance(); + if (mute && mute->isLoadedFromServer() && + (mute->isMuted(pair.first) || + (resident.metadata.state == META_RESOLVED && + mute->isMuted(pair.first, resident.metadata.name.getUserName())))) + { + continue; + } + if (resident.listed) + { + LLChatServiceHistory::prioritizeResident(pair.first, true); + } + else if (resident.activity_discovery == DISCOVERY_NONE) + { + resident.activity_discovery = DISCOVERY_PENDING; + LLChatServiceHistory::prioritizeResident(pair.first); + } + } +} + +F64 nearestWait() +{ + // Sleep until the earliest list, pacing, metadata, or activity quiet deadline; + // explicit wake events interrupt this deadline when state changes sooner. + const F64 now = LLTimer::getTotalSeconds(); + F64 deadline = sRuntime.next_list > now ? sRuntime.next_list : now + LIST_INTERVAL; + if (sRuntime.network_not_before > now) + { + deadline = llmin(deadline, sRuntime.network_not_before); + } + for (const auto& pair : sRuntime.residents) + { + if (pair.second.metadata.state == META_PENDING) + { + deadline = llmin(deadline, pair.second.metadata.deadline); + } + if (LLChatServiceHistory::servicePresentationAllowed() && + pair.second.activity_refresh_due > 0.0) + { + deadline = llmin(deadline, pair.second.activity_refresh_due); + } + } + return llmax(0.01, deadline - now); +} + +void manager(U32 epoch) +{ + // One account-scoped coroutine advances deletion, initialization, network work, + // and storage maintenance in priority order. + while (sRuntime.running && sRuntime.epoch == epoch) + { + if (sRuntime.delete_active) + { + runDelete(epoch); + continue; + } + if (sRuntime.state_safety == STATE_UNKNOWN) + { + // Load privacy state before archives, views, or network work become eligible. + const std::string state_path = sRuntime.account_dir + STATE_NAME; + const StateResult state = runStorage(epoch, [state_path]() + { + LLMutexLock lock(&sStorageMutex); + return loadState(state_path); + }); + sRuntime.state_safety = state.safety; + sRuntime.deleted_before_ticks = state.boundary; + sRuntime.cleanup_pending = state.cleanup_pending; + postPresentation([]() + { + LLLogChat::notifyTranscriptCreated(); + if (!LLChatServiceHistory::historySuppressed()) + { + LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); + } + }); + if (state.safety == STATE_SAFE && state.cleanup_pending) + { + sRuntime.delete_active = true; + sRuntime.delete_requested = true; + sRuntime.delete_click_ticks = state.boundary; + continue; + } + } + if (sRuntime.state_safety == STATE_SAFE && !sRuntime.initialized_archives) + { + initializeArchives(epoch); + continue; + } + + // Expire timers and activate due activity bursts before choosing network work. + expireMetadata(); + activateDueActivityRefreshes(); + + const CapabilityContext context = sampleContext(); + if (context != sRuntime.context) + { + // Capability context changes restart discovery but retain valid archive + // coverage and resident priority state for this account. + sRuntime.context = context; + if (context.complete()) + { + sRuntime.list_needed = true; + sRuntime.list_retry_used = false; + for (auto& pair : sRuntime.residents) + { + if (pair.second.metadata.state == META_FAILED) + { + pair.second.metadata.state = META_UNREQUESTED; + } + } + } + else + { + for (auto& pair : sRuntime.residents) + { + setWorkActive(pair.first, false); + } + } + } + + // Discovery precedes resident paging; opens and due activity share the + // resident priority queue. + const bool base_network = baseNetworkEligible(context); + LLMuteList* mute = LLMuteList::getInstance(); + // Once all external inputs are available, record why synchronization stayed gated. + if (!base_network && context.complete() && mute && mute->isLoaded()) + { + LL_INFOS_ONCE("ChatServiceHistory") + << "Network sync blocked: rollout=" << sRuntime.rollout + << ", consent=" << transcriptConsent() + << ", state_safe=" << (sRuntime.state_safety == STATE_SAFE) + << ", cleanup_pending=" << sRuntime.cleanup_pending + << ", delete_requested=" << sRuntime.delete_requested + << ", mute_source=" << mute->getLoadSourceName() + << ", mute_authoritative=" << mute->isLoadedFromServer() + << LL_ENDL; + } + if (base_network) + { + if (F64(LLTimer::getTotalSeconds()) >= sRuntime.next_list) + { + sRuntime.list_needed = true; + sRuntime.list_retry_used = false; + } + if (sRuntime.list_needed) + { + requestList(context, epoch); + continue; + } + if (!sRuntime.queue.empty()) + { + const LLUUID id = sRuntime.queue.front(); + sRuntime.queue.pop_front(); + syncResident(id, context, epoch); + continue; + } + + // Existing archives use the same cached names as blocking and views. + for (auto& pair : sRuntime.residents) + { + if (pair.second.summary.has_oldest && pair.second.metadata.state == META_UNREQUESTED) + { + ensureMetadata(pair.first, pair.second); + } + } + } + else + { + sRuntime.active_resident.setNull(); + for (auto& pair : sRuntime.residents) + { + setWorkActive(pair.first, false); + } + } + + // Storage maintenance yields to every eligible priority/network occurrence. + if (sRuntime.state_safety == STATE_SAFE && !sRuntime.cleanup_pending && + prepareOneArchive(epoch)) + { + continue; + } + if (sRuntime.index_dirty && sRuntime.state_safety == STATE_SAFE && + !sRuntime.cleanup_pending) + { + if (updateIndex(epoch)) + { + continue; + } + } + waitForWake(nearestWait(), epoch); + } +} + +bool clickTicks(U64& ticks) +{ + // Convert the click-time microsecond clock into the inclusive final UUIDv1 tick + // for that microsecond using checked integer arithmetic only. + const U64 sampled = LLTimer::getTotalTime(); + if (sampled > static_cast<U64>(LLONG_MAX)) + { + return false; + } + const S64 offset = static_cast<S64>(gUTCOffset) * 1000000LL; + S64 corrected = static_cast<S64>(sampled); + if ((offset > 0 && corrected > LLONG_MAX - offset) || + (offset < 0 && corrected < LLONG_MIN - offset)) + { + return false; + } + corrected += offset; + if (corrected < 0 || static_cast<U64>(corrected) > + (UUID_TICK_LIMIT - 1 - UUID_EPOCH - 9) / 10) + { + return false; + } + ticks = UUID_EPOCH + static_cast<U64>(corrected) * 10 + 9; + return ticks < UUID_TICK_LIMIT; +} + +bool legacyWallEpoch(const std::string& text, F64& epoch) +{ + if (text.size() < 15) + { + return false; + } + + // Legacy transcripts may use either a 12-hour suffix or a 24-hour wall clock. + int year = 0; + int month = 0; + int day = 0; + int hour = 0; + int minute = 0; + char suffix[3] = {}; + int consumed = 0; + if (std::sscanf(text.c_str(), "%d/%d/%d %d:%d %2s%n", &year, &month, &day, + &hour, &minute, suffix, &consumed) == 6) + { + if (consumed != static_cast<int>(text.size()) || + (strcmp(suffix, "AM") && strcmp(suffix, "PM")) || hour < 1 || hour > 12) + { + return false; + } + if (!strcmp(suffix, "AM")) + { + hour %= 12; + } + else if (hour != 12) + { + hour += 12; + } + } + else + { + consumed = 0; + if (std::sscanf(text.c_str(), "%d/%d/%d %d:%d%n", &year, &month, &day, + &hour, &minute, &consumed) != 5 || + consumed != static_cast<int>(text.size()) || hour < 0 || hour > 23) + { + return false; + } + } + if (minute < 0 || minute > 59) + { + return false; + } + try + { + const boost::posix_time::ptime value( + boost::gregorian::date(year, month, day), + boost::posix_time::hours(hour) + boost::posix_time::minutes(minute)); + static const boost::posix_time::ptime unix_epoch(boost::gregorian::date(1970, 1, 1)); + epoch = static_cast<F64>((value - unix_epoch).total_seconds()); + return true; + } + catch (...) + { + return false; + } +} + +LLSD serviceMessage(const Row& row) +{ + LLSD message; + + // Keep the validated wire value as metadata while rendering through the viewer's + // SLT transcript format. + const U32 timestamp = static_cast<U32>(LLDate(row.created_at).secondsSinceEpoch()); + message[LL_IM_TIME] = LLLogChat::timestamp2LogString(timestamp, true); + message[LL_IM_DATE_TIME] = row.created_at; + message[LL_IM_FROM] = row.from_name; + message[LL_IM_FROM_ID] = row.from_id; + message[LL_IM_TEXT] = row.message; + message["timestamp"] = static_cast<S32>(timestamp); + message["is_history"] = true; + message["chat_service_msg_id"] = row.msg_id; + return message; +} + +LLChatServiceHistory::HistoryResult readStitched( + const LLUUID& id, const LLUUID& agent_id, const std::string& archive_path, + const std::vector<std::string>& legacy_paths, U32 limit, U32 epoch, U32 serial, + U64 boundary, bool include_service, const std::vector<Row>& preview) +{ + LLChatServiceHistory::HistoryResult result; + result.account_epoch = epoch; + result.archive_serial = serial; + result.included_service = include_service; + ArchiveScan archive; + if (include_service) + { + // View reads serialize with publication but never repair or quarantine storage. + LLMutexLock lock(&sStorageMutex); + if (!scanArchive(archive_path, agent_id, id, boundary, limit, archive) && + archive.state != ARCHIVE_ABSENT) + { + result.maintenance_needed = true; + } + if (archive.state != ARCHIVE_VALID) + { + archive = ArchiveScan(); + } + } + + // Resolve the ordinary transcript and monthly shards in their canonical order. + std::list<LLSD> legacy; + LLSD parameters; + parameters["load_all_history"] = true; + parameters["cut_off_todays_date"] = false; + for (const std::string& path : legacy_paths) + { + LLLogChat::loadChatHistoryExact(path, legacy, parameters); + } + + const F64 service_epoch = archive.has_oldest + ? static_cast<F64>(archive.oldest.ticks - UUID_EPOCH) / 10000000.0 : 0.0; + + // Admit only the legacy prefix that may predate the durable service boundary. + // Offset-less SLT timestamps use their earliest UTC interpretation (UTC-7). + for (const LLSD& message : legacy) + { + F64 wall = 0.0; + if (!legacyWallEpoch(message[LL_IM_DATE_TIME].asString(), wall)) + { + result.messages.push_back(message); + } + else if (!archive.has_oldest || legacyWallMayPrecedeService(wall, service_epoch)) + { + LLSD stitched = message; + stitched[LEGACY_WALL_TIME] = wall; + result.messages.push_back(stitched); + } + } + // Storage reads and preview publications share the same occurrence-aware seam. + std::list<LLSD> service; + for (const Row& row : archive.display_rows) + { + service.push_back(serviceMessage(row)); + } + for (const Row& row : preview) + { + service.push_back(serviceMessage(row)); + } + result.messages = mergeDirectHistory(result.messages, service); + + // Apply the consumer limit to the complete seam order, retaining the service tail. + while (limit && result.messages.size() > limit) + { + result.messages.pop_front(); + } + + return result; +} +} + +void LLChatServiceHistory::start() +{ + // Reset any prior account first, then capture all per-login paths, IDs, gates, + // and signal connections before launching the manager coroutine. + stop(); + + ++sRuntime.epoch; + sRuntime.running = true; + sRuntime.rollout = gSavedSettings.getBOOL(ENABLED_SETTING); + sRuntime.account_dir = gDirUtilp + ? gDirUtilp->getExpandedFilename(LL_PATH_PER_SL_ACCOUNT, "") : std::string(); + sRuntime.agent_id = gAgentID; + sRuntime.state_safety = STATE_UNKNOWN; + sRuntime.next_list = 0.0; + sRuntime.list_needed = true; + + if (!sWake) + { + sWake.reset(new LLEventMailDrop("ChatServiceHistoryWake", true)); + } + sWake->discard(); + + sRegionConnection = gAgent.addRegionChangedCallback([]() + { + LLChatServiceHistory::regionChanged(); + }); + + // Consent changes revoke transient presentation immediately and wake the manager + // to re-evaluate every resident under the new account-wide gate. + if (gSavedPerAccountSettings.controlExists("KeepConversationLogTranscripts")) + { + sRuntime.consent_connection = gSavedPerAccountSettings + .getControl("KeepConversationLogTranscripts")->getSignal()->connect( + [](LLControlVariable*, const LLSD&, const LLSD&) + { + const bool allowed = transcriptConsent(); + for (auto& pair : sRuntime.residents) + { + if (!allowed) + { + pair.second.activity_refresh_due = 0.0; + pair.second.snapshot.head_preview.clear(); + pair.second.snapshot.service_work_active = false; + } + publishSnapshot(pair.first, pair.second); + } + wakeManager(); + }); + } + + // Presentation changes reload model-owned sessions without creating another + // history scheduler. + if (gSavedPerAccountSettings.controlExists("LogShowHistory")) + { + sRuntime.show_history_connection = gSavedPerAccountSettings + .getControl("LogShowHistory")->getSignal()->connect( + [](LLControlVariable*, const LLSD&, const LLSD&) + { + postPresentation([]() + { + LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); + }); + }); + } + + regionChanged(); + const U32 epoch = sRuntime.epoch; + LLCoros::instance().launch("ChatServiceHistory", [epoch]() + { + manager(epoch); + }); +} + +void LLChatServiceHistory::stop() +{ + if (sRuntime.running) + { + sRuntime.running = false; + ++sRuntime.epoch; + + for (auto& pair : sRuntime.residents) + { + pair.second.metadata.connection.disconnect(); + } + + if (sWake) + { + sWake->post(LLSD(true)); + } + } + + sRegionConnection.disconnect(); + sRuntime.consent_connection.disconnect(); + sRuntime.show_history_connection.disconnect(); + + const U32 epoch = sRuntime.epoch; + sRuntime = Runtime(); + sRuntime.epoch = epoch; +} + +void LLChatServiceHistory::regionChanged() +{ + LLViewerRegion* region = gAgent.getRegion(); + if (region && !region->capabilitiesReceived()) + { + region->setCapabilitiesReceivedCallback([](const LLUUID&, LLViewerRegion*) + { + wakeManager(); + }); + } + + wakeManager(); +} + +U32 LLChatServiceHistory::accountEpoch() +{ + return sRuntime.epoch; +} + +bool LLChatServiceHistory::historySuppressed() +{ + return sRuntime.state_safety != STATE_SAFE || sRuntime.cleanup_pending || + sRuntime.delete_requested; +} + +bool LLChatServiceHistory::servicePresentationAllowed() +{ + return sRuntime.rollout && transcriptConsent() && !historySuppressed(); +} + +bool LLChatServiceHistory::localHistoryExists() +{ + if (sRuntime.delete_active) + { + return false; + } + + if (sRuntime.delete_requested || sRuntime.cleanup_pending || + sRuntime.state_safety == STATE_UNSAFE) + { + return true; + } + + for (const auto& pair : sRuntime.residents) + { + if (pair.second.summary.has_oldest) + { + return true; + } + } + + return sRuntime.local_content_exists; +} + +bool LLChatServiceHistory::localHistoryExists(const LLUUID& resident_id) +{ + if (!servicePresentationAllowed()) + { + return false; + } + + const auto found = sRuntime.residents.find(resident_id); + if (found == sRuntime.residents.end() || found->second.summary.state == ARCHIVE_FAILED) + { + wakeManager(); + return false; + } + + return found->second.summary.has_oldest; +} + +bool LLChatServiceHistory::isPersistedDirectDialog(EInstantMessage dialog) +{ + return persistedDirectDialog(static_cast<S32>(dialog)); +} + +void LLChatServiceHistory::prioritizeResident(const LLUUID& id, bool follow_active_request) +{ + if (!sRuntime.running || id.isNull()) + { + return; + } + + // Coalesce priority triggers into one front occurrence. A qualifying trigger + // after the active request began records at most one follow-up pass. + Resident& resident = sRuntime.residents[id]; + resident.retry_used = false; + if (resident.metadata.state == META_FAILED) + { + resident.metadata.state = META_UNREQUESTED; + } + + if (sRuntime.active_resident == id) + { + if (follow_active_request && resident.first_request_started) + { + resident.forced_followup = true; + } + } + else + { + queueResident(id, true); + sRuntime.priority_resident = id; + } + + if (!resident.listed) + { + sRuntime.list_needed = true; + sRuntime.list_retry_used = false; + } + + const CapabilityContext context = sampleContext(); + const bool potentially_active = servicePresentationAllowed() && + context.complete() && !uuidBlocked(id); + setWorkActive(id, potentially_active); + wakeManager(); +} + +void LLChatServiceHistory::noteDirectMessageActivity(const LLUUID& id) +{ + if (!sRuntime.running || !sRuntime.rollout || !transcriptConsent() || + sRuntime.delete_requested || id.isNull() || id == sRuntime.agent_id) + { + return; + } + + // Each incoming or outgoing IM resets the quiet deadline. First contact + // also starts bounded discovery immediately. + Resident& resident = sRuntime.residents[id]; + resident.activity_refresh_due = + F64(LLTimer::getTotalSeconds()) + ACTIVITY_REFRESH_DELAY; + if (!resident.listed && resident.activity_discovery == DISCOVERY_NONE) + { + resident.activity_discovery = DISCOVERY_PENDING; + prioritizeResident(id); + } + else + { + wakeManager(); + } +} + +LLChatServiceHistory::Snapshot LLChatServiceHistory::getSnapshot(const LLUUID& id) +{ + const auto found = sRuntime.residents.find(id); + if (found == sRuntime.residents.end()) + { + Snapshot snapshot; + snapshot.service_presentation_allowed = servicePresentationAllowed(); + return snapshot; + } + return found->second.snapshot; +} + +boost::signals2::connection LLChatServiceHistory::setSnapshotChanged( + const snapshot_callback_t& callback) +{ + return sSnapshotSignal.connect(callback); +} + +static LLChatServiceHistory::Messages composeHistory( + LLChatServiceHistoryCore::History& history, + const LLChatServiceHistory::Snapshot& snapshot, U32 limit, const LLUUID& session_id) +{ + Messages preview; + if (snapshot.service_presentation_allowed) + { + for (const Row& row : snapshot.head_preview) + { + preview.push_back(serviceMessage(row)); + } + } + else + { + history.clearService(); + } + + // Preview has no live rows. An IM session supplies only its append-only deliveries. + Messages live; + LLFloaterIMSession* floater = nullptr; + if (session_id.notNull()) + { + floater = LLFloaterIMSession::findInstance(session_id); + if (const auto* session = LLIMModel::instance().findIMSession(session_id)) + { + for (const LLSD& message : session->mMsgs) + { + if (!message["is_history"].asBoolean()) + { + live.push_back(message); + } + } + } + } + return history.compose(preview, live, limit, floater && floater->isInVisibleChain()); +} + +bool LLChatServiceHistory::replaceHistory(Messages& current, const Messages& history, bool direct) +{ + Messages resolved = history; + // Legacy names can acquire a UUID from local caches without starting a lookup. + // Leave unknown IDs absent so reconciliation can still use the resident name. + for (LLSD& message : resolved) + { + const LLSD& source = message; + if (!source[LL_IM_FROM_ID].isDefined()) + { + const LLUUID id = LLAvatarNameCache::getInstance()->findIdByName( + LLCacheName::buildLegacyName(source[LL_IM_FROM].asString())); + if (id.notNull()) + { + message[LL_IM_FROM_ID] = id; + } + } + } + return LLChatServiceHistoryCore::replaceHistory(current, resolved, direct); +} + +LLSD LLChatServiceHistory::prepareLiveMessage( + const std::string& text, U32& timestamp, const LLSD& context) +{ + return captureLiveMessage(text, timestamp, context, static_cast<U32>(time_corrected())); +} + +void LLChatServiceHistory::recordLiveMessage(LLSD& message, const LLSD& context) +{ + LLChatServiceHistoryCore::recordLiveMessage(message, context, static_cast<U32>(time_corrected())); +} + +bool LLChatServiceHistory::loadStitchedHistory( + const LLUUID& id, const std::string& legacy_stem, U32 limit, + const history_callback_t& callback) +{ + if (!callback || id.isNull() || historySuppressed()) + { + return false; + } + + // Capture account and archive generations with the filesystem inputs so the + // main-queue completion can detect any intervening lifecycle or publication. + Resident& resident = sRuntime.residents[id]; + const bool include_service = servicePresentationAllowed(); + if (include_service) + { + ensureMetadata(id, resident); + } + + const U32 epoch = sRuntime.epoch; + const U32 serial = resident.archive_serial; + const U64 boundary = sRuntime.deleted_before_ticks; + const std::vector<Row> preview = include_service + ? resident.snapshot.head_preview : std::vector<Row>(); + const LLUUID agent_id = sRuntime.agent_id; + const std::string archive_path = archivePath(sRuntime.account_dir, id); + std::vector<std::string> legacy_paths; + LLLogChat::getTranscriptFamily(legacy_stem, legacy_paths); + + LL::WorkQueue::ptr_t main = LL::WorkQueue::getInstance("mainloop"); + LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); + if (!main || !general) + { + return false; + } + + // Read and stitch off the main thread; maintenance requests are applied only if + // the captured archive serial is still current. + return main->postTo(general, + [id, agent_id, archive_path, legacy_paths, limit, epoch, serial, boundary, + include_service, preview]() + { + return readStitched(id, agent_id, archive_path, legacy_paths, limit, epoch, + serial, boundary, include_service, preview); + }, + [id, epoch, serial, callback](HistoryResult result) + { + if (result.maintenance_needed && sRuntime.epoch == epoch) + { + auto found = sRuntime.residents.find(id); + if (found != sRuntime.residents.end() && + found->second.archive_serial == serial) + { + found->second.summary.state = ARCHIVE_FAILED; + found->second.covered_token.clear(); + wakeManager(); + } + } + + callback(result); + }); +} + +void LLChatServiceHistory::History::load( + const LLUUID& resident_id, const std::string& legacy_stem, U32 limit, + const callback_t& callback, const LLUUID& session_id) +{ + // Replacing a request retains visible context but retires its completion. + const bool first_load = !mConnection.connected(); + ++mToken; + mLoading = false; + mResidentID = resident_id; + mSessionID = session_id; + mLegacyStem = legacy_stem; + mLimit = limit; + mCallback = callback; + mConnection = setSnapshotChanged([this](const LLUUID& changed, const Snapshot& snapshot) + { + if (changed == mResidentID) + { + onSnapshot(snapshot); + } + }); + + // Subscribe before querying; a cold name cache may supply the plaintext stem + // later, and the first service page can be displayed while disk work runs. + const Snapshot snapshot = getSnapshot(mResidentID); + mArchiveSerial = snapshot.archive_serial; + if (first_load) + { + mServiceAllowed = snapshot.service_presentation_allowed; + } + if (historySuppressed()) + { + clear(); + mCallback({}); + return; + } + onSnapshot(snapshot); + if (!mLoading) + { + reload(); + } +} + +void LLChatServiceHistory::History::clear() +{ + // Deletion invalidates pending reads before owners clear their displayed history. + ++mToken; + mLoading = false; + mArchiveSerial = 0; + mServiceAllowed = false; + mHistory.clear(); +} + +void LLChatServiceHistory::History::stop() +{ + mConnection.disconnect(); + clear(); +} + +void LLChatServiceHistory::History::publish(const Snapshot& snapshot) +{ + mCallback(composeHistory(mHistory, snapshot, mLimit, mSessionID)); +} + +void LLChatServiceHistory::History::reload() +{ + const U64 token = ++mToken; + const std::string legacy_stem = mLegacyStem; + mLoading = loadStitchedHistory(mResidentID, legacy_stem, mLimit, + [handle = getHandle(), token, legacy_stem](const HistoryResult& result) + { + History* self = handle.get(); + if (!self || token != self->mToken) + { + return; + } + self->mLoading = false; + + // Account changes and deletion discard the read. Archive publication, + // consent, or newly resolved names require one read of the current inputs. + if (result.account_epoch != accountEpoch() || historySuppressed()) + { + return; + } + const Snapshot snapshot = getSnapshot(self->mResidentID); + if (result.archive_serial != snapshot.archive_serial || + result.included_service != snapshot.service_presentation_allowed || + legacy_stem != self->mLegacyStem) + { + self->reload(); + return; + } + + self->mArchiveSerial = result.archive_serial; + self->mHistory.setLoaded(result.messages); + self->publish(snapshot); + }); + // Open IMs fall back to plaintext when the queues are unavailable; Preview + // stays nonblocking. + if (!mLoading && mSessionID.notNull() && !historySuppressed()) + { + Messages legacy; + LLLogChat::loadChatHistory(mLegacyStem, legacy); + mHistory.setLoaded(legacy); + mCallback(legacy); + } +} + +void LLChatServiceHistory::History::onSnapshot(const Snapshot& snapshot) +{ + if (historySuppressed()) + { + return; + } + const bool presentation_changed = mServiceAllowed != snapshot.service_presentation_allowed; + mServiceAllowed = snapshot.service_presentation_allowed; + + // A service-only conversation gains its plaintext history when its name resolves. + const bool name_resolved = mLegacyStem.empty() && snapshot.metadata_resolved; + if (name_resolved) + { + mLegacyStem = LLCacheName::buildUsername(snapshot.metadata.getUserName()); + } + // Apply consent changes and validated previews while the disk read catches up. + if (presentation_changed || !snapshot.head_preview.empty()) + { + publish(snapshot); + } + + // In-flight reads compare their captured inputs on completion, coalescing any + // number of snapshot changes into one follow-up read. + if (!mLoading && (presentation_changed || name_resolved || + snapshot.archive_serial != mArchiveSerial)) + { + reload(); + } +} + +bool LLChatServiceHistory::deleteTranscriptsAsync(const delete_callback_t& callback) +{ + if (!sRuntime.running || sRuntime.delete_active || !callback) + { + return false; + } + + U64 ticks = 0; + if (!clickTicks(ticks)) + { + return false; + } + + // Latch deletion and retire activity deadlines so no new request or view read can + // start, and no expired wake source remains before pending privacy state is durable. + sRuntime.delete_click_ticks = ticks; + sRuntime.delete_callback = callback; + sRuntime.delete_active = true; + sRuntime.delete_requested = true; + for (auto& pair : sRuntime.residents) + { + pair.second.activity_refresh_due = 0.0; + } + + wakeManager(); + return true; +} diff --git a/indra/newview/llchatservicehistory.h b/indra/newview/llchatservicehistory.h new file mode 100644 index 0000000000..2a6d241120 --- /dev/null +++ b/indra/newview/llchatservicehistory.h @@ -0,0 +1,133 @@ +/** + * @file llchatservicehistory.h + * @brief ChatService direct-IM history synchronization and stitched reads. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * $/LicenseInfo$ + */ +#ifndef LL_LLCHATSERVICEHISTORY_H +#define LL_LLCHATSERVICEHISTORY_H + +#include "llavatarname.h" +#include "llchatservicehistorycore.h" +#include "llhandle.h" +#include "llinstantmessage.h" +#include "llsd.h" +#include "lluuid.h" + +#include <boost/function.hpp> +#include <boost/signals2/connection.hpp> +#include <list> +#include <vector> + +namespace LLChatServiceHistory +{ + // Resident snapshots are published on the main thread and contain every value + // direct-IM and Preview consumers need to render or reload history. + struct Snapshot + { + // Remote, metadata, or storage work is still active for this resident. + bool service_work_active = false; + + // Service rows may be presented under the current account-wide gates. + bool service_presentation_allowed = false; + + // Consumers reject local reads captured before the latest archive mutation. + U32 archive_serial = 0; + + // Shared name metadata is ready for blocking and legacy-path resolution. + bool metadata_resolved = false; + LLAvatarName metadata; + + // The validated first page is presentation-only until durable publication. + std::vector<LLChatServiceHistoryCore::Row> head_preview; + }; + + // An asynchronous stitched read carries the account and archive generations + // needed to reject results that became stale while filesystem work ran. + struct HistoryResult + { + std::list<LLSD> messages; + U32 account_epoch = 0; + U32 archive_serial = 0; + bool included_service = false; + + // The reader classified a canonical archive that manager-side maintenance must revisit. + bool maintenance_needed = false; + }; + + typedef boost::function<void(const HistoryResult&)> history_callback_t; + typedef boost::function<void(bool)> delete_callback_t; + typedef boost::function<void(const LLUUID&, const Snapshot&)> snapshot_callback_t; + + // The synchronizer has one lifecycle per logged-in account. + void start(); + void stop(); + void regionChanged(); + + // Account-wide gates keep all historical sources fail-closed during unsafe state + // recovery or transcript deletion. + U32 accountEpoch(); + bool historySuppressed(); + bool servicePresentationAllowed(); + bool localHistoryExists(); + bool localHistoryExists(const LLUUID& resident_id); + + // Opens and due direct-message activity share one account-scoped priority queue. + bool isPersistedDirectDialog(EInstantMessage dialog); + void prioritizeResident(const LLUUID& resident_id, bool follow_active_request = false); + // Incoming and outgoing IMs reset one quiet deadline per resident. + void noteDirectMessageActivity(const LLUUID& resident_id); + + // Views connect first and then query so they cannot miss an active-work transition. + Snapshot getSnapshot(const LLUUID& resident_id); + boost::signals2::connection setSnapshotChanged(const snapshot_callback_t& callback); + using Messages = LLChatServiceHistoryCore::Messages; + + // A view owns its subscription and current read. Callbacks run on the main loop; + // the handle and request token reject results after destruction or reload. + class History : public LLHandleProvider<History> + { + public: + using callback_t = boost::function<void(const Messages&)>; + void load(const LLUUID& resident_id, const std::string& legacy_stem, U32 limit, + const callback_t& callback, const LLUUID& session_id = LLUUID::null); + void clear(); + void stop(); + bool isLoading() const { return mLoading; } + + private: + void reload(); + void onSnapshot(const Snapshot& snapshot); + void publish(const Snapshot& snapshot); + + LLUUID mResidentID; + LLUUID mSessionID; + std::string mLegacyStem; + U32 mLimit = 0; + U64 mToken = 0; + U32 mArchiveSerial = 0; + bool mLoading = false; + bool mServiceAllowed = false; + LLChatServiceHistoryCore::History mHistory; + callback_t mCallback; + boost::signals2::scoped_connection mConnection; + }; + + bool replaceHistory(Messages& current, const Messages& history, bool direct); + + // Core IM delivery carries one opaque context through translation and logging. + LLSD prepareLiveMessage(const std::string& text, U32& timestamp, const LLSD& context); + void recordLiveMessage(LLSD& message, const LLSD& context); + + // The callback runs on the main queue after legacy and service storage are read. + bool loadStitchedHistory(const LLUUID& resident_id, const std::string& legacy_stem, + U32 limit, const history_callback_t& callback); + + // Deletion records its durable cutoff before clearing views or sweeping files. + bool deleteTranscriptsAsync(const delete_callback_t& callback); +} + +#endif diff --git a/indra/newview/llchatservicehistorycore.cpp b/indra/newview/llchatservicehistorycore.cpp new file mode 100644 index 0000000000..88b890e7a1 --- /dev/null +++ b/indra/newview/llchatservicehistorycore.cpp @@ -0,0 +1,1022 @@ +/** + * @file llchatservicehistorycore.cpp + * @brief ChatService wire/storage primitives and direct history composition. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * $/LicenseInfo$ + */ +#include "llviewerprecompiledheaders.h" + +#include "llchatservicehistorycore.h" + +#include "llcachename.h" +#include "lldate.h" +#include "llfile.h" +#include "llstring.h" +#include "llsdutil.h" +#include "fsyspath.h" + +#if LL_WINDOWS +#include "llwin32headers.h" +#else +#include <sys/stat.h> +#endif + +#include <algorithm> +#include <boost/regex.hpp> +#include <cerrno> +#include <limits> +#include <map> +#include <set> + +namespace LLChatServiceHistoryCore +{ +const char* const CSV_HEADER = + "conversation_id,msg_id,from_id,from_name,message,dialog,created_at\n"; +const char* const INDEX_HEADER = "filename,agent_id,agent_name,display_name\n"; + +namespace +{ + const U32 COLUMN_COUNT = 7; + + bool cleanText(const std::string& value, size_t limit) + { + // Wire and CSV text must remain bounded, NUL-free, and canonical UTF-8. + return value.size() <= limit && value.find('\0') == std::string::npos && + wstring_to_utf8str(utf8str_to_wstring(value)) == value; + } + + bool decimalDialog(const std::string& text, S32& dialog) + { + if (text.empty() || (text.size() > 1 && text[0] == '0') || + text.find_first_not_of("0123456789") != std::string::npos) + { + return false; + } + + return LLStringUtil::convertToS32(text, dialog) && persistedDirectDialog(dialog); + } + + bool rowFromFields(const std::vector<std::string>& fields, + const LLUUID& agent_id, const LLUUID& resident_id, + Row& row) + { + // A stored row is trusted only after every field and cross-field identity + // constraint matches the current account and resident. + if (fields.size() != COLUMN_COUNT || + fields[0] != directConversationId(agent_id, resident_id) || + !parseTimeUuid(fields[1], row.key) || + !parseCanonicalUuid(fields[2], row.from_id) || + (row.from_id != agent_id && row.from_id != resident_id) || + !cleanText(fields[3], 256) || !cleanText(fields[4], 1024) || + !decimalDialog(fields[5], row.dialog) || + !parseCreatedAt(fields[6], row.created_at)) + { + return false; + } + + row.conversation_id = fields[0]; + row.msg_id = fields[1]; + row.from_name = fields[3]; + row.message = fields[4]; + return true; + } + + // Decode RFC-4180 records incrementally so validation and torn-tail recovery + // classify the same byte stream with one parser. + bool readRecord(std::istream& input, std::vector<std::string>& fields, + bool& eof_torn, bool& had_record) + { + fields.clear(); + eof_torn = false; + had_record = false; + std::string field; + bool quoted = false; + bool quote_closed = false; + bool started = false; + + for (;;) + { + const int raw = input.get(); + if (raw == EOF) + { + // EOF after a complete unquoted field is a valid final record. An + // open quote or too few columns marks only the final record as torn. + had_record = started; + eof_torn = started && (quoted || fields.size() + 1 < COLUMN_COUNT); + if (started && !eof_torn && !quoted) + { + fields.push_back(field); + } + return !input.bad(); + } + started = true; + const char ch = static_cast<char>(raw); + + // Quoted fields retain delimiters and newlines; doubled quotes decode + // to one literal quote. + if (quoted) + { + if (ch != '"') + { + field.push_back(ch); + continue; + } + if (input.peek() == '"') + { + input.get(); + field.push_back('"'); + continue; + } + quoted = false; + quote_closed = true; + continue; + } + + // Outside quotes, delimiters advance columns and either newline style + // terminates the record. Bytes after a closing quote are invalid. + if (ch == '"') + { + if (!field.empty() || quote_closed) + { + return false; + } + quoted = true; + } + else if (ch == ',') + { + fields.push_back(field); + field.clear(); + quote_closed = false; + } + else if (ch == '\n' || ch == '\r') + { + if (ch == '\r' && input.peek() == '\n') + { + input.get(); + } + fields.push_back(field); + had_record = true; + return true; + } + else if (quote_closed) + { + return false; + } + else + { + field.push_back(ch); + } + } + } + + bool headerMatches(const std::vector<std::string>& fields) + { + static const std::vector<std::string> header{ + "conversation_id", "msg_id", "from_id", "from_name", + "message", "dialog", "created_at"}; + return fields == header; + } + + bool llsdString(const LLSD& map, const char* name, std::string& value) + { + const LLSD& field = map[name]; + if (!field.isString()) + { + return false; + } + value = field.asString(); + return true; + } +} + +bool inspectRegular(const std::string& path, bool& exists) +{ + exists = false; +#if LL_WINDOWS + const DWORD attributes = GetFileAttributesW(ll_convert<std::wstring>(path).c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) + { + return GetLastError() == ERROR_FILE_NOT_FOUND || + GetLastError() == ERROR_PATH_NOT_FOUND; + } + exists = true; + return !(attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)); +#else + struct stat status; + if (::lstat(path.c_str(), &status) != 0) + { + return errno == ENOENT; + } + exists = true; + return S_ISREG(status.st_mode); +#endif +} + +bool TimeUuidKey::operator==(const TimeUuidKey& rhs) const +{ + return ticks == rhs.ticks && tail == rhs.tail; +} + +bool TimeUuidKey::operator<(const TimeUuidKey& rhs) const +{ + // Cassandra orders equal-time UUIDs by signed bytes 8-15, not UUID text. + return ticks != rhs.ticks ? ticks < rhs.ticks : tail < rhs.tail; +} + +std::string directConversationId(const LLUUID& agent_id, const LLUUID& resident_id) +{ + return agent_id < resident_id + ? agent_id.asString() + "_" + resident_id.asString() + : resident_id.asString() + "_" + agent_id.asString(); +} + +bool parseCanonicalUuid(const std::string& text, LLUUID& id) +{ + if (text.size() != 36 || text != utf8str_tolower(text) || !LLUUID::validate(text)) + { + return false; + } + + id.set(text, false); + return id.asString() == text; +} + +bool parseTimeUuid(const std::string& text, TimeUuidKey& key) +{ + LLUUID id; + if (!parseCanonicalUuid(text, id) || (id.mData[6] >> 4) != 1 || + (id.mData[8] & 0xc0) != 0x80) + { + return false; + } + + // UUIDv1 stores the 60-bit timestamp across three network-order fields. + const U64 time_low = (U64(id.mData[0]) << 24) | (U64(id.mData[1]) << 16) | + (U64(id.mData[2]) << 8) | U64(id.mData[3]); + const U64 time_mid = (U64(id.mData[4]) << 8) | U64(id.mData[5]); + const U64 time_high = (U64(id.mData[6] & 0x0f) << 8) | U64(id.mData[7]); + key.ticks = (time_high << 48) | (time_mid << 32) | time_low; + + for (U32 pos = 0; pos < key.tail.size(); ++pos) + { + key.tail[pos] = static_cast<S8>(id.mData[pos + 8]); + } + + return true; +} + +bool persistedDirectDialog(S32 dialog) +{ + switch (dialog) + { + case 0: + case 3: + case 4: + case 20: + case 22: + case 26: + case 38: + return true; + + default: + return false; + } +} + +bool sameDirectSenderName(const std::string& left, const std::string& right) +{ + // Extract the resident name from complete transcript names before normalizing + // legacy and dotted forms; the display-name prefix is presentation only. + const std::string left_resident = + LLCacheName::buildUsername(LLCacheName::buildLegacyName(left)); + const std::string right_resident = + LLCacheName::buildUsername(LLCacheName::buildLegacyName(right)); + return !left_resident.empty() && left_resident == right_resident; +} + +bool sameDirectHistoryOccurrence(const LLSD& history, const LLSD& timed) +{ + if (!timed["timestamp"].isInteger() || timed["timestamp"].asInteger() <= 0) + { + return false; + } + + // Service bodies omit offline notices and translations. Plaintext contains + // the rendered body, so compare it against the same displayed value. + const bool service = history["chat_service_msg_id"].isString(); + const LLSD& text = service && timed["chat_service_original_text"].isString() + ? timed["chat_service_original_text"] : timed["message"]; + if (history["message"].asString() != text.asString()) + { + return false; + } + + // UUIDs are authoritative when available; plaintext otherwise identifies + // its sender by the resident username, including complete-name spellings. + const bool same_sender = history["from_id"].isDefined() && timed["from_id"].isDefined() + ? history["from_id"].asUUID() == timed["from_id"].asUUID() + : sameDirectSenderName(history["from"].asString(), timed["from"].asString()); + if (!same_sender) + { + return false; + } + + const S32 timestamp = timed["timestamp"].asInteger(); + if (service) + { + if (!history["timestamp"].isInteger() || history["timestamp"].asInteger() <= 0) + { + return false; + } + + const S32 service_timestamp = history["timestamp"].asInteger(); + // Receipt and service recording use separate clocks and can finish in + // either order. Admit bounded service lead as well as delivery delay. + if (timed["chat_service_time_source"].asString() == "receipt") + { + constexpr S64 MAX_DELIVERY_DELAY = 60; + constexpr S64 MAX_SERVICE_LEAD = 5; + const S64 delay = static_cast<S64>(timestamp) - service_timestamp; + return delay >= -MAX_SERVICE_LEAD && delay <= MAX_DELIVERY_DELAY; + } + + // Outgoing local echoes admit clock skew within their UTC minute. Saved + // offline send timestamps and unmarked callers retain exact seconds. + if (timed["chat_service_time_source"].asString() == "local") + { + return service_timestamp / 60 == timestamp / 60; + } + + return service_timestamp == timestamp; + } + + // Offline IMs are logged when delivered, potentially days after being sent. + // Legacy SLT minutes admit either UTC-7 or UTC-8 because no offset is stored. + if (!history["chat_service_legacy_wall_time"].isReal()) + { + return false; + } + const S32 logged = timed["chat_service_log_timestamp"].isInteger() + ? timed["chat_service_log_timestamp"].asInteger() : timestamp; + for (const F64 offset : { 7.0 * 3600.0, 8.0 * 3600.0 }) + { + const F64 minute = history["chat_service_legacy_wall_time"].asReal() + offset; + if (logged >= minute && logged < minute + 60.0) + { + return true; + } + } + return false; +} + +std::list<LLSD> filterDirectHistoryDuplicates( + const std::list<LLSD>& history, const std::list<LLSD>& live) +{ + // Reserve known send times before receipt intervals and local-clock minutes. + // Within each kind, match newest deliveries to newest eligible occurrences so + // earlier repeated messages retain their historical position. + auto priority = [](const LLSD& message) + { + const std::string source = message["chat_service_time_source"].asString(); + return source == "receipt" ? 1 : source == "local" ? 2 : 0; + }; + std::vector<const LLSD*> deliveries; + for (const LLSD& message : live) deliveries.push_back(&message); + std::stable_sort(deliveries.begin(), deliveries.end(), [&](const LLSD* a, const LLSD* b) + { + return priority(*a) != priority(*b) ? priority(*a) < priority(*b) + : (*a)["timestamp"].asInteger() > (*b)["timestamp"].asInteger(); + }); + + // Sorted iterators allow matching in time order while retaining the source order. + Messages result = history; + std::vector<Messages::const_iterator> service; + for (auto row = result.cbegin(); row != result.cend(); ++row) + { + if ((*row)["chat_service_msg_id"].isString()) service.push_back(row); + } + std::stable_sort(service.begin(), service.end(), [](auto a, auto b) + { + return (*a)["timestamp"].asInteger() > (*b)["timestamp"].asInteger(); + }); + std::vector<bool> matched(deliveries.size(), false); + for (size_t pos = 0; pos < deliveries.size(); ++pos) + { + const auto match = std::find_if(service.begin(), service.end(), [&](auto row) + { + return row != result.cend() && sameDirectHistoryOccurrence(*row, *deliveries[pos]); + }); + if (match != service.end()) + { + result.erase(*match); + *match = result.cend(); + matched[pos] = true; + } + } + + // Plaintext uses the remaining occurrences. Saved/translated deliveries can + // also identify a decorated log copy that could not match the raw service body. + for (size_t pos = 0; pos < deliveries.size(); ++pos) + { + const LLSD& delivery = *deliveries[pos]; + if (matched[pos] && !(delivery["chat_service_original_text"].isString() && + delivery["chat_service_original_text"].asString() != delivery["message"].asString())) + { + continue; + } + const auto match = std::find_if(result.begin(), result.end(), [&](const LLSD& row) + { + return !row["chat_service_msg_id"].isString() && sameDirectHistoryOccurrence(row, delivery); + }); + if (match != result.end()) result.erase(match); + } + return result; +} + +std::list<LLSD> interleaveDirectHistory(const std::list<LLSD>& history, + const std::list<LLSD>& live) +{ + std::list<LLSD> result = live; + auto position = result.begin(); + + // A service head may contain messages sent after live delivery began. Insert + // them by UTC time while retaining both sources' order and untimed live anchors. + for (const LLSD& message : history) + { + const S32 timestamp = message["timestamp"].asInteger(); + while (position != result.end() && + (timestamp <= 0 || (*position)["timestamp"].asInteger() <= 0 || + (*position)["timestamp"].asInteger() >= timestamp)) + { + ++position; + } + result.insert(position, message); + } + return result; +} + +Messages mergeDirectHistory(const Messages& loaded, const Messages& incoming) +{ + // Each input is a seam already reconciled against its own service IDs. + // Reconcile only IDs missing from that input, then union legacy occurrences. + struct Seam + { + Messages legacy; + std::map<TimeUuidKey, LLSD> service; + explicit Seam(const Messages& messages) + { + for (const LLSD& row : messages) + { + TimeUuidKey key; + if (parseTimeUuid(row["chat_service_msg_id"].asString(), key)) + service.emplace(key, row); + else + legacy.push_back(row); + } + } + } left(loaded), right(incoming); + + auto reconcile = [](Seam& seam, const Seam& other) + { + // Index minutes so full transcript reads do not compare every pair. + std::multimap<S64, const LLSD*> added; + for (const auto& entry : other.service) + { + if (!seam.service.count(entry.first)) + added.emplace(entry.second["timestamp"].asInteger() / 60, &entry.second); + } + seam.legacy.remove_if([&](const LLSD& row) + { + if (!row["chat_service_legacy_wall_time"].isReal()) return false; + for (S64 offset : {7 * 3600, 8 * 3600}) + { + const S64 minute = (static_cast<S64>(row["chat_service_legacy_wall_time"].asReal()) + offset) / 60; + const auto range = added.equal_range(minute); + for (auto candidate = range.first; candidate != range.second; ++candidate) + { + if (sameDirectHistoryOccurrence(row, *candidate->second)) + { + added.erase(candidate); + return true; + } + } + } + return false; + }); + }; + reconcile(left, right); + reconcile(right, left); + + // Shared plaintext occurrences count once; distinct repeated lines retain + // the greater occurrence count from the two bounded source windows. + Messages unmatched = left.legacy; + for (const LLSD& row : right.legacy) + { + const auto match = std::find_if(unmatched.begin(), unmatched.end(), + [&](const LLSD& candidate) { return llsd_equals(candidate, row); }); + if (match == unmatched.end()) left.legacy.push_back(row); + else unmatched.erase(match); + } + left.legacy.sort([](const LLSD& a, const LLSD& b) + { + const LLSD& at = a["chat_service_legacy_wall_time"]; + const LLSD& bt = b["chat_service_legacy_wall_time"]; + return at.isReal() && (!bt.isReal() || at.asReal() < bt.asReal()); + }); + + // Loaded canonical values win preview identity; all service rows use TimeUUID order. + left.service.insert(right.service.begin(), right.service.end()); + for (const auto& entry : left.service) left.legacy.push_back(entry.second); + return left.legacy; +} + +void History::setLoaded(const Messages& messages) +{ + mLoaded = messages; + if (messages.empty()) + { + mVisible.clear(); + } +} + +void History::clear() +{ + mLoaded.clear(); + mVisible.clear(); +} + +void History::clearService() +{ + auto service = [](const LLSD& row) { return row["chat_service_msg_id"].isString(); }; + mLoaded.remove_if(service); + mVisible.remove_if(service); +} + +Messages History::compose(const Messages& preview, const Messages& live, U32 limit, + bool retain_context) +{ + // Retain bounded visible context while open, including plaintext that has not + // acquired a service ID. Compose before filtering so live copies cannot evict it. + Messages result = mergeDirectHistory(mLoaded, preview); + if (retain_context) + { + result = mergeDirectHistory(result, mVisible); + } + result = filterDirectHistoryDuplicates(result, live); + while (limit && result.size() > limit) + { + result.pop_front(); + } + mVisible = retain_context ? result : Messages(); + return result; +} + +bool replaceHistory(Messages& current, const Messages& history, bool direct) +{ + Messages live; + for (const LLSD& message : current) + { + if (!message["is_history"].asBoolean()) + { + live.push_back(message); + } + } + Messages historical; + for (const LLSD& source : history) + { + LLSD message = source; + message["timestamp"] = source["timestamp"].asInteger(); + message["is_history"] = true; + message["is_region_msg"] = false; + historical.push_front(message); + } + if (direct) + { + live = interleaveDirectHistory(historical, live); + } + else + { + live.insert(live.end(), historical.begin(), historical.end()); + } + + // Index changes alone do not require replaying inline notification widgets. + if (current.size() == live.size() && + std::equal(current.begin(), current.end(), live.begin(), [](LLSD left, LLSD right) + { + left.erase("index"); + right.erase("index"); + return llsd_equals(left, right); + })) + { + return false; + } + current.swap(live); + S32 index = static_cast<S32>(current.size()); + for (LLSD& message : current) + { + message["index"] = --index; + } + return true; +} + +LLSD incomingContext(const LLSD& original_text, bool online) +{ + LLSD context; + context["chat_service_original_text"] = original_text; + context["chat_service_time_source"] = online ? "receipt" : "sent"; + return context; +} + +LLSD captureLiveMessage(const std::string& text, U32& timestamp, const LLSD& context, U32 now) +{ + LLSD captured = context; + if (!context["chat_service_original_text"].isString()) + { + captured["chat_service_original_text"] = text; + } + if (!context["chat_service_time_source"].isString() || + (!timestamp && context["chat_service_time_source"].asString() != "receipt")) + { + captured["chat_service_time_source"] = timestamp ? "sent" : "local"; + } + if (!timestamp) + { + timestamp = now; + } + return captured; +} + +void recordLiveMessage(LLSD& message, const LLSD& context, U32 logged_at) +{ + message["chat_service_original_text"] = context["chat_service_original_text"]; + message["chat_service_time_source"] = context["chat_service_time_source"]; + message["chat_service_log_timestamp"] = static_cast<S32>(logged_at); +} + +bool legacyWallMayPrecedeService(F64 wall_epoch, F64 service_epoch) +{ + return wall_epoch + 7.0 * 3600.0 < service_epoch; +} + +bool parseCreatedAt(const std::string& text, std::string& normalized) +{ + // Match the entire ISO timestamp, with bounded clock/offset fields and an + // optional nonempty fraction. LLDate itself also accepts malformed suffixes. + static const boost::regex shape( + R"(([0-9]{4})-([0-9]{2})-([0-9]{2})T)" + R"((?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9])" + R"((?:\.[0-9]+)?(Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])?)"); + boost::smatch fields; + if (!boost::regex_match(text, fields, shape)) + { + return false; + } + + const U32 year = std::stoi(fields[1]); + const U32 month = std::stoi(fields[2]); + const U32 day = std::stoi(fields[3]); + static const U32 DAYS_PER_MONTH[] = { + 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 + }; + if (!year || month < 1 || month > 12) + { + return false; + } + + // Validate the day against the parsed month, including Gregorian leap years. + U32 month_days = DAYS_PER_MONTH[month - 1]; + if (month == 2 && (year % 400 == 0 || (year % 4 == 0 && year % 100 != 0))) + { + ++month_days; + } + if (day < 1 || day > month_days) + { + return false; + } + + // Offset-less timestamps are UTC; preserve an explicit zone and fractional precision. + const std::string parsed = fields[4].matched ? text : text + 'Z'; + LLDate date; + if (!date.fromString(parsed)) + { + return false; + } + + normalized = parsed; + return true; +} + +bool validateConversationList(const LLSD& value, const LLUUID& agent_id, + std::vector<ListEntry>& entries) +{ + entries.clear(); + + // Reject the complete discovery response before extracting any direct entries. + if (!value.isArray() || value.size() > 10000) + { + return false; + } + + std::set<LLUUID> residents; + for (LLSD::array_const_iterator it = value.beginArray(); it != value.endArray(); ++it) + { + // Every list row must have a known type and bounded canonical identifiers, + // even when non-direct rows are ignored by this feature. + std::string type; + std::string conversation; + if (!it->isMap() || !llsdString(*it, "conversation_type", type) || + !llsdString(*it, "conversation_id", conversation) || conversation.empty() || + conversation.size() > 512 || !cleanText(conversation, 512) || + (type != "direct" && type != "group" && type != "adhoc")) + { + return false; + } + if (type != "direct") + { + continue; + } + + // Direct entries use the deterministic account/participant conversation ID + // with a valid latest-message token and no duplicate participants. + std::string resident_text; + std::string token; + LLUUID resident; + TimeUuidKey token_key; + if (!llsdString(*it, "other_participant_id", resident_text) || + !llsdString(*it, "last_msg_id", token) || + !parseCanonicalUuid(resident_text, resident) || + resident == agent_id || + !parseTimeUuid(token, token_key) || + conversation != directConversationId(agent_id, resident) || + !residents.insert(resident).second) + { + return false; + } + + // The service also lists system traffic with a null participant; only + // resident conversations can have a local history archive. + if (resident.notNull()) + { + entries.push_back({resident, token}); + } + } + + return true; +} + +bool validateHistoryPage(const LLSD& value, const LLUUID& agent_id, + const LLUUID& resident_id, + const std::string& requested_cursor, + U64 deleted_before_ticks, Page& page) +{ + page = Page(); + const std::string conversation_id = directConversationId(agent_id, resident_id); + + // The service echoes the exact conversation and fixed page size. Coercible LLSD + // types are rejected so paging state cannot drift across malformed responses. + if (!value.isMap() || !value["conversation_id"].isString() || + value["conversation_id"].asString() != conversation_id || + !value["limit"].isInteger() || value["limit"].asInteger() != 100 || + !value["messages"].isArray() || value["messages"].size() > 100) + { + return false; + } + + // Cursor echoes are exact: the head omits one, while older pages repeat the + // requested UUID unchanged. + const LLSD& echoed = value["before_msg_id"]; + if ((requested_cursor.empty() && !echoed.isUndefined()) || + (!requested_cursor.empty() && + (!echoed.isString() || echoed.asString() != requested_cursor))) + { + return false; + } + + TimeUuidKey cursor_key; + if (!requested_cursor.empty() && !parseTimeUuid(requested_cursor, cursor_key)) + { + return false; + } + + TimeUuidKey previous; + bool have_previous = false; + + // Validate every row before the page can affect scheduler or archive state. + // Strictly descending UUIDv1 keys below the cursor exclude duplicate IDs + // both within this page and across earlier pages in the same pass. + for (LLSD::array_const_iterator it = value["messages"].beginArray(); + it != value["messages"].endArray(); ++it) + { + Row row; + std::string from_text; + if (!it->isMap() || !llsdString(*it, "conversation_id", row.conversation_id) || + !llsdString(*it, "msg_id", row.msg_id) || + !llsdString(*it, "from_id", from_text) || + !llsdString(*it, "from_name", row.from_name) || + !llsdString(*it, "message", row.message) || + !(*it)["dialog"].isInteger() || + !llsdString(*it, "created_at", row.created_at) || + row.conversation_id != conversation_id || + !parseTimeUuid(row.msg_id, row.key) || + !parseCanonicalUuid(from_text, row.from_id) || + (row.from_id != agent_id && row.from_id != resident_id) || + !cleanText(row.from_name, 256) || !cleanText(row.message, 1024) || + !persistedDirectDialog(row.dialog = (*it)["dialog"].asInteger()) || + !parseCreatedAt(row.created_at, row.created_at) || + (have_previous && !(row.key < previous)) || + (!requested_cursor.empty() && !(row.key < cursor_key))) + { + return false; + } + + have_previous = true; + previous = row.key; + page.next_cursor = row.msg_id; + + // The account-wide cutoff is inclusive. Once reached, older rows from the + // same validated page remain suppressed and older paging terminates. + if (row.key.ticks <= deleted_before_ticks) + { + page.terminal = true; + } + else + { + page.rows.push_back(row); + } + } + + page.terminal = page.terminal || value["messages"].size() == 0; + if (page.terminal) + { + page.next_cursor.clear(); + } + + return true; +} + +std::string quoteCsv(const std::string& value) +{ + if (value.find_first_of(",\"\r\n") == std::string::npos) + { + return value; + } + + std::string result("\""); + for (char ch : value) + { + result.push_back(ch); + if (ch == '"') + { + result.push_back('"'); + } + } + + return result + '"'; +} + +void writeCsvRow(std::ostream& output, const Row& row) +{ + output << quoteCsv(row.conversation_id) << ',' << quoteCsv(row.msg_id) << ',' + << quoteCsv(row.from_id.asString()) << ',' << quoteCsv(row.from_name) << ',' + << quoteCsv(row.message) << ',' << row.dialog << ',' + << quoteCsv(row.created_at) << '\n'; +} + +bool archiveStamp(const std::string& path, U64& file_size, S64& file_mtime) +{ + bool exists = false; + if (!inspectRegular(path, exists) || !exists) + { + return false; + } + + std::error_code error; + const std::filesystem::path native_path = fsyspath(path); + const uintmax_t size = std::filesystem::file_size(native_path, error); + if (error || size > std::numeric_limits<U64>::max()) + { + return false; + } + + const std::filesystem::file_time_type write_time = + std::filesystem::last_write_time(native_path, error); + if (error) + { + return false; + } + + file_size = static_cast<U64>(size); + file_mtime = static_cast<S64>(write_time.time_since_epoch().count()); + return true; +} + +bool scanArchive(const std::string& path, const LLUUID& agent_id, + const LLUUID& resident_id, U64 deleted_before_ticks, + U32 display_cap, ArchiveScan& scan) +{ + scan = ArchiveScan(); + + // Only a regular canonical path may participate in archive reads. + bool exists = false; + if (!inspectRegular(path, exists)) + { + scan.state = ARCHIVE_FAILED; + return false; + } + if (!exists) + { + scan.state = ARCHIVE_ABSENT; + return true; + } + if (!archiveStamp(path, scan.file_size, scan.file_mtime)) + { + scan.state = ARCHIVE_FAILED; + return false; + } + llifstream input(path.c_str(), std::ios::binary); + if (!input.is_open()) + { + scan.state = ARCHIVE_FAILED; + return false; + } + + // The exact header is the first committed record and is never repairable data. + std::vector<std::string> fields; + bool torn = false; + bool present = false; + if (!readRecord(input, fields, torn, present) || !present || torn || !headerMatches(fields)) + { + scan.state = ARCHIVE_CORRUPT; + return false; + } + + scan.valid_prefix_bytes = input.tellg(); + TimeUuidKey previous; + bool have_previous = false; + std::deque<Row> newest; + + // Fold records in ascending TimeUUID order while retaining only the newest + // display_cap rows. Complete malformed records are corrupt; incomplete EOF is torn. + for (;;) + { + if (!readRecord(input, fields, torn, present)) + { + scan.state = ARCHIVE_CORRUPT; + return false; + } + if (!present) + { + scan.state = torn ? ARCHIVE_TORN : ARCHIVE_VALID; + break; + } + if (torn) + { + scan.state = ARCHIVE_TORN; + break; + } + + Row row; + if (!rowFromFields(fields, agent_id, resident_id, row) || + (have_previous && !(previous < row.key))) + { + scan.state = ARCHIVE_CORRUPT; + return false; + } + + have_previous = true; + previous = row.key; + const std::streamoff position = input.tellg(); + scan.valid_prefix_bytes = position >= 0 ? position : scan.file_size; + + // Deleted rows still participate in structural and ordering validation but + // do not enter the visible summary. + if (row.key.ticks <= deleted_before_ticks) + { + continue; + } + + if (!scan.has_oldest) + { + scan.has_oldest = true; + scan.oldest = row.key; + } + scan.newest = row.key; + ++scan.row_count; + + if (display_cap) + { + newest.push_back(row); + if (newest.size() > display_cap) + { + newest.pop_front(); + } + } + } + + scan.display_rows.assign(newest.begin(), newest.end()); + return scan.state == ARCHIVE_VALID; +} +} diff --git a/indra/newview/llchatservicehistorycore.h b/indra/newview/llchatservicehistorycore.h new file mode 100644 index 0000000000..60ecbf59a8 --- /dev/null +++ b/indra/newview/llchatservicehistorycore.h @@ -0,0 +1,171 @@ +/** + * @file llchatservicehistorycore.h + * @brief ChatService wire/storage primitives and direct history composition. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * $/LicenseInfo$ + */ +#ifndef LL_LLCHATSERVICEHISTORYCORE_H +#define LL_LLCHATSERVICEHISTORYCORE_H + +#include "llsd.h" +#include "lluuid.h" + +#include <array> +#include <deque> +#include <iosfwd> +#include <list> +#include <string> +#include <vector> + +namespace LLChatServiceHistoryCore +{ + extern const char* const CSV_HEADER; + extern const char* const INDEX_HEADER; + + // Cassandra compares UUIDv1 timestamps first and the final eight bytes as signed + // values when timestamps tie. + struct TimeUuidKey + { + U64 ticks = 0; + std::array<S8, 8> tail{}; + + bool operator==(const TimeUuidKey& rhs) const; + bool operator<(const TimeUuidKey& rhs) const; + bool operator<=(const TimeUuidKey& rhs) const + { + return !(rhs < *this); + } + }; + + // Row is the single validated representation shared by wire pages and CSV files. + struct Row + { + std::string conversation_id; + std::string msg_id; + LLUUID from_id; + std::string from_name; + std::string message; + S32 dialog = 0; + std::string created_at; + TimeUuidKey key; + }; + + // Direct entries are extracted only after the complete discovery list validates. + struct ListEntry + { + LLUUID resident_id; + std::string last_msg_id; + }; + + // Pages retain rows newer than the account deletion cutoff and expose the next + // validated cursor only while older paging remains necessary. + struct Page + { + std::vector<Row> rows; + std::string next_cursor; + bool terminal = false; + }; + + // Archive states distinguish repairable torn tails from complete corrupt records. + enum EArchiveState + { + ARCHIVE_ABSENT, + ARCHIVE_VALID, + ARCHIVE_TORN, + ARCHIVE_CORRUPT, + ARCHIVE_FAILED + }; + + // A scan folds the canonical file into a bounded display window and a compact + // summary without materializing the complete archive. + struct ArchiveScan + { + EArchiveState state = ARCHIVE_ABSENT; + U32 row_count = 0; + bool has_oldest = false; + TimeUuidKey oldest; + TimeUuidKey newest; + U64 file_size = 0; + S64 file_mtime = 0; + std::streamoff valid_prefix_bytes = 0; + std::vector<Row> display_rows; + }; + + std::string directConversationId(const LLUUID& agent_id, const LLUUID& resident_id); + bool parseCanonicalUuid(const std::string& text, LLUUID& id); + bool parseTimeUuid(const std::string& text, TimeUuidKey& key); + bool persistedDirectDialog(S32 dialog); + bool parseCreatedAt(const std::string& text, std::string& normalized); + bool sameDirectSenderName(const std::string& left, const std::string& right); + + // Compare service rows against original bodies and plaintext against logged bodies/times. + bool sameDirectHistoryOccurrence(const LLSD& history, const LLSD& timed); + + // Reserve known send times before receipt intervals and local-clock minute matches. + std::list<LLSD> filterDirectHistoryDuplicates(const std::list<LLSD>& history, + const std::list<LLSD>& live); + + using Messages = std::list<LLSD>; + + // Union historical seams by service ID and plaintext occurrence, consuming overlaps one-for-one. + Messages mergeDirectHistory(const Messages& loaded, const Messages& service); + + // Preview publications start from the loaded value; visible context is retained separately. + class History + { + public: + void setLoaded(const Messages& messages); + void clear(); + void clearService(); + Messages compose(const Messages& preview, const Messages& live, U32 limit, + bool retain_context = false); + + private: + Messages mLoaded; + Messages mVisible; + }; + + // Preserve live order and notification rows; replace history and reindex only on change. + bool replaceHistory(Messages& current, const Messages& history, bool direct); + + // Capture timing before translation and keep its provenance with the original wire body. + LLSD incomingContext(const LLSD& original_text, bool online); + LLSD captureLiveMessage(const std::string& text, U32& timestamp, const LLSD& context, U32 now); + void recordLiveMessage(LLSD& message, const LLSD& context, U32 logged_at); + + // Both inputs are newest-first; interleave timed history without reordering live rows. + std::list<LLSD> interleaveDirectHistory(const std::list<LLSD>& history, + const std::list<LLSD>& live); + + // Legacy SLT wall times carry no UTC offset; UTC-7 is their earliest possible + // interpretation at the durable service boundary. + bool legacyWallMayPrecedeService(F64 wall_epoch, F64 service_epoch); + + // Both wire validators are all-or-nothing and clear their output before parsing. + bool validateConversationList(const LLSD& value, const LLUUID& agent_id, + std::vector<ListEntry>& entries); + bool validateHistoryPage(const LLSD& value, const LLUUID& agent_id, + const LLUUID& resident_id, + const std::string& requested_cursor, + U64 deleted_before_ticks, Page& page); + + std::string quoteCsv(const std::string& value); + void writeCsvRow(std::ostream& output, const Row& row); + + // scanArchive never repairs storage; callers decide whether a classified archive + // may be displayed, repaired, or quarantined. + bool scanArchive(const std::string& path, const LLUUID& agent_id, + const LLUUID& resident_id, U64 deleted_before_ticks, + U32 display_cap, ArchiveScan& scan); + + // Capture the regular file's physical identity for guarded repair and publication. + bool archiveStamp(const std::string& path, U64& file_size, S64& file_mtime); + + // Owned artifacts may be absent or regular files, but never symlinks or directories. + bool inspectRegular(const std::string& path, bool& exists); +} + +#endif diff --git a/indra/newview/llconversationlog.cpp b/indra/newview/llconversationlog.cpp index bfcc2e4182..5a97e8b625 100644 --- a/indra/newview/llconversationlog.cpp +++ b/indra/newview/llconversationlog.cpp @@ -99,7 +99,12 @@ LLConversation::~LLConversation() void LLConversation::updateTimestamp() { - mTime = (U64Seconds)time_corrected(); + updateTimestamp((U64Seconds)time_corrected()); +} + +void LLConversation::updateTimestamp(const U64Seconds& utc_time) +{ + mTime = utc_time; mTimestamp = createTimestamp(mTime); } @@ -388,6 +393,53 @@ void LLConversationLog::sessionAdded(const LLUUID& session_id, const std::string logConversation(session_id, has_offline_msg); } +void LLConversationLog::addServiceConversation(const LLUUID& session_id, + const std::string& conversation_name, + const std::string& history_filename, + const LLUUID& participant_id, + const U64Seconds& archived_time) +{ + // Service publication follows the Conversation Log privacy setting. + if (!mLoggingEnabled) + { + return; + } + + // Durable service activity advances one matching row but never regresses its + // timestamp or replaces retained metadata. Notify and cache only when its + // visible timestamp changes. + for (LLConversation& conversation : mConversations) + { + if (conversation.getSessionID() != session_id) + { + continue; + } + + if (archived_time > conversation.getTime()) + { + conversation.updateTimestamp(archived_time); + notifyParticularConversationObservers( + session_id, LLConversationLogObserver::CHANGED_TIME); + cache(); + } + return; + } + + // A durable archive supplies the P2P metadata normally obtained from a live session. + ConversationParams params; + params.time(archived_time) + .conversation_type(LLIMModel::LLIMSession::P2P_SESSION) + .has_offline_ims(false) + .conversation_name(conversation_name) + .participant_id(participant_id) + .session_id(session_id) + .history_filename(history_filename); + + mConversations.emplace_back(params); + notifyObservers(); + cache(); +} + void LLConversationLog::cache() { if (gSavedPerAccountSettings.getS32("KeepConversationLogTranscripts") > 0) @@ -614,7 +666,9 @@ bool LLConversationLog::loadFromFile(const std::string& filename) // CHUI-325 // The conversation log should be capped to the last 30 days. Conversations with the last utterance // being over 30 days old should be purged from the conversation log text file on login. - if (conversation.isOlderThan(CONVERSATION_LIFETIME)) + // P2P conversations are retained so accumulated local/service history remains discoverable. + if (conversation.getConversationType() != LLIMModel::LLIMSession::P2P_SESSION && + conversation.isOlderThan(CONVERSATION_LIFETIME)) { purge_required = true; continue; diff --git a/indra/newview/llconversationlog.h b/indra/newview/llconversationlog.h index 957d90d55e..77a1562cbd 100644 --- a/indra/newview/llconversationlog.h +++ b/indra/newview/llconversationlog.h @@ -64,9 +64,10 @@ public: bool isOlderThan(U32Days days) const; /* - * updates last interaction time + * Updates last interaction time to now or an explicit UTC archive time. */ void updateTimestamp(); + void updateTimestamp(const U64Seconds& utc_time); void updateHistoryFileName(const std::string &new_name) { mHistoryFileName = new_name; } @@ -126,6 +127,14 @@ public: // LLIMSessionObserver triggers virtual void sessionAdded(const LLUUID& session_id, const std::string& name, const LLUUID& other_participant_id, bool has_offline_msg) override; + + // Publish or advance metadata for durable service history that has no live IM session. + void addServiceConversation(const LLUUID& session_id, + const std::string& conversation_name, + const std::string& history_filename, + const LLUUID& participant_id, + const U64Seconds& archived_time); + virtual void sessionActivated(const LLUUID& session_id, const std::string& name, const LLUUID& other_participant_id) override {}; // Stub virtual void sessionRemoved(const LLUUID& session_id) override{} // Stub virtual void sessionVoiceOrIMStarted(const LLUUID& session_id) override{}; // Stub @@ -199,6 +208,9 @@ private: boost::signals2::connection mAvatarNameCacheConnection; bool mLoggingEnabled; + + // Test-only peer: model enabled/empty startup without filesystem or observer setup. + friend struct LLConversationLogTestAccess; }; class LLConversationLogObserver diff --git a/indra/newview/llconversationloglist.cpp b/indra/newview/llconversationloglist.cpp index e9c6df3ed8..270caba0b2 100644 --- a/indra/newview/llconversationloglist.cpp +++ b/indra/newview/llconversationloglist.cpp @@ -26,6 +26,7 @@ #include "llviewerprecompiledheaders.h" #include "llavataractions.h" +#include "llchatservicehistory.h" #include "llagent.h" #include "llfloaterreg.h" #include "llfloaterconversationpreview.h" @@ -364,7 +365,10 @@ void LLConversationLogList::onCustomAction(const LLSD& userdata) } else if ("chat_history" == command_name) { - LLFloaterReg::showInstance("preview_conversation", selected_conversation_session_id, true); + if (!LLChatServiceHistory::historySuppressed()) + { + LLFloaterReg::showInstance("preview_conversation", selected_conversation_session_id, true); + } } else if ("offer_teleport" == command_name) { @@ -438,7 +442,7 @@ bool LLConversationLogList::isActionEnabled(const LLSD& userdata) } else if ("can_view_chat_history" == command_name) { - return true; + return !LLChatServiceHistory::historySuppressed(); } else if ("can_call" == command_name) { diff --git a/indra/newview/llfloaterconversationlog.cpp b/indra/newview/llfloaterconversationlog.cpp index 40e2d2bf1e..144741a768 100644 --- a/indra/newview/llfloaterconversationlog.cpp +++ b/indra/newview/llfloaterconversationlog.cpp @@ -26,6 +26,7 @@ #include "llviewerprecompiledheaders.h" #include "llconversationloglist.h" +#include "llchatservicehistory.h" #include "llfiltereditor.h" #include "llfloaterconversationlog.h" #include "llfloaterreg.h" @@ -106,12 +107,19 @@ void LLFloaterConversationLog::onCustomAction (const LLSD& userdata) } else if ("view_nearby_chat_history" == command_name) { - LLFloaterReg::showInstance("preview_conversation", LLSD(LLUUID::null), true); + if (!LLChatServiceHistory::historySuppressed()) + { + LLFloaterReg::showInstance("preview_conversation", LLSD(LLUUID::null), true); + } } } bool LLFloaterConversationLog::isActionEnabled(const LLSD& userdata) { + if (userdata.asString() == "view_nearby_chat_history") + { + return !LLChatServiceHistory::historySuppressed(); + } return true; } diff --git a/indra/newview/llfloaterconversationpreview.cpp b/indra/newview/llfloaterconversationpreview.cpp index 9e68507f83..1698d6e914 100644 --- a/indra/newview/llfloaterconversationpreview.cpp +++ b/indra/newview/llfloaterconversationpreview.cpp @@ -26,6 +26,7 @@ #include "llviewerprecompiledheaders.h" #include "llavatarnamecache.h" +#include "llchatservicehistory.h" #include "llconversationlog.h" #include "llfloaterconversationpreview.h" #include "llimview.h" @@ -34,30 +35,35 @@ #include "llspinctrl.h" #include "lltrans.h" #include "llnotificationsutil.h" +#include "workqueue.h" const std::string LL_FCP_COMPLETE_NAME("complete_name"); const std::string LL_FCP_ACCOUNT_NAME("user_name"); +const std::string LL_FCP_PARTICIPANT_ID("participant_id"); const S32 CONVERSATION_HISTORY_PAGE_SIZE = 100; LLFloaterConversationPreview::LLFloaterConversationPreview(const LLSD& session_id) : LLFloater(session_id), mChatHistory(NULL), mSessionID(session_id.asUUID()), + mParticipantID(session_id[LL_FCP_PARTICIPANT_ID].asUUID()), mCurrentPage(0), mPageSize(CONVERSATION_HISTORY_PAGE_SIZE), mAccountName(session_id[LL_FCP_ACCOUNT_NAME]), mCompleteName(session_id[LL_FCP_COMPLETE_NAME]), - mMutex(), mShowHistory(false), mMessages(NULL), mHistoryThreadsBusy(false), mIsGroup(false), + mIsP2P(false), + mServiceToken(0), mOpened(false) { } LLFloaterConversationPreview::~LLFloaterConversationPreview() { + delete mMessages; } bool LLFloaterConversationPreview::postBuild() @@ -68,7 +74,14 @@ bool LLFloaterConversationPreview::postBuild() std::string name; std::string file; - if (mAccountName != "") + if (mParticipantID.notNull()) + { + // A direct-conversation key stays direct while its shared name is unresolved. + mIsP2P = true; + name = mCompleteName; + file = mAccountName; + } + else if (mAccountName != "") { name = mCompleteName; file = mAccountName; @@ -78,21 +91,30 @@ bool LLFloaterConversationPreview::postBuild() name = conv->getConversationName(); file = conv->getHistoryFileName(); mIsGroup = (LLIMModel::LLIMSession::GROUP_SESSION == conv->getConversationType()); + mIsP2P = (LLIMModel::LLIMSession::P2P_SESSION == conv->getConversationType()); + if (mIsP2P) + { + mParticipantID = conv->getParticipantID(); + } } else { name = LLTrans::getString("NearbyChatTitle"); file = "chat"; } + mChatHistoryFileName = file; if (mIsGroup && !LLStringUtil::endsWith(mChatHistoryFileName, GROUP_CHAT_SUFFIX)) { mChatHistoryFileName += GROUP_CHAT_SUFFIX; } + LLStringUtil::format_map_t args; args["[NAME]"] = name; std::string title = getString("Title", args); setTitle(title); + getChild<LLTextBox>("chat_service_loading_text")->setValue( + LLTrans::getString("loading_chat_logs")); return LLFloater::postBuild(); } @@ -101,39 +123,48 @@ void LLFloaterConversationPreview::setPages(std::list<LLSD>* messages, const std { if(file_name == mChatHistoryFileName && messages) { - // additional protection to avoid changes of mMessages in setPages() - LLMutexLock lock(&mMutex); + // Preserve the reader's distance from the newest page while asynchronous + // reloads replace the backing list. + const S32 old_last_page = mMessages && !mMessages->empty() + ? (static_cast<S32>(mMessages->size()) - 1) / mPageSize : 0; + const S32 distance_from_newest = llmax(0, old_last_page - mCurrentPage); if (mMessages) { delete mMessages; // Clean up temporary message list with "Loading..." text } mMessages = messages; - mCurrentPage = (mMessages->size() ? (static_cast<int>(mMessages->size()) - 1) / mPageSize : 0); + const S32 last_page = mMessages->empty() + ? 0 : (static_cast<S32>(mMessages->size()) - 1) / mPageSize; + mCurrentPage = llmax(0, last_page - distance_from_newest); + // Publish the new page range together so draw() never renders stale bounds. mPageSpinner->setEnabled(true); - mPageSpinner->setMaxValue((F32)(mCurrentPage+1)); - // set() won't refresh the displayed text while the editor has focus, unlike forceSetValue() + mPageSpinner->setMaxValue((F32)(last_page+1)); + // Refresh the displayed page even while the editor has focus. mPageSpinner->forceSetValue((F32)(mCurrentPage+1)); mPageSpinner->resetDirty(); - std::string total_page_num = llformat("/ %d", mCurrentPage+1); + std::string total_page_num = llformat("/ %d", last_page+1); getChild<LLTextBox>("page_num_label")->setValue(total_page_num); mShowHistory = true; } - LLLoadHistoryThread* loadThread = LLLogChat::getInstance()->getLoadHistoryThread(mSessionID); - if (loadThread) - { - loadThread->removeLoadEndSignal(boost::bind(&LLFloaterConversationPreview::setPages, this, _1, _2)); - } } void LLFloaterConversationPreview::draw() { + // Local reads and account-scoped service work share one nonmodal status panel. + const bool loading = mIsP2P && + (mServiceHistory.isLoading() || + LLChatServiceHistory::getSnapshot(mParticipantID).service_work_active); + // The widget animates in draw(); hiding its panel suspends that work. + getChildView("chat_service_loading")->setVisible(loading); + if(mShowHistory) { showHistory(); mShowHistory = false; } + LLFloater::draw(); } @@ -143,7 +174,36 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) { return; } + mOpened = true; + ++mServiceToken; + mServiceHistory.clear(); + mPageSpinner = getChild<LLSpinCtrl>("history_page_spin"); + mPageSpinner->setCommitCallback( + boost::bind(&LLFloaterConversationPreview::onMoreHistoryBtnClick, this)); + mPageSpinner->setMinValue(1); + mPageSpinner->set(1); + mPageSpinner->setEnabled(false); + + if (mIsP2P) + { + // The history owner publishes on the main loop and stops with this Preview. + mServiceHistory.load(mParticipantID, mChatHistoryFileName, 10000, + [this](const std::list<LLSD>& history) + { + setPages(new std::list<LLSD>(history), mChatHistoryFileName); + }); + LLChatServiceHistory::prioritizeResident(mParticipantID); + + return; + } + + // Legacy Preview loads once per open, with completion fenced by its open token. + if (LLChatServiceHistory::historySuppressed()) + { + return; + } + if (!LLLogChat::getInstance()->historyThreadsFinished(mSessionID)) { LLNotificationsUtil::add("ChatHistoryIsBusyAlert"); @@ -151,6 +211,9 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) closeFloater(); return; } + + const U64 load_token = ++mServiceToken; + LLSD load_params; load_params["load_all_history"] = true; load_params["cut_off_todays_date"] = false; @@ -158,17 +221,13 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) // The temporary message list with "Loading..." text // Will be deleted upon loading completion in setPages() method + delete mMessages; mMessages = new std::list<LLSD>(); LLSD loading; loading[LL_IM_TEXT] = LLTrans::getString("loading_chat_logs"); mMessages->push_back(loading); - mPageSpinner = getChild<LLSpinCtrl>("history_page_spin"); - mPageSpinner->setCommitCallback(boost::bind(&LLFloaterConversationPreview::onMoreHistoryBtnClick, this)); - mPageSpinner->setMinValue(1); - mPageSpinner->set(1); - mPageSpinner->setEnabled(false); // The actual message list to load from file // Will be deleted in a separate thread LLDeleteHistoryThread not to freeze UI @@ -179,7 +238,30 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) log_chat_inst->cleanupHistoryThreads(); LLLoadHistoryThread* loadThread = new LLLoadHistoryThread(mChatHistoryFileName, messages, load_params); - loadThread->setLoadEndSignal(boost::bind(&LLFloaterConversationPreview::setPages, this, _1, _2)); + LLHandle<LLFloaterConversationPreview> handle = + getDerivedHandle<LLFloaterConversationPreview>(); + LL::WorkQueue::ptr_t main = LL::WorkQueue::getInstance("mainloop"); + loadThread->setLoadEndSignal( + [handle, load_token, main](std::list<LLSD>* loaded, const std::string& file_name) + { + // Copy before the delete thread reclaims loader-owned data, then fence + // the main-thread apply with the current open token and privacy gate. + std::shared_ptr<std::list<LLSD>> copy = + std::make_shared<std::list<LLSD>>(*loaded); + if (main) + { + main->post([handle, load_token, copy, file_name]() + { + LLFloaterConversationPreview* floater = handle.get(); + if (floater && floater->mOpened && + load_token == floater->mServiceToken && + !LLChatServiceHistory::historySuppressed()) + { + floater->setPages(new std::list<LLSD>(*copy), file_name); + } + }); + } + }); loadThread->start(); log_chat_inst->addLoadHistoryThread(mSessionID, loadThread); @@ -191,7 +273,15 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) void LLFloaterConversationPreview::onClose(bool app_quitting) { + // Close is a lifecycle fence for callbacks, not a request to cancel shared service work. mOpened = false; + ++mServiceToken; + mServiceHistory.stop(); + + if (mIsP2P) + { + return; + } if (!mHistoryThreadsBusy) { LLDeleteHistoryThread* deleteThread = LLLogChat::getInstance()->getDeleteHistoryThread(mSessionID); @@ -202,16 +292,32 @@ void LLFloaterConversationPreview::onClose(bool app_quitting) } } +void LLFloaterConversationPreview::invalidateHistory() +{ + // Advancing the token invalidates every outstanding legacy or stitched completion + // before deletion clears the visible backing lists. + ++mServiceToken; + mServiceHistory.clear(); + + if (mMessages) + { + mMessages->clear(); + } + + if (mChatHistory) + { + mChatHistory->clear(); + } +} + void LLFloaterConversationPreview::showHistory() { - // additional protection to avoid changes of mMessages in setPages - LLMutexLock lock(&mMutex); + // Both loaders publish on the main loop, so pages stay stable during rendering. + mChatHistory->clear(); if(mMessages == NULL || !mMessages->size() || mCurrentPage * mPageSize >= mMessages->size()) { return; } - - mChatHistory->clear(); std::ostringstream message; std::list<LLSD>::const_iterator iter = mMessages->begin(); std::advance(iter, mCurrentPage * mPageSize); diff --git a/indra/newview/llfloaterconversationpreview.h b/indra/newview/llfloaterconversationpreview.h index 563592fb20..f55487747f 100644 --- a/indra/newview/llfloaterconversationpreview.h +++ b/indra/newview/llfloaterconversationpreview.h @@ -27,10 +27,12 @@ #define LLFLOATERCONVERSATIONPREVIEW_H_ #include "llchathistory.h" +#include "llchatservicehistory.h" #include "llfloater.h" extern const std::string LL_FCP_COMPLETE_NAME; //"complete_name" extern const std::string LL_FCP_ACCOUNT_NAME; //"user_name" +extern const std::string LL_FCP_PARTICIPANT_ID; //"participant_id" class LLSpinCtrl; @@ -48,14 +50,19 @@ public: void onOpen(const LLSD& key) override; void onClose(bool app_quitting) override; + // Fence outstanding loads and clear the visible history after account deletion. + void invalidateHistory(); + private: + // Non-P2P previews retain the legacy loader, while P2P previews use the shared + // stitched loader and resident snapshot stream. void onMoreHistoryBtnClick(); void showHistory(); - LLMutex mMutex; LLSpinCtrl* mPageSpinner; LLChatHistory* mChatHistory; LLUUID mSessionID; + LLUUID mParticipantID; int mCurrentPage; int mPageSize; @@ -67,6 +74,11 @@ private: bool mHistoryThreadsBusy; bool mOpened; bool mIsGroup; + bool mIsP2P; + + // Fence legacy completions across close, reopen, and deletion. + U64 mServiceToken; + LLChatServiceHistory::History mServiceHistory; }; #endif /* LLFLOATERCONVERSATIONPREVIEW_H_ */ diff --git a/indra/newview/llfloaterimcontainer.cpp b/indra/newview/llfloaterimcontainer.cpp index e466090624..65d77d5455 100644 --- a/indra/newview/llfloaterimcontainer.cpp +++ b/indra/newview/llfloaterimcontainer.cpp @@ -39,6 +39,7 @@ #include "llavatariconctrl.h" #include "llavatarnamecache.h" #include "llcallbacklist.h" +#include "llchatservicehistory.h" #include "lldonotdisturbnotificationstorage.h" #include "llgroupactions.h" #include "llgroupiconctrl.h" @@ -1421,6 +1422,13 @@ void LLFloaterIMContainer::doToSelectedConversation(const std::string& command, } else if("chat_history" == command) { + // The deletion latch hides every local history source until its durable + // sweep completes. + if (LLChatServiceHistory::historySuppressed()) + { + return; + } + if (selectedIDS.size() > 0) { if(conversationItem->getType() == LLConversationItem::CONV_SESSION_GROUP) @@ -1456,7 +1464,10 @@ void LLFloaterIMContainer::doToSelectedConversation(const std::string& command, { if("chat_history" == command) { - LLFloaterReg::showInstance("preview_conversation", LLSD(LLUUID::null), true); + if (!LLChatServiceHistory::historySuppressed()) + { + LLFloaterReg::showInstance("preview_conversation", LLSD(LLUUID::null), true); + } } } } @@ -1531,6 +1542,11 @@ bool LLFloaterIMContainer::enableContextMenuItem(const LLSD& userdata) //Enable Chat history item for ad-hoc and group conversations if ("can_chat_history" == item && uuids.size() > 0) { + // History is unavailable while an account-wide deletion is pending. + if (LLChatServiceHistory::historySuppressed()) + { + return false; + } //Disable menu item if selected participant is user agent if(uuids.front() != gAgentID) { @@ -1550,7 +1566,7 @@ bool LLFloaterIMContainer::enableContextMenuItem(const LLSD& userdata) else { bool is_group = (getCurSelectedViewModelItem()->getType() == LLConversationItem::CONV_SESSION_GROUP); - return LLLogChat::isTranscriptExist(uuids.front(),is_group); + return LLLogChat::isTranscriptExist(uuids.front(), is_group); } } } @@ -1582,7 +1598,7 @@ bool LLFloaterIMContainer::enableContextMenuItem(const std::string& item, uuid_v if ("can_chat_history" == item && is_single_select) { - return LLLogChat::isTranscriptExist(uuids.front(),false); + return LLLogChat::isTranscriptExist(uuids.front(), false); } // Handle options that are applicable to all including the user agent diff --git a/indra/newview/llfloaterimsession.cpp b/indra/newview/llfloaterimsession.cpp index 73bce6f2fd..6fd6dbee9d 100644 --- a/indra/newview/llfloaterimsession.cpp +++ b/indra/newview/llfloaterimsession.cpp @@ -51,6 +51,7 @@ #include "llsyswellwindow.h" #include "lltrans.h" #include "llchathistory.h" +#include "llchatservicehistory.h" #include "llnotifications.h" #include "llviewerregion.h" #include "llviewerwindow.h" @@ -374,6 +375,8 @@ bool LLFloaterIMSession::postBuild() //see LLFloaterIMPanel for how it is done (IB) initIMFloater(); + getChild<LLTextBox>("chat_service_loading_text")->setValue( + LLTrans::getString("loading_chat_logs")); return result; } @@ -713,6 +716,8 @@ void LLFloaterIMSession::setVisible(bool visible) if (visible && isInVisibleChain()) { + // Visibility updates presentation state; session creation and live delivery + // own service refresh priority. sIMFloaterShowedSignal(mSessionID); updateMessages(); } @@ -836,6 +841,16 @@ void LLFloaterIMSession::updateMessages() for (; iter != iter_end; ++iter) { LLSD msg = *iter; + // Consume hidden fallbacks and expired offer markers as well as visible rows. + mLastMessageIndex = msg["index"].asInteger(); + + // An active inline offer replaces only its explicitly linked text log. + // The fallback can be absent or arrive after unrelated chat messages. + const LLUUID notification_log_id = msg["notification_log_id"].asUUID(); + if (notification_log_id.notNull() && LLNotificationsUtil::find(notification_log_id) != NULL) + { + continue; + } std::string time = msg["time"].asString(); LLUUID from_id = msg["from_id"].asUUID(); @@ -872,7 +887,7 @@ void LLFloaterIMSession::updateMessages() channel->hideToast(chat.mNotifId); } } - // if notification doesn't exist - try to use next message which should be log entry + // Expired offers leave their text fallback to render independently. else { continue; @@ -886,20 +901,6 @@ void LLFloaterIMSession::updateMessages() // Add the message to the chat log appendMessage(chat); - mLastMessageIndex = msg["index"].asInteger(); - - // if it is a notification - next message is a notification history log, so skip it - if (chat.mNotifId.notNull() && LLNotificationsUtil::find(chat.mNotifId) != NULL) - { - if (++iter == iter_end) - { - break; - } - else - { - mLastMessageIndex++; - } - } } } } @@ -916,6 +917,8 @@ void LLFloaterIMSession::reloadMessages(bool clean_messages/* = false*/) } } + // Clear and replay in one turn so the transcript never presents an empty frame. + // LLChatHistory detaches old inline widgets before replacements are registered. mChatHistory->clear(); mLastMessageIndex = -1; updateMessages(); @@ -1117,6 +1120,18 @@ void LLFloaterIMSession::processSessionUpdate(const LLSD& session_update) // virtual void LLFloaterIMSession::draw() { + // The floater presents both model-owned local reads and account-scoped service + // work without scheduling either source from draw(). + const bool loading = + mSession && + mIsP2PChat && + gSavedPerAccountSettings.getBOOL("LogShowHistory") && + (mSession->isChatHistoryLoading() || + LLChatServiceHistory::getSnapshot(mSession->mOtherParticipantID).service_work_active); + + // The widget animates in draw(); hiding its panel suspends that work. + getChildView("chat_service_loading")->setVisible(loading); + // add people who were added via dropPerson() if (!mPendingParticipants.empty()) { diff --git a/indra/newview/llfloaterimsession.h b/indra/newview/llfloaterimsession.h index aa03fc4e62..a35fc27791 100644 --- a/indra/newview/llfloaterimsession.h +++ b/indra/newview/llfloaterimsession.h @@ -189,6 +189,7 @@ private: LLFrameTimer mOtherTypingTimer; bool mSessionInitialized; + LLSD mQueuedMsgsForInit; uuid_vec_t mInvitedParticipants; diff --git a/indra/newview/llfloaterimsessiontab.cpp b/indra/newview/llfloaterimsessiontab.cpp index 3445b0a7cf..bbe2bc1f36 100644 --- a/indra/newview/llfloaterimsessiontab.cpp +++ b/indra/newview/llfloaterimsessiontab.cpp @@ -1054,6 +1054,14 @@ void LLFloaterIMSessionTab::reshapeChatLayoutPanel() // static void LLFloaterIMSessionTab::processChatHistoryStyleUpdate(bool clean_messages/* = false*/) { + if (clean_messages) + { + // Model-owned direct history is reloaded once, including sessions without a floater. + LLIMModel::instance().reloadDirectHistories(); + } + + // P2P floaters render the model reload above; other floaters retain their + // existing clean-and-reload behavior. LLFloaterReg::const_instance_list_t& inst_list = LLFloaterReg::getFloaterList("impanel"); for (LLFloaterReg::const_instance_list_t::const_iterator iter = inst_list.begin(); iter != inst_list.end(); ++iter) @@ -1061,7 +1069,11 @@ void LLFloaterIMSessionTab::processChatHistoryStyleUpdate(bool clean_messages/* LLFloaterIMSession* floater = dynamic_cast<LLFloaterIMSession*>(*iter); if (floater) { - floater->reloadMessages(clean_messages); + LLIMModel::LLIMSession* session = + LLIMModel::instance().findIMSession(floater->mSessionID); + const bool reload_model = clean_messages && + (!session || !session->isP2P()); + floater->reloadMessages(reload_model); } } diff --git a/indra/newview/llfloaterpreference.cpp b/indra/newview/llfloaterpreference.cpp index 6d86809a1e..54789278f1 100644 --- a/indra/newview/llfloaterpreference.cpp +++ b/indra/newview/llfloaterpreference.cpp @@ -33,6 +33,7 @@ #include "llviewerprecompiledheaders.h" #include "llfloaterpreference.h" +#include "llchatservicehistory.h" #include "message.h" #include "llfloaterautoreplacesettings.h" @@ -542,7 +543,9 @@ bool LLFloaterPreference::postBuild() void LLFloaterPreference::updateDeleteTranscriptsButton() { - mDeleteTranscriptsBtn->setEnabled(LLLogChat::transcriptFilesExist()); + const bool history_exists = + LLLogChat::transcriptFilesExist() || LLChatServiceHistory::localHistoryExists(); + mDeleteTranscriptsBtn->setEnabled(!mDeleteTranscriptsPending && history_exists); } void LLFloaterPreference::onDoNotDisturbResponseChanged() @@ -1918,17 +1921,31 @@ void LLFloaterPreference::onDeleteTranscriptsResponse(const LLSD& notification, { if (0 == LLNotificationsUtil::getSelectedOption(notification, response)) { - LLLogChat::deleteTranscripts(); + // Disable repeated requests immediately. The weak handle keeps the + // main-queue completion safe if Preferences closes during the durable sweep. + LLHandle<LLFloaterPreference> handle = getDerivedHandle<LLFloaterPreference>(); + mDeleteTranscriptsPending = true; updateDeleteTranscriptsButton(); + + if (!LLChatServiceHistory::deleteTranscriptsAsync( + [handle](bool) + { + if (LLFloaterPreference* floater = handle.get()) + { + floater->mDeleteTranscriptsPending = false; + floater->updateDeleteTranscriptsButton(); + } + })) + { + mDeleteTranscriptsPending = false; + updateDeleteTranscriptsButton(); + } } } void LLFloaterPreference::onLogChatHistorySaved() { - if (!mDeleteTranscriptsBtn->getEnabled()) - { - mDeleteTranscriptsBtn->setEnabled(true); - } + updateDeleteTranscriptsButton(); } void LLFloaterPreference::updateClickActionControls() diff --git a/indra/newview/llfloaterpreference.h b/indra/newview/llfloaterpreference.h index ce55fd4e77..07ed39d46c 100644 --- a/indra/newview/llfloaterpreference.h +++ b/indra/newview/llfloaterpreference.h @@ -235,6 +235,9 @@ private: LLScrollListCtrl* mEnabledPopups = nullptr; LLScrollListCtrl* mDisabledPopups = nullptr; LLButton* mDeleteTranscriptsBtn = nullptr; + + // Prevent duplicate account-wide deletion requests while the durable sweep runs. + bool mDeleteTranscriptsPending = false; LLButton* mEnablePopupBtn = nullptr; LLButton* mDisablePopupBtn = nullptr; LLComboBox* mTimeFormatCombobox = nullptr; diff --git a/indra/newview/llimprocessing.cpp b/indra/newview/llimprocessing.cpp index 9fafb2685a..52334a5212 100644 --- a/indra/newview/llimprocessing.cpp +++ b/indra/newview/llimprocessing.cpp @@ -519,6 +519,10 @@ void LLIMProcessing::processNewMessage(LLUUID from_id, break; case IM_NOTHING_SPECIAL: // p2p IM + // Record delivery metadata so live transport can be distinguished from history retrieval. + LL_INFOS("ChatServiceHistory") << "Direct IM received: sender_id=" << from_id + << ", offline=" << (offline == IM_OFFLINE) + << ", timestamp=" << timestamp << ", bytes=" << message.size() << LL_ENDL; // Don't show dialog, just do IM if (!gAgent.isGodlike() && gAgent.inPrelude() @@ -622,6 +626,7 @@ void LLIMProcessing::processNewMessage(LLUUID from_id, real_name = SYSTEM_FROM; } + // Keep the undecorated body for matching against service history. gIMMgr->addMessage(session_id, from_id, name, @@ -635,7 +640,8 @@ void LLIMProcessing::processNewMessage(LLUUID from_id, region_message, timestamp, LLUUID::null, - real_name); + real_name, + notice_name.empty() ? LLSD(message) : LLSD()); } else { @@ -1454,7 +1460,13 @@ void LLIMProcessing::processNewMessage(LLUUID from_id, for (auto& panel : LLToastNotifyPanel::instance_snapshot()) { const std::string& notification_name = panel.getNotificationName(); - if (notification_name == "OfferFriendship" && panel.isControlPanelEnabled()) + LLNotificationPtr panel_notification = LLNotificationsUtil::find(panel.getID()); + // Only a live actionable offer from this sender makes the packet a duplicate. + if (!panel.isDead() + && notification_name == "OfferFriendship" + && panel_notification + && panel_notification->getPayload()["from_id"].asUUID() == from_id + && panel.isControlPanelEnabled()) { add_notification = false; break; diff --git a/indra/newview/llimview.cpp b/indra/newview/llimview.cpp index 9aba011a5c..8b76ea9db0 100644 --- a/indra/newview/llimview.cpp +++ b/indra/newview/llimview.cpp @@ -31,6 +31,7 @@ #include "llimview.h" #include "llavatarnamecache.h" // IDEVO +#include "llchatservicehistory.h" #include "llavataractions.h" #include "llfloaterconversationlog.h" #include "llfloaterreg.h" @@ -39,6 +40,7 @@ #include "llrect.h" #include "llerror.h" #include "llbutton.h" +#include "llsdutil.h" #include "llsdutil_math.h" #include "llstring.h" #include "lltextutil.h" @@ -598,39 +600,37 @@ void chatterBoxInvitationCoro(std::string url, LLUUID sessionId, LLIMMgr::EInvit } void translateSuccess(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, const std::string& utf8_text, - U64 time_n_flags, std::string originalMsg, std::string expectLang, std::string translation, const std::string detected_language) + U64 time_n_flags, LLSD history_context, std::string expectLang, std::string translation, const std::string detected_language) { std::string message_txt(utf8_text); // filter out non-interesting responses if (!translation.empty() && ((detected_language.empty()) || (expectLang != detected_language)) - && (LLStringUtil::compareInsensitive(translation, originalMsg) != 0)) + && (LLStringUtil::compareInsensitive(translation, utf8_text) != 0)) { // Note - if this format changes, also fix code in addMessagesFromServerHistory() message_txt += XL8_START_TAG + LLTranslate::removeNoTranslateTags(translation) + XL8_END_TAG; } - // Extract info packed in time_n_flags bool log2file = (bool)(time_n_flags & (1LL << 32)); bool is_region_msg = (bool)(time_n_flags & (1LL << 33)); U32 time_stamp = (U32)(time_n_flags & 0x00000000ffffffff); - LLIMModel::getInstance()->processAddingMessage(session_id, from, from_id, message_txt, log2file, is_region_msg, time_stamp); + LLIMModel::getInstance()->processAddingMessage(session_id, from, from_id, message_txt, log2file, is_region_msg, time_stamp, history_context); } void translateFailure(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, const std::string& utf8_text, - U64 time_n_flags, int status, const std::string err_msg) + U64 time_n_flags, LLSD history_context, int status, const std::string err_msg) { std::string message_txt(utf8_text); std::string msg = LLTrans::getString("TranslationFailed", LLSD().with("[REASON]", err_msg)); LLStringUtil::replaceString(msg, "\n", " "); // we want one-line error messages message_txt += XL8_START_TAG + msg + XL8_END_TAG; - // Extract info packed in time_n_flags bool log2file = (bool)(time_n_flags & (1LL << 32)); bool is_region_msg = (bool)(time_n_flags & (1LL << 33)); U32 time_stamp = (U32)(time_n_flags & 0x00000000ffffffff); - LLIMModel::getInstance()->processAddingMessage(session_id, from, from_id, message_txt, log2file, is_region_msg, time_stamp); + LLIMModel::getInstance()->processAddingMessage(session_id, from, from_id, message_txt, log2file, is_region_msg, time_stamp, history_context); } void chatterBoxHistoryCoro(std::string url, LLUUID sessionId, std::string from, std::string message, U32 timestamp) @@ -795,7 +795,6 @@ LLIMModel::LLIMSession::LLIMSession(const LLUUID& session_id, } buildHistoryFileName(); - loadHistory(); // Localizing name of ad-hoc session. STORM-153 // Changing name should happen here- after the history file was created, so that @@ -996,6 +995,7 @@ LLIMModel::LLIMSession::~LLIMSession() mSpeakers = NULL; mVoiceChannelStateChangeConnection.disconnect(); + mChatServiceHistory.stop(); // HAVE to do this here -- if it happens in the LLVoiceChannel destructor it will call the wrong version (since the object's partially deconstructed at that point). mVoiceChannel->deactivate(); @@ -1357,16 +1357,65 @@ void LLIMModel::LLIMSession::chatFromLogFile(LLLogChat::ELogLineType type, const void LLIMModel::LLIMSession::loadHistory() { - mMsgs.clear(); mLastHistoryCacheMsgs.clear(); mLastHistoryCacheDateTime.clear(); - if ( gSavedPerAccountSettings.getBOOL("LogShowHistory") ) + if (!gSavedPerAccountSettings.getBOOL("LogShowHistory")) + { + mChatServiceHistory.stop(); + replaceHistoricalMessages({}); + return; + } + + // Direct sessions subscribe to stitched history; the owner fences reloads and + // lifetime changes before replacing this overlay. Live rows stay in the model. + if (isP2P()) + { + mChatServiceHistory.load( + mOtherParticipantID, mHistoryFileName, 200, + [this](const chat_message_list_t& history) + { + // The settings callback may still be queued when a read completes. + if (gSavedPerAccountSettings.getBOOL("LogShowHistory")) + { + replaceHistoricalMessages(history); + } + }, + mSessionID); + return; + } + + // Group and ad-hoc history retain the synchronous legacy loader. + chat_message_list_t history; + LLLogChat::loadChatHistory(mHistoryFileName, history, LLSD(), isGroupChat()); + replaceHistoricalMessages(history); +} + +void LLIMModel::LLIMSession::replaceHistoricalMessages(const chat_message_list_t& history) +{ + if (LLChatServiceHistory::replaceHistory(mMsgs, history, isP2P())) + { + if (LLFloaterIMSession* floater = LLFloaterIMSession::findInstance(mSessionID)) + { + floater->reloadMessages(false); + } + } +} + +void LLIMModel::LLIMSession::clearForHistoryDeletion() +{ + // Delete is the only operation that removes live and historical model rows together. + mChatServiceHistory.clear(); + mLastHistoryCacheDateTime.clear(); + mLastHistoryCacheMsgs.clear(); + mMsgs.clear(); + mNumUnread = 0; + mParticipantUnreadMessageCount = 0; + mHasOfflineMessage = false; + + if (LLFloaterIMSession* floater = LLFloaterIMSession::findInstance(mSessionID)) { - // read and parse chat history from local file - chat_message_list_t chat_history; - LLLogChat::loadChatHistory(mHistoryFileName, chat_history, LLSD(), isGroupChat()); - addMessagesFromHistoryCache(chat_history); + floater->reloadMessages(false); } } @@ -1375,6 +1424,18 @@ LLIMModel::LLIMSession* LLIMModel::findIMSession(const LLUUID& session_id) const return get_if_there(mId2SessionMap, session_id, (LLIMModel::LLIMSession*) NULL); } +void LLIMModel::reloadDirectHistories() +{ + // Model-owned P2P sessions reload even when no floater currently exists. + for (auto& pair : mId2SessionMap) + { + if (pair.second->isP2P()) + { + pair.second->loadHistory(); + } + } +} + //*TODO consider switching to using std::set instead of std::list for holding LLUUIDs across the whole code LLIMModel::LLIMSession* LLIMModel::findAdHocIMSession(const uuid_vec_t& ids) { @@ -1589,6 +1650,14 @@ bool LLIMModel::newSession(const LLUUID& session_id, const std::string& name, co LLIMSession *session = new LLIMSession(session_id, name, type, other_participant_id, voiceChannelInfo, ids, has_offline_msg); mId2SessionMap[session_id] = session; + // Insert first so snapshot callbacks and asynchronous completions can resolve the + // newly-created session through the model. + session->loadHistory(); + if (session->isP2P()) + { + LLChatServiceHistory::prioritizeResident(other_participant_id); + } + // When notifying observer, name of session is used instead of "name", because they may not be the // same if it is an adhoc session (in this case name is localized in LLIMSession constructor). std::string session_name = LLIMModel::getInstance()->getName(session_id); @@ -1679,8 +1748,18 @@ bool LLIMModel::addToHistory(const LLUUID& session_id, return false; } - // This is where a normal arriving message is added to the session. Note that the time string created here is without the full date - session->addMessage(from, from_id, utf8_text, LLLogChat::timestamp2LogString(timestamp, false), false, is_region_msg, timestamp); + // Fill missing direct timestamps for display and plaintext reconciliation. + U32 model_timestamp = timestamp; + if (!model_timestamp && session->isP2PSessionType()) + { + model_timestamp = static_cast<U32>(time_corrected()); + } + + // This is where a normal arriving message is added to the session. The time + // string created here omits the full date. + session->addMessage(from, from_id, utf8_text, + LLLogChat::timestamp2LogString(model_timestamp, false), + false, is_region_msg, model_timestamp); return true; } @@ -1720,27 +1799,34 @@ void LLIMModel::proccessOnlineOfflineNotification( } void LLIMModel::addMessage(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, - const std::string& utf8_text, bool log2file /* = true */, bool is_region_msg, /* = false */ U32 time_stamp /* = 0 */) + const std::string& utf8_text, bool log2file /* = true */, bool is_region_msg, /* = false */ U32 time_stamp /* = 0 */, + LLSD history_context) { + // Capture direct-message timing before an asynchronous translation can delay it. + LLIMSession* session = findIMSession(session_id); + if (session && session->isP2P()) + { + history_context = LLChatServiceHistory::prepareLiveMessage(utf8_text, time_stamp, history_context); + } if (gSavedSettings.getBOOL("TranslateChat") && (from != SYSTEM_FROM)) { const std::string from_lang = ""; // leave empty to trigger autodetect const std::string to_lang = LLTranslate::getTranslateLanguage(); U64 time_n_flags = ((U64) time_stamp) | (log2file ? (1LL << 32) : 0) | (is_region_msg ? (1LL << 33) : 0); // boost::bind has limited parameters LLTranslate::translateMessage(from_lang, to_lang, utf8_text, - boost::bind(&translateSuccess, session_id, from, from_id, utf8_text, time_n_flags, utf8_text, from_lang, _1, _2), - boost::bind(&translateFailure, session_id, from, from_id, utf8_text, time_n_flags, _1, _2)); + boost::bind(&translateSuccess, session_id, from, from_id, utf8_text, time_n_flags, history_context, from_lang, _1, _2), + boost::bind(&translateFailure, session_id, from, from_id, utf8_text, time_n_flags, history_context, _1, _2)); } else { - processAddingMessage(session_id, from, from_id, utf8_text, log2file, is_region_msg, time_stamp); + processAddingMessage(session_id, from, from_id, utf8_text, log2file, is_region_msg, time_stamp, history_context); } } void LLIMModel::processAddingMessage(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, - const std::string& utf8_text, bool log2file, bool is_region_msg, U32 time_stamp) + const std::string& utf8_text, bool log2file, bool is_region_msg, U32 time_stamp, LLSD history_context) { - LLIMSession* session = addMessageSilently(session_id, from, from_id, utf8_text, log2file, is_region_msg, time_stamp); + LLIMSession* session = addMessageSilently(session_id, from, from_id, utf8_text, log2file, is_region_msg, time_stamp, history_context); if (!session) return; @@ -1767,7 +1853,7 @@ void LLIMModel::processAddingMessage(const LLUUID& session_id, const std::string LLIMModel::LLIMSession* LLIMModel::addMessageSilently(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, const std::string& utf8_text, bool log2file /* = true */, bool is_region_msg, /* false */ - U32 timestamp /* = 0 */) + U32 timestamp /* = 0 */, LLSD history_context) { LLIMSession* session = findIMSession(session_id); @@ -1783,7 +1869,16 @@ LLIMModel::LLIMSession* LLIMModel::addMessageSilently(const LLUUID& session_id, from_name = SYSTEM_FROM; } + // Direct silent callers also capture provenance; live delivery always appends. + if (session->isP2P()) + { + history_context = LLChatServiceHistory::prepareLiveMessage(utf8_text, timestamp, history_context); + } addToHistory(session_id, from_name, from_id, utf8_text, is_region_msg, timestamp); + if (session->isP2P()) + { + LLChatServiceHistory::recordLiveMessage(session->mMsgs.front(), history_context); + } if (log2file) { logToFile(getHistoryFileName(session_id), from_name, from_id, utf8_text); @@ -2004,6 +2099,13 @@ void LLIMModel::sendMessage(const std::string& utf8_text, if((dialog == IM_NOTHING_SPECIAL) && (other_participant_id.notNull())) { + // The packet has been sent; incoming and outgoing IMs share one quiet + // service refresh while first contact starts bounded discovery immediately. + if (session && session->isP2PSessionType()) + { + LLChatServiceHistory::noteDirectMessageActivity(other_participant_id); + } + // Do we have to replace the /me's here? std::string from; LLAgentUI::buildFullname(from); @@ -3155,11 +3257,19 @@ void LLIMMgr::addMessage( bool is_region_msg, U32 timestamp, // May be zero LLUUID display_id, - std::string_view display_name) + std::string_view display_name, + const LLSD& original_text) { LLUUID other_participant_id = target_id; std::string message_display_name = (display_name.empty()) ? from : std::string(display_name); - if (display_id.isNull() && (display_name.empty())) + + // System notices use the participant UUID for session routing only; their model + // identity stays null so they render and reconcile as system rows. + if (message_display_name == SYSTEM_FROM) + { + display_id = LLUUID::null; + } + else if (display_id.isNull() && display_name.empty()) { display_id = other_participant_id; } @@ -3278,7 +3388,20 @@ void LLIMMgr::addMessage( if (!LLMuteList::getInstance()->isMuted(other_participant_id, LLMute::flagTextChat) && !skip_message) { - LLIMModel::instance().addMessage(new_session_id, message_display_name, display_id, msg, true, is_region_msg, timestamp); + // Online direct timestamps describe receipt; offline timestamps describe + // the saved send. Preserve that distinction through translation. + const bool incoming_direct = LLChatServiceHistory::isPersistedDirectDialog(dialog) && + !is_region_msg && message_display_name != SYSTEM_FROM && + other_participant_id.notNull() && other_participant_id != gAgentID; + if (incoming_direct) + { + // Incoming activity shares the outgoing quiet deadline before reconciliation. + LLChatServiceHistory::noteDirectMessageActivity(other_participant_id); + } + LLIMModel::instance().addMessage(new_session_id, message_display_name, display_id, msg, true, is_region_msg, timestamp, + incoming_direct + ? LLChatServiceHistoryCore::incomingContext(original_text, !is_offline_msg) + : LLSD()); } // Open conversation floater if offline messages are present @@ -4358,4 +4481,3 @@ LLHTTPRegistration<LLViewerChatterBoxSessionUpdate> LLHTTPRegistration<LLViewerChatterBoxInvitation> gHTTPRegistrationMessageChatterBoxInvitation( "/message/ChatterBoxInvitation"); - diff --git a/indra/newview/llimview.h b/indra/newview/llimview.h index 23f90ca795..bcb18e9ba8 100644 --- a/indra/newview/llimview.h +++ b/indra/newview/llimview.h @@ -28,6 +28,7 @@ #define LL_LLIMVIEW_H #include "../llui/lldockablefloater.h" +#include "llchatservicehistory.h" #include "lleventtimer.h" #include "llinstantmessage.h" @@ -116,7 +117,16 @@ public: /** ad-hoc sessions involve sophisticated chat history file naming schemes */ void buildHistoryFileName(); + // Direct history is an asynchronous overlay; these operations never discard live rows. void loadHistory(); + void replaceHistoricalMessages(const chat_message_list_t& history); + + // Account deletion is the explicit exception that clears both live and historical rows. + void clearForHistoryDeletion(); + bool isChatHistoryLoading() const + { + return mChatServiceHistory.isLoading(); + } LLUUID mSessionID; std::string mName; @@ -141,6 +151,9 @@ public: chat_message_list_t mMsgs; + // Historical rows are replaced as one overlay so live rows remain untouched. + LLChatServiceHistory::History mChatServiceHistory; + LLVoiceChannel* mVoiceChannel; LLIMSpeakerMgr* mSpeakers; bool mP2PAsAdhocCall; @@ -183,6 +196,9 @@ public: */ LLIMSession* findIMSession(const LLUUID& session_id) const; + // Reload every model-owned direct session, including sessions without an open floater. + void reloadDirectHistories(); + /** * Find an Ad-Hoc IM Session with specified participants * @return first found Ad-Hoc session or NULL if the session does not exist @@ -226,6 +242,7 @@ public: * Add a message to an IM Model - the message is saved in a message store associated with a session specified by session_id * and also saved into a file if log2file is specified. * It sends new message signal for each added message. + * history_context carries ChatService metadata through delivery and translation. */ void addMessage(const LLUUID& session_id, const std::string& from, @@ -233,7 +250,8 @@ public: const std::string& utf8_text, bool log2file = true, bool is_region_msg = false, - U32 time_stamp = 0); + U32 time_stamp = 0, + LLSD history_context = LLSD()); void processAddingMessage(const LLUUID& session_id, const std::string& from, @@ -241,13 +259,15 @@ public: const std::string& utf8_text, bool log2file, bool is_region_msg, - U32 time_stamp); + U32 time_stamp, + LLSD history_context = LLSD()); /** * Similar to addMessage(...) above but won't send a signal about a new message added */ LLIMModel::LLIMSession* addMessageSilently(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, - const std::string& utf8_text, bool log2file = true, bool is_region_msg = false, U32 timestamp = 0); + const std::string& utf8_text, bool log2file = true, bool is_region_msg = false, U32 timestamp = 0, + LLSD history_context = LLSD()); /** * Add a system message to an IM Model @@ -370,7 +390,8 @@ public: bool is_region_msg = false, U32 timestamp = 0, LLUUID display_id = LLUUID::null, - std::string_view display_name = ""); + std::string_view display_name = "", + const LLSD& original_text = LLSD()); void addSystemMessage(const LLUUID& session_id, const std::string& message_name, const LLSD& args); diff --git a/indra/newview/lllogchat.cpp b/indra/newview/lllogchat.cpp index 8630438155..fa30b1de31 100644 --- a/indra/newview/lllogchat.cpp +++ b/indra/newview/lllogchat.cpp @@ -29,6 +29,7 @@ #include "llagent.h" #include "llagentui.h" #include "llavatarnamecache.h" +#include "llchatservicehistory.h" #include "lllogchat.h" #include "llregex.h" #include "lltrans.h" @@ -47,6 +48,13 @@ #include <boost/date_time/gregorian/gregorian.hpp> #include <boost/date_time/posix_time/posix_time.hpp> #include <boost/date_time/local_time_adjustor.hpp> +#include <memory> +#include <mutex> + +#if !LL_WINDOWS +#include <fcntl.h> +#include <unistd.h> +#endif const S32 LOG_RECALL_SIZE = 20480; @@ -114,6 +122,12 @@ const static int IDX_TEXT = 3; using namespace boost::posix_time; using namespace boost::gregorian; +namespace +{ + // A save and the deletion sweep linearize at this feature-private boundary. + LLMutex sTranscriptMutationMutex; +} + void append_to_last_message(std::list<LLSD>& messages, const std::string& line) { if (!messages.size()) return; @@ -357,6 +371,9 @@ void LLLogChat::saveHistory(const std::string& filename, return; } + // Keep open/write/close on one side of the deletion sweep, then unlock before + // notifying observers that may read or write transcripts. + std::unique_lock<LLMutex> mutation_lock(sTranscriptMutationMutex); llofstream file(LLLogChat::makeLogFileName(filename).c_str(), std::ios_base::app); if (!file.is_open()) { @@ -401,6 +418,7 @@ void LLLogChat::saveHistory(const std::string& filename, file << LLChatLogFormatter(item) << std::endl; file.close(); + mutation_lock.unlock(); LLLogChat::getInstance()->triggerHistorySignal(); } @@ -408,14 +426,16 @@ void LLLogChat::saveHistory(const std::string& filename, // static void LLLogChat::loadChatHistory(const std::string& file_name, std::list<LLSD>& messages, const LLSD& load_params, bool is_group) { + if (LLChatServiceHistory::historySuppressed()) + { + return; + } if (file_name.empty()) { LL_WARNS("LLLogChat::loadChatHistory") << "Local history file name is empty!" << LL_ENDL; return ; } - bool load_all_history = load_params.has("load_all_history") ? load_params["load_all_history"].asBoolean() : false; - // Stat the file to find it and get the last history entry time llstat stat_data; @@ -453,84 +473,122 @@ void LLLogChat::loadChatHistory(const std::string& file_name, std::list<LLSD>& m } } - // If we got here, we managed to stat the file. - // Open the file to read in binary mode to prevent interpreting other characters as EOF - LLFILE* fptr = LLFile::fopen(log_file_name, LLFILE_MODE("rb")); /*Flawfinder: ignore*/ - if (!fptr) - { // Ok, this is strange but not really tragic in the big picture of things - LL_WARNS("ChatHistory") << "Unable to read file " << log_file_name << " after stat was successful" << LL_ENDL; + auto save_num_messages = messages.size(); + loadChatHistoryExact(log_file_name, messages, load_params); + + LL_DEBUGS("ChatHistory") << "Read " << (messages.size() - save_num_messages) + << " messages of chat history from " << log_file_name + << " file mod time " << (F64)stat_data.st_mtime << LL_ENDL; +} + +// static +void LLLogChat::loadChatHistoryExact(const std::string& path, std::list<LLSD>& messages, + const LLSD& load_params) +{ + if (path.empty()) + { return; } - auto save_num_messages = messages.size(); - - char buffer[LOG_RECALL_SIZE]; /*Flawfinder: ignore*/ - char *bptr; - size_t len; - bool firstline = true; + // Binary mode keeps transcript contents independent of platform EOF handling. + std::unique_ptr<LLFILE, int (*)(LLFILE*)> input( + LLFile::fopen(path, LLFILE_MODE("rb")), LLFile::close); + if (!input) + { + LL_WARNS("ChatHistory") << "Unable to read file " << path << LL_ENDL; + return; + } - if (load_all_history || fseek(fptr, (LOG_RECALL_SIZE - 1) * -1 , SEEK_END)) - { //We need to load the whole historyFile or it's smaller than recall size, so get it all. - firstline = false; - if (fseek(fptr, 0, SEEK_SET)) + // A bounded load seeks near EOF and discards the first partial line; full + // stitched reads parse from the beginning of each exact transcript path. + char buffer[LOG_RECALL_SIZE]; + bool skip_partial_line = true; + if (load_params["load_all_history"].asBoolean() || fseek(input.get(), (LOG_RECALL_SIZE - 1) * -1, SEEK_END)) + { + skip_partial_line = false; + if (fseek(input.get(), 0, SEEK_SET)) { - fclose(fptr); return; } } - while (fgets(buffer, LOG_RECALL_SIZE, fptr) && !feof(fptr)) + + while (fgets(buffer, LOG_RECALL_SIZE, input.get())) { - len = strlen(buffer) - 1; /*Flawfinder: ignore*/ - // backfill any end of line characters with nulls - for (bptr = (buffer + len); (*bptr == '\n' || *bptr == '\r') && bptr>buffer; bptr--) *bptr='\0'; + // Normalize either newline style before applying legacy multiline rules. + size_t length = strlen(buffer); + while (length && (buffer[length - 1] == '\n' || buffer[length - 1] == '\r')) + { + buffer[--length] = '\0'; + } - if (firstline) + if (skip_partial_line) { - firstline = false; + skip_partial_line = false; continue; } std::string line(remove_utf8_bom(buffer)); - - // fast heuristic test for a mention URL in a string - // this is used to avoid costly regex calls + // Restore mention URLs only when the inexpensive marker indicates the + // formatter's markdown wrapper may be present. if (line.find("/mention)") != std::string::npos) { - // restore original mention URL from [@username](URL) format - static const boost::regex altered_mention_regex("\\[@([^\\]]+)\\]\\((" APP_HEADER_REGEX "/agent/[\\da-f-]+/mention)\\)", - boost::regex::perl | boost::regex::icase); - - // $2 captures the URL part - line = boost::regex_replace(line, altered_mention_regex, "$2"); + static const boost::regex mention_regex( + "\\[@([^\\]]+)\\]\\((" APP_HEADER_REGEX "/agent/[\\da-f-]+/mention)\\)", + boost::regex::perl | boost::regex::icase); + line = boost::regex_replace(line, mention_regex, "$2"); } - //updated 1.23 plain text log format requires a space added before subsequent lines in a multilined message - if (' ' == line[0]) + // Indented lines continue the prior multiline message in the current + // plaintext format. + if (!line.empty() && line[0] == ' ') { line.erase(0, MULTI_LINE_PREFIX.length()); append_to_last_message(messages, '\n' + line); + continue; } - else if (0 == len && ('\n' == line[0] || '\r' == line[0])) - { - //to support old format's multilined messages with new lines used to divide paragraphs - append_to_last_message(messages, line); - } - else + + // Parser failures remain readable as plain transcript text. + LLSD item; + if (!LLChatLogParser::parse(line, item, load_params)) { - LLSD item; - if (!LLChatLogParser::parse(line, item, load_params)) - { - item[LL_IM_TEXT] = line; - } - messages.push_back(item); + item[LL_IM_TEXT] = line; } + messages.push_back(item); } - fclose(fptr); +} - LL_DEBUGS("ChatHistory") << "Read " << (messages.size() - save_num_messages) - << " messages of chat history from " << log_file_name - << " file mod time " << (F64)stat_data.st_mtime << LL_ENDL; +// static +void LLLogChat::getTranscriptFamily(const std::string& file_name, std::vector<std::string>& paths) +{ + paths.clear(); + if (file_name.empty()) + { + return; + } + + const std::string stem = cleanFileName(file_name); + const std::string directory = gDirUtilp->getPerAccountChatLogsDir() + + gDirUtilp->getDirDelimiter(); + const std::string ordinary = directory + stem + '.' + LL_TRANSCRIPT_FILE_EXTENSION; + if (LLFile::isfile(ordinary)) + { + paths.push_back(ordinary); + } + + // Monthly shards follow the ordinary file in lexical order for deterministic + // seam ordering across separate legacy files. + std::vector<std::string> shards; + // Split the glob before the separator so its question marks cannot form a C++ trigraph. + LLDirIterator iterator(directory, stem + "-????" "-??." + LL_TRANSCRIPT_FILE_EXTENSION); + std::string name; + while (iterator.next(name)) + { + shards.push_back(directory + name); + } + + std::sort(shards.begin(), shards.end()); + paths.insert(paths.end(), shards.begin(), shards.end()); } bool LLLogChat::historyThreadsFinished(LLUUID session_id) @@ -554,17 +612,6 @@ bool LLLogChat::historyThreadsFinished(LLUUID session_id) return finished; } -LLLoadHistoryThread* LLLogChat::getLoadHistoryThread(LLUUID session_id) -{ - LLMutexLock lock(historyThreadsMutex()); - std::map<LLUUID,LLLoadHistoryThread *>::iterator it = mLoadHistoryThreads.find(session_id); - if (it != mLoadHistoryThreads.end()) - { - return it->second; - } - return NULL; -} - LLDeleteHistoryThread* LLLogChat::getDeleteHistoryThread(LLUUID session_id) { LLMutexLock lock(historyThreadsMutex()); @@ -819,10 +866,31 @@ bool LLLogChat::moveTranscripts(const std::string currentDirectory, //static void LLLogChat::deleteTranscripts() { + deleteTranscriptContent(gDirUtilp->getPerAccountChatLogsDir()); + LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); +} + +// static +bool LLLogChat::deleteTranscriptContent(const std::string& directory) +{ + // Snapshot and remove the exact legacy targets while plaintext writers are excluded. + LLMutexLock mutation_lock(&sTranscriptMutationMutex); std::vector<std::string> list_of_transcriptions; - getListOfTranscriptFiles(list_of_transcriptions); - getListOfTranscriptBackupFiles(list_of_transcriptions); + const std::string separator = !directory.empty() && + (directory.back() == '/' || directory.back() == '\\') ? "" : "/"; + for (const std::string& pattern : { std::string("*.") + LL_TRANSCRIPT_FILE_EXTENSION, + std::string("*.") + LL_TRANSCRIPT_FILE_EXTENSION + ".backup*" }) + { + LLDirIterator iterator(directory, pattern); + std::string filename; + while (iterator.next(filename)) + { + list_of_transcriptions.push_back(directory + separator + filename); + } + } + // Retry each captured path independently while writers remain excluded. Any + // exhausted target keeps the durable account deletion in its pending state. for (const std::string& fullpath : list_of_transcriptions) { S32 retry_count = 0; @@ -838,7 +906,7 @@ void LLLogChat::deleteTranscripts() if(retry_count >= 5) { LL_WARNS("LLLogChat::deleteTranscripts") << "Failed to remove " << fullpath << LL_ENDL; - return; + return false; } ms_sleep(100); @@ -854,19 +922,44 @@ void LLLogChat::deleteTranscripts() } } - LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); +#if LL_WINDOWS + return true; +#else + // Flush removed directory entries before durable Delete can publish completion. + const int descriptor = ::open(directory.c_str(), O_RDONLY | O_DIRECTORY); + if (descriptor < 0) + { + return false; + } + + const bool synced = ::fsync(descriptor) == 0; + ::close(descriptor); + return synced; +#endif +} + +// static +void LLLogChat::notifyTranscriptCreated() +{ + LLLogChat::getInstance()->triggerHistorySignal(); } // static bool LLLogChat::isTranscriptExist(const LLUUID& avatar_id, bool is_group) { + // History actions share the deletion gate and both direct transcript sources. + if (LLChatServiceHistory::historySuppressed()) + { + return false; + } LLAvatarName avatar_name; LLAvatarNameCache::get(avatar_id, &avatar_name); std::string avatar_user_name = avatar_name.getAccountName(); if(!is_group) { std::replace(avatar_user_name.begin(), avatar_user_name.end(), '.', '_'); - return isTranscriptFileFound(makeLogFileName(avatar_user_name)); + return isTranscriptFileFound(makeLogFileName(avatar_user_name)) || + LLChatServiceHistory::localHistoryExists(avatar_id); } else { @@ -1156,8 +1249,7 @@ LLLoadHistoryThread::LLLoadHistoryThread(const std::string& file_name, std::list mMessages(messages), mFileName(file_name), mLoadParams(load_params), - mNewLoad(true), - mLoadEndSignal(NULL) + mNewLoad(true) { } @@ -1208,7 +1300,7 @@ void LLLoadHistoryThread::loadHistory(const std::string& file_name, std::list<LL if (!fptr) { mNewLoad = false; - (*mLoadEndSignal)(messages, file_name); + mLoadEndSignal(messages, file_name); return; //No previous conversation with this name. } } @@ -1227,7 +1319,7 @@ void LLLoadHistoryThread::loadHistory(const std::string& file_name, std::list<LL { fclose(fptr); mNewLoad = false; - (*mLoadEndSignal)(messages, file_name); + mLoadEndSignal(messages, file_name); return; } } @@ -1271,25 +1363,10 @@ void LLLoadHistoryThread::loadHistory(const std::string& file_name, std::list<LL fclose(fptr); mNewLoad = false; - (*mLoadEndSignal)(messages, file_name); + mLoadEndSignal(messages, file_name); } boost::signals2::connection LLLoadHistoryThread::setLoadEndSignal(const load_end_signal_t::slot_type& cb) { - if (NULL == mLoadEndSignal) - { - mLoadEndSignal = new load_end_signal_t(); - } - - return mLoadEndSignal->connect(cb); -} - -void LLLoadHistoryThread::removeLoadEndSignal(const load_end_signal_t::slot_type& cb) -{ - if (NULL != mLoadEndSignal) - { - mLoadEndSignal->disconnect_all_slots(); - delete mLoadEndSignal; - } - mLoadEndSignal = NULL; + return mLoadEndSignal.connect(cb); } diff --git a/indra/newview/lllogchat.h b/indra/newview/lllogchat.h index 39c40b985e..e9e512f09a 100644 --- a/indra/newview/lllogchat.h +++ b/indra/newview/lllogchat.h @@ -49,7 +49,8 @@ private: class LLLoadHistoryThread : public LLActionThread { private: - const std::string& mFileName; + // The worker owns its input after the Preview closes. + const std::string mFileName; std::list<LLSD>* mMessages; LLSD mLoadParams; bool mNewLoad; @@ -61,9 +62,8 @@ public: virtual void run(); typedef boost::signals2::signal<void (std::list<LLSD>* messages,const std::string& file_name)> load_end_signal_t; - load_end_signal_t * mLoadEndSignal; + load_end_signal_t mLoadEndSignal; boost::signals2::connection setLoadEndSignal(const load_end_signal_t::slot_type& cb); - void removeLoadEndSignal(const load_end_signal_t::slot_type& cb); }; class LLDeleteHistoryThread : public LLActionThread @@ -109,6 +109,13 @@ public: static void getListOfTranscriptBackupFiles(std::vector<std::string>& list_of_transcriptions); static void loadChatHistory(const std::string& file_name, std::list<LLSD>& messages, const LLSD& load_params = LLSD(), bool is_group = false); + // Enumerate the ordinary transcript followed by lexical monthly shards, then + // reuse the canonical parser for each exact path. + static void getTranscriptFamily(const std::string& file_name, std::vector<std::string>& paths); + // Worker reads use captured paths and an explicit full-history policy; the caller + // checks account/privacy state before dispatch and before publishing the result. + static void loadChatHistoryExact(const std::string& path, std::list<LLSD>& messages, + const LLSD& load_params); typedef boost::signals2::signal<void ()> save_history_signal_t; boost::signals2::connection setSaveHistorySignal(const save_history_signal_t::slot_type& cb); @@ -122,6 +129,12 @@ public: std::vector<std::string>& listOfFilesToMove); static void deleteTranscripts(); + + // The success-returning sweep lets durable ChatService deletion remain pending + // until every legacy transcript target has been removed safely. + static bool deleteTranscriptContent(const std::string& directory); + static void notifyTranscriptCreated(); + // Whether history can be opened: plaintext or direct service rows, subject to deletion. static bool isTranscriptExist(const LLUUID& avatar_id, bool is_group=false); static bool isNearbyTranscriptExist(); static bool isAdHocTranscriptExist(std::string file_name); @@ -130,7 +143,6 @@ public: static std::string getGroupChatSuffix(); bool historyThreadsFinished(LLUUID session_id); - LLLoadHistoryThread* getLoadHistoryThread(LLUUID session_id); LLDeleteHistoryThread* getDeleteHistoryThread(LLUUID session_id); bool addLoadHistoryThread(LLUUID& session_id, LLLoadHistoryThread* lthread); bool addDeleteHistoryThread(LLUUID& session_id, LLDeleteHistoryThread* dthread); diff --git a/indra/newview/llmutelist.h b/indra/newview/llmutelist.h index aff23c72d1..8aeafa61ce 100644 --- a/indra/newview/llmutelist.h +++ b/indra/newview/llmutelist.h @@ -93,6 +93,14 @@ class LLMuteList : public LLSingleton<LLMuteList> MLS_SERVER_CACHE, MLS_FALLBACK_CACHE, }; + + // UseCachedMuteList is an authoritative server response even though its bytes + // come from the viewer's CRC-matched local cache. + static bool isServerAuthoritativeSource(EMuteListSource source) + { + return source == MLS_SERVER || source == MLS_SERVER_EMPTY || source == MLS_SERVER_CACHE; + } + public: // reasons for auto-unmuting a resident enum EAutoReason @@ -130,9 +138,10 @@ public: bool isLoaded() const { return mLoadState == ML_LOADED; } // Loaded, but not necessarily from server. bool isFailed() const { return mLoadState == ML_FAILED; } // Unable to load any mute list. Server did not reply. // Loaded from an authoritative server response, including when the server directs us to use our cached copy. - bool isLoadedFromServer() const { return isLoaded() && (mLoadSource == MLS_SERVER || mLoadSource == MLS_SERVER_EMPTY || mLoadSource == MLS_SERVER_CACHE); } + bool isLoadedFromServer() const { return isLoaded() && isServerAuthoritativeSource(mLoadSource); } // Loaded without an authoritative server response. Would be nice to upgrade to a server load from here if possible. bool isLoadedDegraded() const { return isLoaded() && !isLoadedFromServer(); } + const char* getLoadSourceName() const { return sourceToString(mLoadSource); } // Advance the load state machine, trying cache fallback if necessary. // Return value indicates mute list consumption readiness. @@ -212,6 +221,7 @@ private: boost::signals2::connection mRegionChangedCallback; friend class LLDispatchEmptyMuteList; + friend struct LLMuteListTestAccess; }; class LLMuteListObserver diff --git a/indra/newview/llnotificationhandler.h b/indra/newview/llnotificationhandler.h index a259a4b248..0099adca88 100644 --- a/indra/newview/llnotificationhandler.h +++ b/indra/newview/llnotificationhandler.h @@ -287,17 +287,20 @@ public: /** * Writes notification message to IM session. + * notification_id links a text fallback to its inline offer, when present. */ static void logToIM(const EInstantMessage& session_type, const std::string& session_name, const std::string& from_name, const std::string& message, const LLUUID& session_owner_id, - const LLUUID& from_id); + const LLUUID& from_id, const LLUUID& notification_id = LLUUID::null); /** * Writes notification message to IM p2p session. */ - static void logToIMP2P(const LLNotificationPtr& notification, bool to_file_only = false); - static void logToIMP2P(const LLUUID& from_id, const std::string& message, bool to_file_only = false); + static void logToIMP2P(const LLNotificationPtr& notification, bool to_file_only = false, + const LLUUID& notification_id = LLUUID::null); + static void logToIMP2P(const LLUUID& from_id, const std::string& message, bool to_file_only = false, + const LLUUID& notification_id = LLUUID::null); /** * Writes group notice notification message to IM group session. diff --git a/indra/newview/llnotificationhandlerutil.cpp b/indra/newview/llnotificationhandlerutil.cpp index cdf7f05ada..935094fdc0 100644 --- a/indra/newview/llnotificationhandlerutil.cpp +++ b/indra/newview/llnotificationhandlerutil.cpp @@ -72,7 +72,7 @@ bool LLHandlerUtil::isIMFloaterOpened(const LLNotificationPtr& notification) void LLHandlerUtil::logToIM(const EInstantMessage& session_type, const std::string& session_name, const std::string& from_name, const std::string& message, const LLUUID& session_owner_id, - const LLUUID& from_id) + const LLUUID& from_id, const LLUUID& notification_id) { std::string from = from_name; if (from_name.empty()) @@ -111,6 +111,14 @@ void LLHandlerUtil::logToIM(const EInstantMessage& session_type, S32 participant_unread = session->mParticipantUnreadMessageCount; LLIMModel::instance().addMessageSilently(session_id, from, from_id, message); + + // Link the text fallback to its inline offer before the floater sees it. + // Name lookup may complete after other messages have arrived. + if (notification_id.notNull()) + { + session->mMsgs.front()["notification_log_id"] = notification_id; + } + // we shouldn't increment counters when logging, so restore them session->mNumUnread = unread; session->mParticipantUnreadMessageCount = participant_unread; @@ -121,15 +129,17 @@ void LLHandlerUtil::logToIM(const EInstantMessage& session_type, } void log_name_callback(const LLAvatarName& av_name, const std::string& from_name, - const std::string& message, const LLUUID& from_id) + const std::string& message, const LLUUID& from_id, + const LLUUID& notification_id) { LLHandlerUtil::logToIM(IM_NOTHING_SPECIAL, av_name.getUserName(), from_name, message, - from_id, LLUUID()); + from_id, LLUUID(), notification_id); } // static -void LLHandlerUtil::logToIMP2P(const LLUUID& from_id, const std::string& message, bool to_file_only) +void LLHandlerUtil::logToIMP2P(const LLUUID& from_id, const std::string& message, bool to_file_only, + const LLUUID& notification_id) { if (!gCacheName) { @@ -145,19 +155,20 @@ void LLHandlerUtil::logToIMP2P(const LLUUID& from_id, const std::string& message if(to_file_only) { - LLAvatarNameCache::get(from_id, boost::bind(&log_name_callback, _2, "", message, LLUUID())); + LLAvatarNameCache::get(from_id, boost::bind(&log_name_callback, _2, "", message, LLUUID(), notification_id)); } else { - LLAvatarNameCache::get(from_id, boost::bind(&log_name_callback, _2, INTERACTIVE_SYSTEM_FROM, message, from_id)); + LLAvatarNameCache::get(from_id, boost::bind(&log_name_callback, _2, INTERACTIVE_SYSTEM_FROM, message, from_id, notification_id)); } } // static -void LLHandlerUtil::logToIMP2P(const LLNotificationPtr& notification, bool to_file_only) +void LLHandlerUtil::logToIMP2P(const LLNotificationPtr& notification, bool to_file_only, + const LLUUID& notification_id) { LLUUID from_id = notification->getPayload()["from_id"]; - logToIMP2P(from_id, notification->getMessage(), to_file_only); + logToIMP2P(from_id, notification->getMessage(), to_file_only, notification_id); } // static @@ -327,11 +338,12 @@ void LLHandlerUtil::decIMMesageCounter(const LLNotificationPtr& notification) { LLSD arg; arg["session_id"] = session_id; - session->mNumUnread--; + // Keep the decrements clamped if notification dismissal follows session cleanup. + session->mNumUnread = llmax(0, session->mNumUnread - 1); arg["num_unread"] = session->mNumUnread; - session->mParticipantUnreadMessageCount--; + session->mParticipantUnreadMessageCount = + llmax(0, session->mParticipantUnreadMessageCount - 1); arg["participant_unread"] = session->mParticipantUnreadMessageCount; LLIMModel::getInstance()->mNewMsgSignal(arg); } } - diff --git a/indra/newview/llnotificationofferhandler.cpp b/indra/newview/llnotificationofferhandler.cpp index 27ef28f32a..aa875ab247 100644 --- a/indra/newview/llnotificationofferhandler.cpp +++ b/indra/newview/llnotificationofferhandler.cpp @@ -150,6 +150,8 @@ bool LLOfferHandler::processNotification(const LLNotificationPtr& notification, { // log only to file if notif panel can be embedded to IM and IM is opened bool file_only = add_notif_to_im && LLHandlerUtil::isIMFloaterOpened(notification); + // Only a log paired with an inline offer may be hidden during replay. + const LLUUID notification_id = add_notif_to_im ? notification->getID() : LLUUID::null; if ((notification->getName() == "TeleportOffered" || notification->getName() == "TeleportOffered_MaturityExceeded" || notification->getName() == "TeleportOffered_MaturityBlocked")) @@ -157,11 +159,11 @@ bool LLOfferHandler::processNotification(const LLNotificationPtr& notification, boost::regex r("<icon\\s*>\\s*([^<]*)?\\s*</icon\\s*>( - )?", boost::regex::perl|boost::regex::icase); std::string stripped_msg = boost::regex_replace(notification->getMessage(), r, ""); - LLHandlerUtil::logToIMP2P(notification->getPayload()["from_id"], stripped_msg,file_only); + LLHandlerUtil::logToIMP2P(notification->getPayload()["from_id"], stripped_msg, file_only, notification_id); } else { - LLHandlerUtil::logToIMP2P(notification, file_only); + LLHandlerUtil::logToIMP2P(notification, file_only, notification_id); } } } @@ -171,22 +173,29 @@ bool LLOfferHandler::processNotification(const LLNotificationPtr& notification, /*virtual*/ void LLOfferHandler::onChange(LLNotificationPtr p) { - auto panelp = LLToastNotifyPanel::getInstance(p->getID()); - if (panelp) + bool has_toast_panel = false; + + // Deferred panel deletion can briefly overlap old and replacement views. + // Prefer the live IM projection for this notification; otherwise close its toast. + for (LLToastNotifyPanel& panel : LLToastNotifyPanel::instance_snapshot()) { - // - // HACK: if we're dealing with a notification embedded in IM, update it - // otherwise remove its toast - // - if (dynamic_cast<LLIMToastNotifyPanel*>(panelp.get())) + if (panel.isDead() || panel.getID() != p->getID()) { - panelp->updateNotification(); + continue; } - else + + if (LLIMToastNotifyPanel* im_panel = dynamic_cast<LLIMToastNotifyPanel*>(&panel)) { - // if notification has changed, hide it - mChannel.get()->removeToastByNotificationID(p->getID()); + im_panel->updateNotification(); + return; } + + has_toast_panel = true; + } + + if (has_toast_panel) + { + mChannel.get()->removeToastByNotificationID(p->getID()); } } diff --git a/indra/newview/llpanelprofile.cpp b/indra/newview/llpanelprofile.cpp index 9402facb2a..3cdbfc7749 100644 --- a/indra/newview/llpanelprofile.cpp +++ b/indra/newview/llpanelprofile.cpp @@ -2499,4 +2499,3 @@ void LLPanelProfile::createClassified() mPanelClassifieds->createClassified(); mTabContainer->selectTabPanel(mPanelClassifieds); } - diff --git a/indra/newview/lltoastnotifypanel.cpp b/indra/newview/lltoastnotifypanel.cpp index e905a879cb..c2bb880e2c 100644 --- a/indra/newview/lltoastnotifypanel.cpp +++ b/indra/newview/lltoastnotifypanel.cpp @@ -58,7 +58,7 @@ LLToastNotifyPanel::button_click_signal_t LLToastNotifyPanel::sButtonClickSignal LLToastNotifyPanel::LLToastNotifyPanel(const LLNotificationPtr& notification, const LLRect& rect, bool show_images) : LLCheckBoxToastPanel(notification) -, LLInstanceTracker<LLToastNotifyPanel, LLUUID, LLInstanceTrackerReplaceOnCollision>(notification->getID()) +, LLInstanceTracker<LLToastNotifyPanel>() , mTextBox(NULL), mButtonWidth(BUTTON_WIDTH) { diff --git a/indra/newview/lltoastnotifypanel.h b/indra/newview/lltoastnotifypanel.h index 71b9eaf8f5..31df823e27 100644 --- a/indra/newview/lltoastnotifypanel.h +++ b/indra/newview/lltoastnotifypanel.h @@ -44,10 +44,12 @@ class LLNotificationForm; * * Replaces class LLNotifyBox. * + * Toast and IM projections can overlap until deferred view deletion completes. + * * @deprecated this class will be removed after all toast panel types are * implemented in separate classes. */ -class LLToastNotifyPanel: public LLCheckBoxToastPanel, public LLInstanceTracker<LLToastNotifyPanel, LLUUID, LLInstanceTrackerReplaceOnCollision> +class LLToastNotifyPanel: public LLCheckBoxToastPanel, public LLInstanceTracker<LLToastNotifyPanel> { public: /** diff --git a/indra/newview/llviewerregion.cpp b/indra/newview/llviewerregion.cpp index c37daa53a3..7b9a6de2ed 100644 --- a/indra/newview/llviewerregion.cpp +++ b/indra/newview/llviewerregion.cpp @@ -3254,6 +3254,8 @@ void LLViewerRegionImpl::buildCapabilityNames(LLSD& capabilityNames) capabilityNames.append("AvatarRenderInfo"); capabilityNames.append("CharacterProperties"); capabilityNames.append("ChatSessionRequest"); + capabilityNames.append("PersistentChatConversationsRequest"); + capabilityNames.append("PersistentChatHistoryRequest"); capabilityNames.append("CopyInventoryFromNotecard"); capabilityNames.append("CreateInventoryCategory"); capabilityNames.append("DeclineFriendship"); diff --git a/indra/newview/skins/default/xui/en/floater_conversation_preview.xml b/indra/newview/skins/default/xui/en/floater_conversation_preview.xml index 720e730495..7774d926c5 100644 --- a/indra/newview/skins/default/xui/en/floater_conversation_preview.xml +++ b/indra/newview/skins/default/xui/en/floater_conversation_preview.xml @@ -61,4 +61,28 @@ top_delta="4" width="40"> </text> + <panel + background_visible="true" + bg_alpha_color="DkGray2" + name="chat_service_loading" + follows="top|right" + height="24" + right="-8" + top="28" + visible="false" + width="105"> + <loading_indicator + height="20" + left="0" + name="chat_service_loading_wheel" + top="2" + width="20" /> + <text + height="20" + left_pad="4" + name="chat_service_loading_text" + top="2" + value="Loading..." + width="80" /> + </panel> </floater> diff --git a/indra/newview/skins/default/xui/en/floater_im_session.xml b/indra/newview/skins/default/xui/en/floater_im_session.xml index 1ceafaf82e..897dc0a6b3 100644 --- a/indra/newview/skins/default/xui/en/floater_im_session.xml +++ b/indra/newview/skins/default/xui/en/floater_im_session.xml @@ -232,6 +232,30 @@ left="0" top="0" bottom="-1" /> + <panel + background_visible="true" + bg_alpha_color="DkGray2" + name="chat_service_loading" + follows="top|right" + height="24" + right="-8" + top="8" + visible="false" + width="105"> + <loading_indicator + height="20" + left="0" + name="chat_service_loading_wheel" + top="2" + width="20" /> + <text + height="20" + left_pad="4" + name="chat_service_loading_text" + top="2" + value="Loading..." + width="80" /> + </panel> </layout_panel> </layout_stack> </layout_panel> diff --git a/indra/newview/skins/default/xui/en/notifications.xml b/indra/newview/skins/default/xui/en/notifications.xml index 90cda572ba..7b7fb9cc3b 100644 --- a/indra/newview/skins/default/xui/en/notifications.xml +++ b/indra/newview/skins/default/xui/en/notifications.xml @@ -12290,7 +12290,7 @@ Cannot create large prims that intersect other residents. Please re-try when ot icon="alertmodal.tga" name="PreferenceChatDeleteTranscripts" type="alertmodal"> - This will delete the transcripts for all previous conversations. The list of past conversations will not be affected. All files with the suffixes .txt and txt.backup in the folder [FOLDER] will be deleted. + This removes local transcript files and downloaded Chat Service history. Remote records are retained, but service rows at or below the recorded deletion boundary remain hidden locally. The list of past conversations is not affected. <tag>confirm</tag> <usetemplate ignoretext="Confirm before I delete transcripts." @@ -12301,6 +12301,13 @@ Cannot create large prims that intersect other residents. Please re-try when ot <notification icon="alert.tga" + name="ChatServiceHistoryDeleteFailed" + type="alert"> + Some local history could not be deleted safely. History remains hidden until Delete Transcripts succeeds. + </notification> + + <notification + icon="alert.tga" name="PreferenceChatPathChanged" type="alert"> Unable to move files. Restored previous path. diff --git a/indra/newview/tests/llchatservicehistorycore_test.cpp b/indra/newview/tests/llchatservicehistorycore_test.cpp new file mode 100644 index 0000000000..38f77de4af --- /dev/null +++ b/indra/newview/tests/llchatservicehistorycore_test.cpp @@ -0,0 +1,1138 @@ +/** + * @file llchatservicehistorycore_test.cpp + * @brief Strict ChatService wire, TimeUUID, and archive tests. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * $/LicenseInfo$ + */ +#include "linden_common.h" +#include "llsingleton.h" +#include "../test/lltut.h" +#include "../test/namedtempfile.h" +#include "../llchatservicehistorycore.h" +#include "../llmutelist.h" +#include <array> +#include "llsdutil.h" + +struct LLMuteListTestAccess +{ + static bool server() + { + return LLMuteList::isServerAuthoritativeSource(LLMuteList::MLS_SERVER); + } + static bool serverEmpty() + { + return LLMuteList::isServerAuthoritativeSource(LLMuteList::MLS_SERVER_EMPTY); + } + static bool serverCache() + { + return LLMuteList::isServerAuthoritativeSource(LLMuteList::MLS_SERVER_CACHE); + } + static bool fallbackCache() + { + return LLMuteList::isServerAuthoritativeSource(LLMuteList::MLS_FALLBACK_CACHE); + } +}; + +namespace tut +{ +using namespace LLChatServiceHistoryCore; + +struct chat_service_history_core {}; +typedef test_group<chat_service_history_core> group_t; +typedef group_t::object object_t; +group_t group("LLChatServiceHistoryCore"); + +const LLUUID AGENT("00000000-0000-0000-0000-000000000001"); +const LLUUID RESIDENT("00000000-0000-0000-0000-000000000002"); +const char* const FIRST = "00000001-0000-1000-8000-000000000000"; +const char* const SECOND = "00000002-0000-1000-8000-000000000000"; + +LLSD wireRow(const std::string& id, const LLUUID& from = RESIDENT) +{ + LLSD row; + row["conversation_id"] = directConversationId(AGENT, RESIDENT); + row["msg_id"] = id; + row["from_id"] = from.asString(); + row["from_name"] = "Resident"; + row["message"] = "hello"; + row["dialog"] = 0; + row["created_at"] = "2026-08-12T12:34:56.123Z"; + + return row; +} + +LLSD wirePage(const LLSD& rows, const std::string& cursor = std::string()) +{ + LLSD page; + page["conversation_id"] = directConversationId(AGENT, RESIDENT); + page["limit"] = 100; + page["messages"] = rows; + if (!cursor.empty()) + { + page["before_msg_id"] = cursor; + } + + return page; +} + +template<> template<> void object_t::test<1>() +{ + ensure_equals("deterministic direct id", directConversationId(RESIDENT, AGENT), + AGENT.asString() + "_" + RESIDENT.asString()); +} + +template<> template<> void object_t::test<2>() +{ + // UUID parsing accepts only canonical version-1 identifiers and preserves their + // timestamp order. + TimeUuidKey first; + TimeUuidKey second; + ensure("UUIDv1 parses", parseTimeUuid(FIRST, first)); + ensure("timestamp ordering", parseTimeUuid(SECOND, second) && first < second); + + ensure("UUIDv4 rejected", !parseTimeUuid( + "00000001-0000-4000-8000-000000000000", first)); + ensure("noncanonical UUID rejected", !parseTimeUuid( + "00000001-0000-1000-8000-00000000000A", first)); +} + +template<> template<> void object_t::test<3>() +{ + // Equal-timestamp tails follow Cassandra's signed-byte comparison. + TimeUuidKey low; + TimeUuidKey high; + parseTimeUuid(FIRST, low); + parseTimeUuid("00000001-0000-1000-bfff-000000000000", high); + ensure("Cassandra signed tail ordering", low < high); +} + +template<> template<> void object_t::test<4>() +{ + // A valid page is strictly descending and exposes its oldest row as the cursor. + LLSD rows = LLSD::emptyArray(); + rows.append(wireRow(SECOND)); + rows.append(wireRow(FIRST, AGENT)); + + Page page; + ensure("strict descending page", validateHistoryPage( + wirePage(rows), AGENT, RESIDENT, + "", 0, page)); + ensure_equals("all rows retained", page.rows.size(), size_t(2)); + ensure_equals("oldest cursor", page.next_cursor, std::string(FIRST)); + + // The deletion boundary is inclusive and ends paging, but not validation. + ensure("cutoff page accepted", validateHistoryPage( + wirePage(rows), AGENT, RESIDENT, "", 1, page)); + ensure_equals("only newer rows retained", page.rows.size(), size_t(1)); + ensure_equals("newer row retained", page.rows.front().msg_id, std::string(SECOND)); + ensure("cutoff ends paging", page.terminal && page.next_cursor.empty()); + rows.append(wireRow("00000000-0000-1000-8000-000000000000")); + rows[2]["dialog"] = 99; + ensure("invalid row below cutoff rejected", !validateHistoryPage( + wirePage(rows), AGENT, RESIDENT, "", 1, page)); +} + +template<> template<> void object_t::test<5>() +{ + // Ordering and exact LLSD types are page-wide validation requirements. + LLSD rows = LLSD::emptyArray(); + rows.append(wireRow(FIRST)); + rows.append(wireRow(SECOND)); + + Page page; + ensure("ascending page rejected", !validateHistoryPage( + wirePage(rows), AGENT, RESIDENT, + "", 0, page)); + + // Ordering alone rejects duplicates, including replay of the preceding page's cursor. + rows[1] = rows[0]; + ensure("duplicate row rejected", !validateHistoryPage( + wirePage(rows), AGENT, RESIDENT, "", 0, page)); + rows.erase(1); + ensure("cursor row cannot repeat on the next page", !validateHistoryPage( + wirePage(rows, FIRST), AGENT, RESIDENT, FIRST, 0, page)); + + LLSD wrong = wirePage(LLSD::emptyArray()); + wrong["conversation_id"] = directConversationId(AGENT, LLUUID::null); + ensure("another conversation rejected", !validateHistoryPage( + wrong, AGENT, RESIDENT, "", 0, page)); + wrong = wirePage(LLSD::emptyArray()); + wrong["limit"] = "100"; + ensure("coercible type rejected", !validateHistoryPage( + wrong, AGENT, RESIDENT, "", 0, page)); +} + +template<> template<> void object_t::test<6>() +{ + // Discovery rejects duplicate peers and ignores the null system participant. + LLSD list = LLSD::emptyArray(); + LLSD entry; + entry["conversation_type"] = "direct"; + entry["conversation_id"] = directConversationId(AGENT, RESIDENT); + entry["other_participant_id"] = RESIDENT.asString(); + entry["last_msg_id"] = SECOND; + list.append(entry); + + std::vector<ListEntry> entries; + ensure("strict list accepted", validateConversationList(list, AGENT, entries)); + + list.append(entry); + ensure("duplicate resident rejected", !validateConversationList(list, AGENT, entries)); + + LLSD null_list = LLSD::emptyArray(); + entry["other_participant_id"] = LLUUID::null.asString(); + entry["conversation_id"] = directConversationId(AGENT, LLUUID::null); + null_list.append(entry); + ensure("null system participant ignored", validateConversationList(null_list, AGENT, entries)); + ensure("system conversation has no resident history", entries.empty()); +} + +template<> template<> void object_t::test<7>() +{ + std::string normalized; + + // Accept the supported UTC, explicit-offset, offset-less, and fractional forms. + ensure("UTC timestamp", parseCreatedAt("2026-08-12T12:34:56Z", normalized)); + ensure("offset timestamp", parseCreatedAt("2026-08-12T12:34:56+02:30", normalized)); + ensure("offset-less timestamp", parseCreatedAt("2026-08-12T12:34:56", normalized)); + ensure("fractional timestamp", parseCreatedAt("2026-08-12T12:34:56.123Z", normalized)); + + // Reject malformed suffixes and noncanonical field shapes. + ensure("reject trailing timestamp data", + !parseCreatedAt("2026-08-12T12:34:56Z123", normalized)); + ensure("reject invalid offset", !parseCreatedAt("2026-08-12T12:34:56+99:99", normalized)); + ensure("reject signed date field", !parseCreatedAt("2026-+1-12T12:34:56Z", normalized)); + ensure("reject empty fraction", !parseCreatedAt("2026-08-12T12:34:56.Z", normalized)); + + // Enforce calendar and clock ranges after the fixed-width shape validates. + ensure("reject month", !parseCreatedAt("2026-13-12T12:34:56Z", normalized)); + ensure("reject day", !parseCreatedAt("2026-08-35T12:34:56Z", normalized)); + ensure("reject nonleap day", !parseCreatedAt("2026-02-29T12:34:56Z", normalized)); + ensure("accept leap day", parseCreatedAt("2024-02-29T12:34:56Z", normalized)); + ensure("reject hour", !parseCreatedAt("2026-08-12T24:34:56Z", normalized)); + ensure("reject minute", !parseCreatedAt("2026-08-12T12:60:56Z", normalized)); + ensure("reject second", !parseCreatedAt("2026-08-12T12:34:60Z", normalized)); + ensure("bad width", !parseCreatedAt("2026-8-12T12:34:56Z", normalized)); + + // Persisted history uses an explicit IM-dialog allowlist. + ensure("dialog allowlist", persistedDirectDialog(38) && !persistedDirectDialog(99)); +} + +template<> template<> void object_t::test<8>() +{ + ensure_equals("RFC4180 quoting", quoteCsv("comma, quote \" and\nnewline"), + "\"comma, quote \"\" and\nnewline\""); +} + +std::string validArchive() +{ + // Build a minimal ascending archive used by valid, torn, and corrupt scan cases. + Row first; + Row second; + first.conversation_id = directConversationId(AGENT, RESIDENT); + second.conversation_id = first.conversation_id; + first.msg_id = FIRST; + second.msg_id = SECOND; + first.from_id = AGENT; + second.from_id = RESIDENT; + first.from_name = "Resident"; + second.from_name = first.from_name; + first.message = "first"; + second.message = "second, quoted"; + first.created_at = "2026-08-12T12:34:56Z"; + second.created_at = first.created_at; + parseTimeUuid(first.msg_id, first.key); + parseTimeUuid(second.msg_id, second.key); + + std::ostringstream output; + output << CSV_HEADER; + writeCsvRow(output, first); + writeCsvRow(output, second); + + return output.str(); +} + +template<> template<> void object_t::test<9>() +{ + NamedTempFile file("chatservice", validArchive()); + ArchiveScan scan; + TimeUuidKey oldest; + parseTimeUuid(FIRST, oldest); + + ensure("valid archive", scanArchive(file.getPath().string(), AGENT, RESIDENT, 0, 1, scan)); + ensure_equals("two summarized", scan.row_count, U32(2)); + ensure("display cap preserves durable oldest", scan.oldest == oldest); + ensure_equals("newest display cap", scan.display_rows.size(), size_t(1)); + ensure_equals("newest retained", scan.display_rows.front().msg_id, std::string(SECOND)); +} + +template<> template<> void object_t::test<10>() +{ + // An incomplete final quoted record is repairable torn-tail state. + NamedTempFile file("chatservice", validArchive() + "\"unterminated"); + ArchiveScan scan; + + ensure("torn EOF rejected", !scanArchive( + file.getPath().string(), AGENT, RESIDENT, 0, 0, scan)); + ensure_equals("torn distinguished", scan.state, ARCHIVE_TORN); +} + +template<> template<> void object_t::test<11>() +{ + // A newline-terminated row with an unsupported dialog is complete corruption, + // not a repairable EOF fragment. + std::string content = validArchive(); + const std::string needle = ",0,2026-08-12T12:34:56Z\n"; + const size_t last = content.rfind(needle); + content.replace(last, needle.size(), ",99,2026-08-12T12:34:56Z\n"); + + NamedTempFile file("chatservice", content); + ArchiveScan scan; + + ensure("complete malformed row rejected", !scanArchive( + file.getPath().string(), AGENT, RESIDENT, 0, 0, scan)); + ensure_equals("corrupt distinguished", scan.state, ARCHIVE_CORRUPT); +} + +template<> template<> void object_t::test<12>() +{ + // A full server response on the first login and a server-validated cache on + // the next login must satisfy the same ChatService network prerequisite. + ensure("full server response is authoritative", LLMuteListTestAccess::server()); + ensure("empty server response is authoritative", LLMuteListTestAccess::serverEmpty()); + ensure("warm-cache relog remains authoritative", LLMuteListTestAccess::serverCache()); + + // A timeout fallback has not been validated by the server and remains closed. + ensure("fallback cache remains degraded", !LLMuteListTestAccess::fallbackCache()); +} + +template<> template<> void object_t::test<13>() +{ + // Chat service legacy names and viewer resident names identify the same direct sender. + ensure("legacy and resident names match", + sameDirectSenderName("Bridie Linden", "bridie.linden")); + ensure("different residents stay distinct", + !sameDirectSenderName("bridie.linden", "beanie.tester")); +} + +template<> template<> void object_t::test<14>() +{ + const F64 wall = 1000.0; + const F64 daylight_utc = wall + 7.0 * 3600.0; + + // The earlier UTC-7 interpretation controls conservative seam admission. + ensure("possible pre-boundary row retained", + legacyWallMayPrecedeService(wall, daylight_utc + 1.0)); + ensure("row at boundary excluded", + !legacyWallMayPrecedeService(wall, daylight_utc)); + ensure("post-boundary row excluded", + !legacyWallMayPrecedeService(wall, daylight_utc - 1.0)); + + // A boundary between the UTC-7 and UTC-8 interpretations remains ambiguous. + ensure("ambiguous DST row retained", + legacyWallMayPrecedeService(wall, daylight_utc + 30.0 * 60.0)); +} + +template<> template<> void object_t::test<15>() +{ + // Complete transcript names carry the resident identity inside parentheses. + ensure("complete name matches legacy resident name", + sameDirectSenderName("Bridie (bridie.linden)", "Bridie Linden")); + ensure("complete name matches dotted resident name", + sameDirectSenderName("Bridie (bridie.linden)", "bridie.linden")); + ensure("single-name resident matches Resident suffix", + sameDirectSenderName("beanie lin alt (danyanka)", "danyanka Resident")); + ensure("resident names compare without case", + sameDirectSenderName("BRIDIE.LINDEN", "Bridie Linden")); + + // Display-name changes and collisions must not change the compared identity. + ensure("different display names for one resident match", + sameDirectSenderName("Old Name (bridie.linden)", "New Name (bridie.linden)")); + ensure("same display name for different residents stays distinct", + !sameDirectSenderName("Bridie (bridie.linden)", "Bridie (danyanka)")); + ensure("display name alone does not identify the resident", + !sameDirectSenderName("Bridie", "Bridie (bridie.linden)")); + ensure("missing names do not identify a resident", !sameDirectSenderName("", "")); +} + +template<> template<> void object_t::test<16>() +{ + LLSD history; + history["from_id"] = RESIDENT; + history["message"] = "hello 1"; + history["timestamp"] = 1789005928; + history["chat_service_msg_id"] = FIRST; + + // Offline delivery carries its original body independently of the localized notice. + LLSD delivered = history; + delivered.erase("chat_service_msg_id"); + delivered["message"] = "(Saved Wed Sep 09 22:05:28 2026)hello 1"; + delivered["chat_service_original_text"] = "hello 1"; + ensure("offline service copy matches delivered original", sameDirectHistoryOccurrence(history, delivered)); + delivered["message"] = "(Enregistre le mercredi)hello 1"; + ensure("offline notice localization does not affect identity", sameDirectHistoryOccurrence(history, delivered)); + delivered["message"] = "hello 1 (bonjour 1)"; + ensure("translation does not affect identity", sameDirectHistoryOccurrence(history, delivered)); + + // Never infer original text by stripping something the sender may have typed. + delivered["message"] = "(Saved Wed Sep 09 22:05:28 2026)hello 1"; + delivered["chat_service_original_text"] = delivered["message"]; + ensure("literal saved prefix remains message content", !sameDirectHistoryOccurrence(history, delivered)); + delivered.erase("chat_service_original_text"); + ensure("unidentified decorated text is not guessed", !sameDirectHistoryOccurrence(history, delivered)); + delivered["message"] = "hello 1"; + ensure("plain live delivery still matches", sameDirectHistoryOccurrence(history, delivered)); + delivered["from_id"] = AGENT; + ensure("another sender stays distinct", !sameDirectHistoryOccurrence(history, delivered)); + delivered["from_id"] = RESIDENT; + delivered["timestamp"] = 1789005988; + ensure("another send minute stays distinct", !sameDirectHistoryOccurrence(history, delivered)); +} + +template<> template<> void object_t::test<17>() +{ + LLSD delivered; + delivered["from_id"] = RESIDENT; + delivered["from"] = "peppertest Resident"; + delivered["message"] = "(Saved Wed Sep 09 22:05:28 2026)hello 1"; + delivered["chat_service_original_text"] = "hello 1"; + delivered["timestamp"] = 1789005928; + delivered["chat_service_log_timestamp"] = 1789092365; + + // An offline message delivered the next day has a plaintext receipt minute + // that differs from its service send minute; both copies identify the same delivery. + LLSD legacy; + legacy["from"] = "peppertest"; + legacy["message"] = delivered["message"]; + const F64 logged_minute = (1789092365 / 60) * 60; + legacy["chat_service_legacy_wall_time"] = logged_minute - 7.0 * 3600.0; + ensure("plaintext matches the delivery minute", sameDirectHistoryOccurrence(legacy, delivered)); + legacy["chat_service_legacy_wall_time"] = logged_minute - 8.0 * 3600.0; + ensure("plaintext accepts the standard-time SLT offset", sameDirectHistoryOccurrence(legacy, delivered)); + legacy["message"] = "hello 1"; + ensure("plaintext matches its logged body exactly", !sameDirectHistoryOccurrence(legacy, delivered)); + legacy["message"] = delivered["message"]; + legacy.erase("chat_service_legacy_wall_time"); + ensure("undated plaintext is not guessed", !sameDirectHistoryOccurrence(legacy, delivered)); +} + +template<> template<> void object_t::test<18>() +{ + auto row = [](const std::string& text, S32 timestamp) + { + return LLSD().with("message", text).with("timestamp", timestamp); + }; + const std::list<LLSD> live{ + row("hello 3", 1789005932), row("hello 2", 1789005930), row("hello 1", 1789005928) + }; + const std::list<LLSD> history{row("hello", 1789005955), row("older", 1789005800)}; + auto merged = interleaveDirectHistory(history, live); + auto it = merged.begin(); + ensure_equals("fourth message follows the offline deliveries", (*it++)["message"].asString(), "hello"); + ensure_equals("third delivery keeps its place", (*it++)["message"].asString(), "hello 3"); + ensure_equals("second delivery keeps its place", (*it++)["message"].asString(), "hello 2"); + ensure_equals("first delivery keeps its place", (*it++)["message"].asString(), "hello 1"); + ensure_equals("older history precedes the live conversation", (*it++)["message"].asString(), "older"); + ensure("all occurrences retained", it == merged.end()); + + // Timestamp ties keep the existing live row later; undated history stays at the start. + merged = interleaveDirectHistory({row("tie", 1789005930), row("undated", 0)}, live); + it = merged.begin(); + ensure_equals("newest live stays newest", (*it++)["message"].asString(), "hello 3"); + ensure_equals("live wins timestamp tie", (*it++)["message"].asString(), "hello 2"); + ensure_equals("equal-time history retains its occurrence", (*it++)["message"].asString(), "tie"); + ensure_equals("oldest live stays in delivery order", (*it++)["message"].asString(), "hello 1"); + ensure_equals("undated history remains before live rows", (*it++)["message"].asString(), "undated"); + ensure("interleaving leaves source lists unchanged", live.size() == 3 && history.size() == 2); +} + +template<> template<> void object_t::test<19>() +{ + // A retained service row at :05 is a different occurrence from a new send at + // :35, even when its sender, text, and minute are identical. + LLSD history; + history["from_id"] = RESIDENT; + history["message"] = "ok"; + history["timestamp"] = 1789005905; + history["chat_service_msg_id"] = FIRST; + + LLSD delivered = history; + delivered.erase("chat_service_msg_id"); + delivered["timestamp"] = 1789005935; + ensure("a later repeat in the same minute stays distinct", + !sameDirectHistoryOccurrence(history, delivered)); + delivered["timestamp"] = 1789005904; + ensure("a preceding send in the same minute stays distinct", + !sameDirectHistoryOccurrence(history, delivered)); + delivered["timestamp"] = 1789005905; + ensure("the matching service second reconciles", + sameDirectHistoryOccurrence(history, delivered)); + + // Missing precision provides no evidence for consuming a service occurrence. + delivered["timestamp"] = 0; + ensure("an untimed delivery stays distinct", !sameDirectHistoryOccurrence(history, delivered)); +} + +template<> template<> void object_t::test<20>() +{ + LLSD history; + history["from_id"] = AGENT; + history["message"] = "hello"; + history["timestamp"] = 1789005905; + history["chat_service_msg_id"] = FIRST; + + // Outgoing echoes use a local clock; server persistence may occur in another second. + LLSD echo = history; + echo.erase("chat_service_msg_id"); + echo["chat_service_time_source"] = "local"; + echo["timestamp"] = 1789005904; + ensure("local send before persistence reconciles", sameDirectHistoryOccurrence(history, echo)); + echo["timestamp"] = 1789005906; + ensure("local clock ahead of the server reconciles", sameDirectHistoryOccurrence(history, echo)); + + // Timestamp tolerance does not relax sender/body identity or extend past the minute. + echo["message"] = "hello (bonjour)"; + echo["chat_service_original_text"] = "hello"; + ensure("translated local echo reconciles its original body", sameDirectHistoryOccurrence(history, echo)); + echo["chat_service_original_text"] = "another message"; + ensure("different local body stays distinct", !sameDirectHistoryOccurrence(history, echo)); + echo["chat_service_original_text"] = "hello"; + echo["from_id"] = RESIDENT; + ensure("another sender stays distinct for local time", !sameDirectHistoryOccurrence(history, echo)); + echo["from_id"] = AGENT; + echo["timestamp"] = 1789005965; + ensure("local time in another minute stays distinct", !sameDirectHistoryOccurrence(history, echo)); + + // Supplied timestamps use exact seconds even for the current agent's messages. + echo["timestamp"] = 1789005906; + echo["chat_service_time_source"] = "sent"; + ensure("supplied outgoing timestamp requires exact seconds", !sameDirectHistoryOccurrence(history, echo)); + echo["timestamp"] = 1789005905; + ensure("supplied outgoing timestamp matches its service second", sameDirectHistoryOccurrence(history, echo)); + echo["chat_service_time_source"] = "local"; + history["timestamp"] = 0; + ensure("local provenance does not make an untimed service row match", !sameDirectHistoryOccurrence(history, echo)); +} + +// Repeated incoming bodies share a plaintext minute but have distinct supplied seconds. +LLSD repeatedDelivery(S32 second) +{ + LLSD message; + message["from_id"] = RESIDENT; + message["from"] = "Example Resident"; + message["message"] = "ok"; + message["timestamp"] = 1789005900 + second; + message["chat_service_log_timestamp"] = message["timestamp"]; + return message; +} + +LLSD serviceCopy(const LLSD& delivery, const std::string& id) +{ + LLSD message = delivery; + message["chat_service_msg_id"] = id; + return message; +} + +LLSD plaintextCopy(const LLSD& delivery) +{ + LLSD message; + message["from"] = delivery["from"]; + message["message"] = delivery["message"]; + message["chat_service_legacy_wall_time"] = + F64(delivery["timestamp"].asInteger() / 60 * 60) - 7.0 * 3600.0; + return message; +} + +template<> template<> void object_t::test<21>() +{ + const LLSD earlier = repeatedDelivery(5); + const LLSD later = repeatedDelivery(35); + const LLSD legacy = plaintextCopy(earlier); + const LLSD service = serviceCopy(later, FIRST); + + // Partial coverage leaves plaintext before the only service row, which must + // reserve the :35 delivery before the plaintext minute can consume it. + for (bool history_reversed : {false, true}) + { + for (bool live_reversed : {false, true}) + { + std::list<LLSD> history{legacy, service}; + std::list<LLSD> live{later, earlier}; + if (history_reversed) history.reverse(); + if (live_reversed) live.reverse(); + ensure("partial service coverage reconciles both deliveries in either order", + filterDirectHistoryDuplicates(history, live).empty()); + } + } +} + +template<> template<> void object_t::test<22>() +{ + const LLSD earlier = repeatedDelivery(5); + const LLSD later = repeatedDelivery(35); + const LLSD legacy = plaintextCopy(earlier); + LLSD keep_before = legacy; + keep_before["message"] = "before"; + LLSD keep_after = legacy; + keep_after["message"] = "after"; + const std::list<LLSD> history{legacy, keep_before, serviceCopy(later, FIRST), legacy, keep_after}; + const std::list<LLSD> live{later, earlier}; + + // One exact reservation leaves only one occurrence for plaintext. Extra + // identical history and unrelated rows remain in their original positions. + const auto filtered = filterDirectHistoryDuplicates(history, live); + ensure_equals("each live occurrence is consumed once", filtered.size(), size_t(3)); + auto row = filtered.begin(); + ensure_equals("first unmatched row keeps its position", (*row++)["message"].asString(), "before"); + ensure("extra repeated occurrence retains its plaintext source", !row->has("chat_service_msg_id")); + ensure_equals("extra repeated occurrence remains", (*row++)["message"].asString(), "ok"); + ensure_equals("last unmatched row keeps its position", (*row++)["message"].asString(), "after"); + ensure_equals("history input is unchanged", history.size(), size_t(5)); + ensure_equals("live input is unchanged", live.size(), size_t(2)); + ensure_equals("no live rows leaves history intact", filterDirectHistoryDuplicates(history, {}).size(), history.size()); + ensure("no history leaves an empty result", filterDirectHistoryDuplicates({}, live).empty()); +} + +template<> template<> void object_t::test<23>() +{ + const LLSD supplied = repeatedDelivery(5); + LLSD local = repeatedDelivery(34); + local["chat_service_time_source"] = "local"; + const LLSD service_earlier = serviceCopy(supplied, FIRST); + const LLSD service_later = serviceCopy(repeatedDelivery(35), SECOND); + + // A local-minute fallback must not take an exact supplied match's place, + // regardless of the order of the service rows or live deliveries. + for (bool history_reversed : {false, true}) + { + for (bool live_reversed : {false, true}) + { + std::list<LLSD> history{service_earlier, service_later}; + std::list<LLSD> live{local, supplied}; + if (history_reversed) history.reverse(); + if (live_reversed) live.reverse(); + ensure("exact service matches precede local-minute fallback", + filterDirectHistoryDuplicates(history, live).empty()); + } + } +} + +template<> template<> void object_t::test<24>() +{ + LLSD earlier = repeatedDelivery(5); + LLSD later = repeatedDelivery(35); + const LLSD service_earlier = serviceCopy(earlier, FIRST); + const LLSD service_later = serviceCopy(later, SECOND); + for (LLSD* delivery : {&earlier, &later}) + { + (*delivery)["chat_service_original_text"] = "ok"; + (*delivery)["message"] = "ok (d'accord)"; + } + const LLSD legacy = plaintextCopy(earlier); + std::list<LLSD> history{legacy, service_later, legacy, service_earlier}; + const std::list<LLSD> live{later, earlier}; + + // Translated or saved delivery can cover both its decorated plaintext and + // raw service representation, with separate one-for-one consumption. + ensure("decorated plaintext and raw service copies both reconcile", + filterDirectHistoryDuplicates(history, live).empty()); + history.push_front(legacy); + history.push_back(service_earlier); + const auto filtered = filterDirectHistoryDuplicates(history, live); + ensure_equals("extra occurrences in each representation remain", filtered.size(), size_t(2)); + ensure("extra decorated plaintext retains its source", !filtered.front().has("chat_service_msg_id")); + ensure_equals("extra service retains its source", filtered.back()["chat_service_msg_id"].asString(), std::string(FIRST)); +} + +template<> template<> void object_t::test<25>() +{ + // Service creation can precede online delivery across a second boundary. + LLSD history = serviceCopy(repeatedDelivery(0), FIRST); + history["message"] = "123"; + history["datetime"] = "2026-09-10T17:17:00.904000Z"; + history["timestamp"] = S32(LLDate(history["datetime"].asString()).secondsSinceEpoch()); + LLSD delivered = history; + delivered.erase("chat_service_msg_id"); + delivered["timestamp"] = 1789060621; + delivered["chat_service_time_source"] = "receipt"; + ensure("online delivery reconciles the preceding service second", + filterDirectHistoryDuplicates({history}, {delivered}).empty()); + + // Only online delivery admits this delay; offline send times retain their precision. + delivered["chat_service_time_source"] = "sent"; + ensure("offline send time stays exact", !sameDirectHistoryOccurrence(history, delivered)); + delivered["timestamp"] = 1789060619; + ensure("offline send time rejects later service recording", !sameDirectHistoryOccurrence(history, delivered)); + delivered["chat_service_time_source"] = "receipt"; + ensure("online receipt can precede service recording", sameDirectHistoryOccurrence(history, delivered)); + delivered["timestamp"] = 1789060681; + ensure("delivery outside the rolling window stays distinct", !sameDirectHistoryOccurrence(history, delivered)); + + // Elapsed delay crosses minute boundaries, with exact sender and body checks. + history["timestamp"] = 1789060619; + delivered["timestamp"] = 1789060620; + ensure("delivery delay crosses a minute boundary", sameDirectHistoryOccurrence(history, delivered)); + delivered["from_id"] = AGENT; + ensure("delivery tolerance retains sender identity", !sameDirectHistoryOccurrence(history, delivered)); + delivered["from_id"] = RESIDENT; + delivered["message"] = "1234"; + ensure("delivery tolerance retains body identity", !sameDirectHistoryOccurrence(history, delivered)); +} + +template<> template<> void object_t::test<26>() +{ + const LLSD earlier = serviceCopy(repeatedDelivery(0), FIRST); + const LLSD later = serviceCopy(repeatedDelivery(1), SECOND); + LLSD delivered = repeatedDelivery(1); + delivered["chat_service_time_source"] = "receipt"; + + // Prefer the most recent eligible occurrence when a receipt can match repeated + // sends, preserving the earlier occurrence in history. + for (bool reversed : {false, true}) + { + std::list<LLSD> history{earlier, later}; + if (reversed) history.reverse(); + const auto filtered = filterDirectHistoryDuplicates(history, {delivered}); + ensure_equals("one live delivery consumes one service occurrence", filtered.size(), size_t(1)); + ensure_equals("the earlier repeated send survives", filtered.front()["chat_service_msg_id"].asString(), std::string(FIRST)); + } +} + +template<> template<> void object_t::test<27>() +{ + const LLSD earlier = serviceCopy(repeatedDelivery(0), FIRST); + const LLSD later = serviceCopy(repeatedDelivery(1), SECOND); + LLSD first_delivery = repeatedDelivery(1); + first_delivery["chat_service_time_source"] = "receipt"; + LLSD second_delivery = first_delivery; + + // Two deliveries in one second remain separate occurrences even when one + // service creation crossed the boundary. + for (bool reversed : {false, true}) + { + std::list<LLSD> history{earlier, later}; + if (reversed) history.reverse(); + ensure("both adjacent service occurrences reconcile one-for-one", + filterDirectHistoryDuplicates(history, {first_delivery, second_delivery}).empty()); + } + + // Delivery matches reserve their occurrence before local clocks and plaintext minutes. + LLSD local = repeatedDelivery(0); + local["chat_service_time_source"] = "local"; + const LLSD much_later = serviceCopy(repeatedDelivery(35), SECOND); + ensure("delivery matching precedes local-minute matching", + filterDirectHistoryDuplicates({earlier, much_later}, {local, first_delivery}).empty()); + const LLSD legacy = plaintextCopy(first_delivery); + ensure("delivery matching precedes plaintext matching", + filterDirectHistoryDuplicates({legacy, earlier}, {first_delivery, repeatedDelivery(35)}).empty()); +} + +template<> template<> void object_t::test<28>() +{ + // Service lead and delivery delay use elapsed seconds across minute, hour, + // and day boundaries instead of comparing calendar minutes. + for (const char* created : {"2026-09-10T12:34:01Z", "2026-09-10T12:59:59Z", + "2026-09-10T13:00:01Z", "2026-09-10T23:59:59Z", + "2026-09-11T00:00:01Z"}) + { + LLSD history = serviceCopy(repeatedDelivery(0), FIRST); + const S32 timestamp = S32(LLDate(created).secondsSinceEpoch()); + history["timestamp"] = timestamp; + LLSD delivered = repeatedDelivery(0); + delivered["chat_service_time_source"] = "receipt"; + for (S32 delay : {-5, -4, -2, -1, 0, 2, 5, 30, 60}) + { + delivered["timestamp"] = timestamp + delay; + ensure("receipt inside the rolling window reconciles", + filterDirectHistoryDuplicates({history}, {delivered}).empty()); + } + for (S32 delay : {-6, 61}) + { + delivered["timestamp"] = timestamp + delay; + ensure_equals("receipt outside the rolling window remains", + filterDirectHistoryDuplicates({history}, {delivered}).size(), size_t(1)); + } + } +} + +template<> template<> void object_t::test<29>() +{ + // Both sides can contain repeated bodies with different delays or service + // lead. Newest-first pairing keeps a newer receipt from consuming an older + // receipt's only candidate, even when their timestamps match exactly. + for (const auto times : {std::array<S32, 4>{0, 15, 5, 30}, {0, 15, 30, 75}, + {0, 10, 10, 65}, {65, 70, 60, 65}, {0, 6, -4, 2}}) + { + const LLSD first = serviceCopy(repeatedDelivery(times[0]), FIRST); + const LLSD second = serviceCopy(repeatedDelivery(times[1]), SECOND); + LLSD first_delivery = repeatedDelivery(times[2]); + LLSD second_delivery = repeatedDelivery(times[3]); + first_delivery["chat_service_time_source"] = "receipt"; + second_delivery["chat_service_time_source"] = "receipt"; + for (bool history_reversed : {false, true}) + { + for (bool live_reversed : {false, true}) + { + std::list<LLSD> history{first, second}; + std::list<LLSD> live{first_delivery, second_delivery}; + if (history_reversed) history.reverse(); + if (live_reversed) live.reverse(); + ensure("delayed repeats reconcile independently of source order", + filterDirectHistoryDuplicates(history, live).empty()); + } + } + } +} + +// Stable service IDs let the view retain context without guessing plaintext identity. +LLSD contextRow(U8 ordinal) +{ + LLUUID id(FIRST); + id.mData[3] = ordinal; + LLSD row = serviceCopy(repeatedDelivery(ordinal * 10), id.asString().c_str()); + row["message"] = std::to_string(ordinal); + return row; +} + +LLSD liveCopy(LLSD row) +{ + row.erase("chat_service_msg_id"); + row["is_history"] = false; + return row; +} + +template<> template<> void object_t::test<30>() +{ + const LLSD first = serviceCopy(repeatedDelivery(5), FIRST); + const LLSD second = serviceCopy(repeatedDelivery(35), SECOND); + const LLSD legacy = plaintextCopy(first); + History history; + history.setLoaded(mergeDirectHistory({legacy, legacy, legacy}, {first})); + + // Reapplying a preview must not consume the next identical plaintext occurrence. + const Messages expected{legacy, first, second}; + for (int publication = 0; publication < 3; ++publication) + { + const auto result = history.compose({second, first}, {}, 10); + ensure_equals("preview keeps all three occurrences", result.size(), expected.size()); + ensure("preview is idempotent and chronological", + std::equal(result.begin(), result.end(), expected.begin(), + [](const LLSD& a, const LLSD& b) { return llsd_equals(a, b); })); + } + + // Canonical ID identity wins over a stale preview, and limits follow reconciliation. + LLSD stale = first; + stale["message"] = "stale preview"; + const auto limited = history.compose({second, stale, second}, {}, 2); + ensure_equals("duplicate IDs do not evict other history", limited.size(), size_t(2)); + ensure_equals("canonical value wins", limited.front()["message"].asString(), std::string("ok")); + ensure_equals("service tail stays in TimeUUID order", limited.back()["chat_service_msg_id"].asString(), std::string(SECOND)); +} + +template<> template<> void object_t::test<31>() +{ + const Messages initial{contextRow(1), contextRow(2), contextRow(3)}; + const Messages bounded{contextRow(2), contextRow(3), contextRow(4)}; + const LLSD delivered = liveCopy(contextRow(4)); + History history; + history.setLoaded(initial); + history.compose({}, {}, 3, true); + history.setLoaded(bounded); + auto result = history.compose({}, {delivered}, 3, true); + ensure_equals("live copy does not evict older visible context", result.size(), size_t(3)); + ensure_equals("oldest context survives rollover", result.front()["message"].asString(), std::string("1")); + + // A late delivery may replace any retained row, including the newest one. + for (U8 ordinal : {2, 3}) + { + history.clear(); + history.setLoaded(initial); + history.compose({}, {}, 3, true); + history.setLoaded(bounded); + history.compose({}, {delivered}, 3, true); + for (int publication = 0; publication < 3; ++publication) + { + result = history.compose({}, {liveCopy(contextRow(ordinal)), delivered}, 3, true); + ensure_equals("late live delivery replaces one retained occurrence", result.size(), size_t(2)); + ensure_equals("unrelated context remains", result.front()["message"].asString(), std::string("1")); + } + } + + history.clear(); + history.setLoaded(initial); + history.compose({}, {}, 3, true); + history.setLoaded({contextRow(4), contextRow(5), contextRow(6)}); + const Messages live{liveCopy(contextRow(6)), liveCopy(contextRow(5)), delivered}; + ensure_equals("an all-live source retains open history", history.compose({}, live, 3, true).size(), size_t(3)); + ensure_equals("repeated all-live publication is stable", history.compose({}, live, 3, true).size(), size_t(3)); + ensure("hidden view releases old context", history.compose({}, live, 3).empty()); + history.setLoaded(initial); + history.compose({}, {}, 3, true); + history.setLoaded({}); + ensure("empty load clears retained context", history.compose({}, {}, 3, true).empty()); + + // Revoking service consent leaves plaintext, while deletion clears both sources. + const LLSD legacy = plaintextCopy(repeatedDelivery(5)); + history.setLoaded({legacy, contextRow(1)}); + history.compose({}, {}, 3, true); + history.clearService(); + ensure_equals("service revocation keeps legacy", history.compose({}, {}, 3, true).size(), size_t(1)); + history.clear(); + ensure("clear removes every historical source", history.compose({}, {}, 3, true).empty()); +} + +template<> template<> void object_t::test<32>() +{ + LLSD offer; + offer["notification_id"] = AGENT; + offer["timestamp"] = 0; + offer["is_history"] = false; + LLSD first = liveCopy(contextRow(4)); + LLSD second = first; + first["message"] = "test"; + second["message"] = "test"; + second["timestamp"] = first["timestamp"].asInteger() + 1; + Messages current{second, first, offer}; + ensure("initial history changes the model", replaceHistory(current, {contextRow(1)}, true)); + ensure_equals("two live tests and the offer survive", current.size(), size_t(4)); + auto row = current.begin(); + ensure_equals("latest test stays live", (*row++)["message"].asString(), std::string("test")); + ensure_equals("first test stays live", (*row++)["message"].asString(), std::string("test")); + ensure_equals("inline offer identity survives", (*row)["notification_id"].asUUID(), AGENT); + ensure("identical publication avoids replay", !replaceHistory(current, {contextRow(1)}, true)); + ensure("clearing history changes the model", replaceHistory(current, {}, true)); + ensure_equals("clearing history retains both live sends and offer", current.size(), size_t(3)); + S32 index = 3; + for (const LLSD& message : current) + { + ensure_equals("indexes remain contiguous", message["index"].asInteger(), --index); + } +} + +template<> template<> void object_t::test<33>() +{ + constexpr U32 received = 1789060621; + for (bool supplied : {false, true}) + { + U32 timestamp = supplied ? received : 0; + LLSD captured = captureLiveMessage("123", timestamp, incomingContext("123", true), received); + ensure_equals("supplied and synthesized receipt times agree", timestamp, received); + captured = captureLiveMessage("123 (translated)", timestamp, captured, received + 90); + LLSD row = repeatedDelivery(0); + row["timestamp"] = static_cast<S32>(timestamp); + row["message"] = "123 (translated)"; + recordLiveMessage(row, captured, received + 90); + ensure_equals("translation preserves original body", row["chat_service_original_text"].asString(), std::string("123")); + ensure_equals("translation preserves receipt provenance", row["chat_service_time_source"].asString(), std::string("receipt")); + ensure_equals("logging time follows translation completion", row["chat_service_log_timestamp"].asInteger(), S32(received + 90)); + LLSD service = serviceCopy(repeatedDelivery(0), FIRST); + service["message"] = "123"; + service["timestamp"] = S32(received - 1); + ensure("translation delay does not affect service matching", sameDirectHistoryOccurrence(service, row)); + service["timestamp"] = S32(received + 1); + ensure("later service recording matches the captured receipt", sameDirectHistoryOccurrence(service, row)); + } + + U32 timestamp = received - 86400; + LLSD offline = captureLiveMessage("(Saved yesterday)123", timestamp, incomingContext("123", false), received); + ensure_equals("offline delivery keeps the send time", timestamp, received - 86400); + ensure_equals("offline time is authoritative", offline["chat_service_time_source"].asString(), std::string("sent")); + timestamp = 0; + offline = captureLiveMessage("(Saved)123", timestamp, incomingContext("123", false), received); + ensure_equals("missing send time cannot become authoritative", offline["chat_service_time_source"].asString(), std::string("local")); + timestamp = 0; + LLSD echo = captureLiveMessage("123", timestamp, LLSD(), received); + ensure_equals("outgoing echo captures its local clock", echo["chat_service_time_source"].asString(), std::string("local")); + ensure_equals("outgoing echo captures before translation", timestamp, received); +} + +template<> template<> void object_t::test<34>() +{ + const LLSD old = contextRow(1); + const LLSD legacy = plaintextCopy(old); + const LLSD live = liveCopy(contextRow(4)); + History history; + history.setLoaded({legacy, contextRow(2), contextRow(3)}); + history.compose({}, {}, 3, true); + history.setLoaded({contextRow(2), contextRow(3), contextRow(4)}); + for (int publication = 0; publication < 3; ++publication) + { + const auto result = history.compose({}, {live}, 3, true); + ensure_equals("capped service rollover retains older plaintext", result.size(), size_t(3)); + ensure("retained plaintext keeps its source", !result.front().has("chat_service_msg_id")); + } + history.setLoaded({old, contextRow(2), contextRow(3)}); + auto result = history.compose({}, {live}, 3, true); + ensure_equals("new archive row replaces retained plaintext", result.size(), size_t(3)); + ensure_equals("replaced plaintext acquires the canonical ID", result.front()["chat_service_msg_id"].asString(), old["chat_service_msg_id"].asString()); + + // Each window may already have consumed a different member of repeated plaintext. + const LLSD first = serviceCopy(repeatedDelivery(5), FIRST); + const LLSD second = serviceCopy(repeatedDelivery(35), SECOND); + const LLSD repeat = plaintextCopy(first); + const Messages left = mergeDirectHistory({repeat, repeat, repeat}, {first}); + const Messages right = mergeDirectHistory({repeat, repeat, repeat}, {second}); + result = mergeDirectHistory(left, right); + ensure_equals("overlapping windows preserve three original occurrences", result.size(), size_t(3)); + ensure_equals("repeated seam publication is stable", mergeDirectHistory(result, right).size(), size_t(3)); + ensure_equals("source window order preserves counts", mergeDirectHistory(right, left).size(), size_t(3)); +} + +template<> template<> void object_t::test<35>() +{ + // Recreating a session leaves only the latest delivery live. An offline repeat + // within its receipt window must retain its earlier place in the transcript. + LLSD earlier = contextRow(1); + earlier["message"] = "hello"; + earlier["timestamp"] = 1789092403; + LLSD between = contextRow(2); + between["message"] = "6"; + between["timestamp"] = 1789092414; + LLSD later = contextRow(3); + later["message"] = "hello"; + later["timestamp"] = 1789092446; + const Messages archive{earlier, between, later}; + + for (S32 delay : {-5, -1, 0, 2, 15}) + { + History history; + history.setLoaded({earlier, between}); + LLSD delivery = liveCopy(later); + delivery["chat_service_time_source"] = "receipt"; + delivery["timestamp"] = later["timestamp"].asInteger() + delay; + Messages current{delivery}; + + // The archive read can complete before the latest service head arrives. + replaceHistory(current, history.compose({}, {delivery}, 10, true), true); + for (int publication = 0; publication < 3; ++publication) + { + const auto composed = history.compose(archive, {delivery}, 10, true); + ensure_equals("one delivery replaces only one historical occurrence", composed.size(), size_t(2)); + ensure_equals("the earlier hello retains its identity and position", + composed.front()["chat_service_msg_id"].asString(), earlier["chat_service_msg_id"].asString()); + const bool changed = replaceHistory(current, composed, true); + ensure_equals("unchanged publications do not replay the transcript", changed, publication == 0); + ensure_equals("both hellos and the intervening message remain", current.size(), size_t(3)); + } + + // Reopening with all rows historical preserves the same message order. + Messages reopened; + replaceHistory(reopened, archive, true); + auto expected = reopened.begin(); + for (const LLSD& row : current) + { + ensure_equals("reopening preserves message order", row["message"].asString(), (*expected++)["message"].asString()); + } + } +} + +template<> template<> void object_t::test<36>() +{ + // System traffic may share a discovery response with ordinary resident history. + LLSD peer; + peer["conversation_type"] = "direct"; + peer["conversation_id"] = directConversationId(AGENT, RESIDENT); + peer["other_participant_id"] = RESIDENT.asString(); + peer["last_msg_id"] = SECOND; + LLSD system = peer; + system["conversation_id"] = directConversationId(AGENT, LLUUID::null); + system["other_participant_id"] = LLUUID::null.asString(); + + for (bool system_first : {true, false}) + { + LLSD list = LLSD::emptyArray(); + list.append(system_first ? system : peer); + list.append(system_first ? peer : system); + std::vector<ListEntry> entries; + ensure("system entry does not block resident discovery", + validateConversationList(list, AGENT, entries)); + ensure_equals("only the resident is scheduled", entries.size(), size_t(1)); + ensure_equals("resident identity preserved", entries.front().resident_id, RESIDENT); + ensure_equals("history token preserved", entries.front().last_msg_id, std::string(SECOND)); + } +} + +template<> template<> void object_t::test<37>() +{ + // Missing online wire timestamps are captured before the corresponding + // service rows are recorded. The two representations describe one reply. + Messages archive; + Messages live; + U8 ordinal = 0; + for (const char* body : {"30", "thnk you"}) + { + const U32 received = ordinal ? 1789489070 : 1789489012; + LLSD service = contextRow(++ordinal); + service["message"] = body; + service["timestamp"] = static_cast<S32>(received + 1); + archive.push_back(service); + + U32 timestamp = 0; + const LLSD context = captureLiveMessage(body, timestamp, incomingContext(body, true), received); + LLSD delivery = liveCopy(service); + delivery["timestamp"] = static_cast<S32>(timestamp); + recordLiveMessage(delivery, context, received); + live.push_front(delivery); + } + + // Preview and durable reads can arrive in either order. Repeated publication + // must keep both live replies without adding historical copies or replaying them. + for (bool loaded_first : {false, true}) + { + History history; + if (loaded_first) history.setLoaded(archive); + Messages current = live; + for (int publication = 0; publication < 3; ++publication) + { + const auto composed = history.compose(archive, live, 200, true); + ensure("recording after receipt does not add history copies", composed.empty()); + ensure("unchanged live replies do not need replay", !replaceHistory(current, composed, true)); + ensure_equals("both replies remain visible once", current.size(), size_t(2)); + history.setLoaded(archive); + } + + const auto durable = history.compose({}, live, 200, true); + ensure("durable read reconciles after preview expires", durable.empty()); + ensure("durable publication does not replay replies", !replaceHistory(current, durable, true)); + ensure_equals("newest reply retains delivery order", current.front()["message"].asString(), std::string("thnk you")); + ensure_equals("earliest reply retains delivery order", current.back()["message"].asString(), std::string("30")); + ensure("newest reply stays live", !current.front()["is_history"].asBoolean()); + ensure("earliest reply stays live", !current.back()["is_history"].asBoolean()); + } +} + +template<> template<> void object_t::test<38>() +{ + // With only the first delivery live, a later identical service message is a + // separate occurrence. Preserve its ID and the intervening message's position. + LLSD earlier = contextRow(1); + earlier["message"] = "hello"; + LLSD between = contextRow(2); + between["message"] = "6"; + between["timestamp"] = earlier["timestamp"].asInteger() + 15; + LLSD later = contextRow(3); + later["message"] = "hello"; + later["timestamp"] = earlier["timestamp"].asInteger() + 30; + LLSD delivery = liveCopy(earlier); + delivery["chat_service_time_source"] = "receipt"; + History history; + history.setLoaded({earlier, between, later}); + + const auto composed = history.compose({}, {delivery}, 200, true); + ensure_equals("one receipt consumes one occurrence", composed.size(), size_t(2)); + ensure_equals("later repeat keeps its service ID", composed.back()["chat_service_msg_id"].asString(), + later["chat_service_msg_id"].asString()); + Messages current{delivery}; + ensure("unseen service messages enter the transcript", replaceHistory(current, composed, true)); + ensure_equals("three real occurrences remain", current.size(), size_t(3)); + auto row = current.begin(); + ensure_equals("later repeat follows intervening message", (*row++)["chat_service_msg_id"].asString(), + later["chat_service_msg_id"].asString()); + ensure_equals("intervening message keeps its position", (*row++)["message"].asString(), std::string("6")); + ensure("earliest occurrence keeps its live delivery", !(*row)["is_history"].asBoolean()); +} + +} diff --git a/indra/newview/tests/llconversationlog_test.cpp b/indra/newview/tests/llconversationlog_test.cpp new file mode 100644 index 0000000000..aa53c312e7 --- /dev/null +++ b/indra/newview/tests/llconversationlog_test.cpp @@ -0,0 +1,268 @@ +/** + * @file llconversationlog_test.cpp + * @brief Conversation Log discovery tests. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * $/LicenseInfo$ + */ +#include "../llviewerprecompiledheaders.h" +#include "llsingleton.h" +#include "llcontrol.h" +#include "../test/lltut.h" +#include "../llconversationlog.h" + +LLControlGroup gSavedSettings("test settings"); +LLControlGroup gSavedPerAccountSettings("test per-account settings"); +LLUUID gAgentID("00000000-0000-0000-0000-000000000001"); + +// The fixture needs only the no-session branches of the viewer services reached +// by LLConversationLog; these doubles keep that boundary isolated. +LLIMModel::LLIMModel() +{ +} + +LLIMModel::LLIMSession* LLIMModel::findIMSession(const LLUUID&) const +{ + return NULL; +} + +bool LLIMModel::LLIMSession::isOutgoingAdHoc() const +{ + return false; +} + +LLUUID LLIMModel::LLIMSession::generateOutgoingAdHocHash() const +{ + return mSessionID; +} + +LLIMMgr::LLIMMgr() +{ +} + +void LLIMMgr::addSessionObserver(LLIMSessionObserver*) +{ +} + +void LLIMMgr::removeSessionObserver(LLIMSessionObserver*) +{ +} + +LLAvatarTracker LLAvatarTracker::sInstance; + +LLAvatarTracker::LLAvatarTracker() +: mTrackingData(NULL), + mTrackedAgentValid(false), + mModifyMask(0), + mIsNotifyObservers(false) +{ +} + +LLAvatarTracker::~LLAvatarTracker() +{ +} + +void LLAvatarTracker::addObserver(LLFriendObserver*) +{ +} + +void LLAvatarTracker::removeObserver(LLFriendObserver*) +{ +} + +void LLLogChat::renameLogFile(const std::string&, const std::string&) +{ +} + +LLFloaterIMSession* LLFloaterIMSession::findInstance(const LLUUID&) +{ + return NULL; +} + +boost::signals2::connection LLFloaterIMSession::setIMFloaterShowedCallback( + const floater_showed_signal_t::slot_type&) +{ + return boost::signals2::connection(); +} + +// Keep private access to fixture state; production initialization binds global services. +struct LLConversationLogTestAccess +{ + static void reset(bool logging_enabled) + { + LLConversationLog& log = LLConversationLog::instance(); + log.mConversations.clear(); + log.mLoggingEnabled = logging_enabled; + } + + static void setOfflineMessages(const LLUUID& session_id, bool has_offline_messages) + { + LLConversationLog& log = LLConversationLog::instance(); + for (LLConversation& conversation : log.mConversations) + { + if (conversation.getSessionID() == session_id) + { + conversation.setOfflineMessages(has_offline_messages); + return; + } + } + } +}; + +// Capture both observer contracts so timestamp updates stay targeted to one row. +struct ConversationLogObserver : public LLConversationLogObserver +{ + void changed() override + { + ++mRefreshes; + } + + void changed(const LLUUID& session_id, U32 mask) override + { + ++mParticularChanges; + mSessionID = session_id; + mMask = mask; + } + + S32 mRefreshes = 0; + S32 mParticularChanges = 0; + LLUUID mSessionID; + U32 mMask = 0; +}; + +namespace tut +{ +struct conversation_log +{ + conversation_log() + { + // A different device starts without locally created conversation metadata. + LLConversationLogTestAccess::reset(true); + } + + ~conversation_log() + { + LLConversationLogTestAccess::reset(false); + } +}; + +typedef test_group<conversation_log> group_t; +typedef group_t::object object_t; +group_t group("LLConversationLog"); + +template<> template<> void object_t::test<1>() +{ + const LLUUID resident_id("00000000-0000-0000-0000-000000000002"); + const LLUUID session_id = gAgentID ^ resident_id; + const U64Seconds archived_time(LLUnits::Seconds::fromValue(1787756400.0)); + + // A durable service archive has resident metadata but no live IM session. + LLConversationLog& log = LLConversationLog::instance(); + log.addServiceConversation( + session_id, + "Bridie Linden", + "bridie.linden", + resident_id, + archived_time); + + // Repeated archive publication must not duplicate the Conversation Log row. + log.addServiceConversation( + session_id, + "Bridie Linden", + "bridie.linden", + resident_id, + archived_time); + + const LLConversation* discovered = log.getConversation(session_id); + ensure("remote direct conversation is discoverable", discovered != NULL); + ensure_equals("remote conversation is unique", log.getConversations().size(), size_t(1)); + ensure("remote conversation is P2P", + discovered->getConversationType() == LLIMModel::LLIMSession::P2P_SESSION); + ensure("remote archive time retained", discovered->getTime() == archived_time); + ensure_equals("remote participant retained", discovered->getParticipantID(), resident_id); + ensure_equals("remote name retained", discovered->getConversationName(), + std::string("Bridie Linden")); + ensure_equals("remote transcript stem retained", discovered->getHistoryFileName(), + std::string("bridie.linden")); +} + +template<> template<> void object_t::test<2>() +{ + const LLUUID resident_id("00000000-0000-0000-0000-000000000002"); + const LLUUID changed_resident_id("00000000-0000-0000-0000-000000000003"); + const LLUUID session_id = gAgentID ^ resident_id; + const U64Seconds original_time(LLUnits::Seconds::fromValue(1787756400.0)); + const U64Seconds newer_time(LLUnits::Seconds::fromValue(1787929200.0)); + const U64Seconds older_time(LLUnits::Seconds::fromValue(1787666400.0)); + + LLConversationLog& log = LLConversationLog::instance(); + log.addServiceConversation( + session_id, + "Bridie Linden", + "bridie.linden", + resident_id, + original_time); + LLConversationLogTestAccess::setOfflineMessages(session_id, true); + + // A newer archive advances only the timestamp. Equal and older publications + // remain silent so stale service data cannot regress or duplicate the row. + ConversationLogObserver observer; + log.addObserver(&observer); + log.addServiceConversation( + session_id, + "Changed Name", + "changed.filename", + changed_resident_id, + newer_time); + log.addServiceConversation( + session_id, + "Changed Again", + "changed.again", + changed_resident_id, + newer_time); + log.addServiceConversation( + session_id, + "Older Name", + "older.filename", + changed_resident_id, + older_time); + log.removeObserver(&observer); + + const LLConversation* updated = log.getConversation(session_id); + ensure("updated conversation remains present", updated != NULL); + ensure_equals("updated conversation remains unique", log.getConversations().size(), size_t(1)); + ensure("newer archive time applied", updated->getTime() == newer_time); + ensure_equals("formatted archive time refreshed", updated->getTimestamp(), + LLConversation::createTimestamp(newer_time)); + ensure_equals("existing participant preserved", updated->getParticipantID(), resident_id); + ensure_equals("existing name preserved", updated->getConversationName(), + std::string("Bridie Linden")); + ensure_equals("existing transcript stem preserved", updated->getHistoryFileName(), + std::string("bridie.linden")); + ensure("existing offline state preserved", updated->hasOfflineMessages()); + ensure_equals("timestamp update avoids full refresh", observer.mRefreshes, S32(0)); + ensure_equals("only newer time notifies", observer.mParticularChanges, S32(1)); + ensure_equals("timestamp update identifies session", observer.mSessionID, session_id); + ensure_equals("timestamp update identifies change", observer.mMask, + U32(LLConversationLogObserver::CHANGED_TIME)); +} + +template<> template<> void object_t::test<3>() +{ + LLConversationLogTestAccess::reset(false); + + // Service history must not create metadata when Conversation Log privacy is disabled. + const LLUUID resident_id("00000000-0000-0000-0000-000000000002"); + LLConversationLog& log = LLConversationLog::instance(); + log.addServiceConversation( + gAgentID ^ resident_id, + "Bridie Linden", + "bridie.linden", + resident_id, + U64Seconds(LLUnits::Seconds::fromValue(1787756400.0))); + + ensure("disabled Conversation Log remains empty", log.isLogEmpty()); +} +} |
