From ed9fe9c5635237c4d3a36ca99991df2a325fcf9e Mon Sep 17 00:00:00 2001 From: Christophe Deschamps Date: Fri, 23 Jan 2026 15:32:31 +0100 Subject: [PATCH 1/2] Generate ICS manually for calendar export with user friendly information --- .../core/conference/ConferenceInfoCore.cpp | 186 +++++++++++++++++- .../core/conference/ConferenceInfoCore.hpp | 2 +- 2 files changed, 177 insertions(+), 11 deletions(-) diff --git a/Linphone/core/conference/ConferenceInfoCore.cpp b/Linphone/core/conference/ConferenceInfoCore.cpp index 676faac46..865bf7237 100644 --- a/Linphone/core/conference/ConferenceInfoCore.cpp +++ b/Linphone/core/conference/ConferenceInfoCore.cpp @@ -29,6 +29,7 @@ #include "tool/thread/SafeConnection.hpp" #include +#include DEFINE_ABSTRACT_OBJECT(ConferenceInfoCore) @@ -201,8 +202,7 @@ void ConferenceInfoCore::setSelf(QSharedPointer me) { if (state == linphone::ConferenceScheduler::State::Ready) { uri = mConferenceInfoModel->getConferenceScheduler()->getUri(); emit CoreModel::getInstance() -> conferenceInfoReceived( - CoreModel::getInstance()->getCore(), - mConferenceInfoModel->getConferenceInfo()); + CoreModel::getInstance()->getCore(), mConferenceInfoModel->getConferenceInfo()); } mConfInfoModelConnection->invokeToCore([this, state = LinphoneEnums::fromLinphone(state), infoState = LinphoneEnums::fromLinphone(confInfoState), @@ -659,13 +659,179 @@ bool ConferenceInfoCore::isAllDayConf() const { mEndDateTime.time().minute() == 59; } -void ConferenceInfoCore::exportConferenceToICS() const { - QString filePath(Paths::getAppLocalDirPath() + "conference.ics"); - QFile file(filePath); - if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { - QTextStream out(&file); - out << mIcalendarString; - file.close(); +void ConferenceInfoCore::exportConferenceToICS() { + // Collect participant addresses to look up display names + QStringList participantAddresses; + for (const auto &participant : mParticipants) { + auto map = participant.toMap(); + QString address = map["address"].toString(); + if (!address.isEmpty()) { + participantAddresses.append(address); + } } - QDesktopServices::openUrl(QUrl::fromLocalFile(filePath)); + + // Copy data needed for ICS generation + QString uri = mUri; + QString organizerAddress = mOrganizerAddress; + QString organizerName = mOrganizerName; + QString subject = mSubject; + QString description = mDescription; + QDateTime dateTime = mDateTime; + QDateTime endDateTime = mEndDateTime; + + // Look up display names from friend list in model thread + App::postModelAsync( + [participantAddresses, uri, organizerAddress, organizerName, subject, description, dateTime, endDateTime]() { + // Helper to extract phone number from SIP address + auto extractPhoneNumber = [](const QString &address) -> QString { + QString addr = address; + if (addr.startsWith("sip:", Qt::CaseInsensitive)) { + addr = addr.mid(4); + } else if (addr.startsWith("sips:", Qt::CaseInsensitive)) { + addr = addr.mid(5); + } + int atIndex = addr.indexOf('@'); + if (atIndex > 0) { + return addr.left(atIndex); + } + return addr; + }; + + // Build map of address -> display name + QMap displayNames; + auto appFriendList = ToolModel::getAppFriendList(); + + for (const QString &address : participantAddresses) { + QString displayName; + + // First try standard lookup by address + auto linFriend = ToolModel::findFriendByAddress(address); + if (linFriend) { + displayName = Utils::coreStringToAppString(linFriend->getName()); + } + + // If not found, search by phone number in friend list + if (displayName.isEmpty() && appFriendList) { + QString phoneNumber = extractPhoneNumber(address); + if (!phoneNumber.isEmpty()) { + // Iterate through all friends and check their phone numbers + for (const auto &friendEntry : appFriendList->getFriends()) { + auto friendPhoneNumbers = friendEntry->getPhoneNumbers(); + for (const auto &friendPhone : friendPhoneNumbers) { + QString friendPhoneStr = Utils::coreStringToAppString(friendPhone); + // Normalize both numbers for comparison (digits only) + QString normalizedFriendPhone = friendPhoneStr; + QString normalizedSearchPhone = phoneNumber; + normalizedFriendPhone.remove(QRegularExpression("[^0-9]")); + normalizedSearchPhone.remove(QRegularExpression("[^0-9]")); + // Check if phone numbers match + if (friendPhoneStr == phoneNumber || normalizedFriendPhone == normalizedSearchPhone) { + displayName = Utils::coreStringToAppString(friendEntry->getName()); + break; + } + } + if (!displayName.isEmpty()) break; + } + } + } + + // Fallback to phone number if no display name found + if (displayName.isEmpty()) { + displayName = extractPhoneNumber(address); + } + + displayNames[address] = displayName; + } + + // Write ICS file in main thread + App::postCoreAsync([participantAddresses, displayNames, uri, organizerAddress, organizerName, subject, + description, dateTime, endDateTime]() { + QString filePath(Paths::getAppLocalDirPath() + "conference.ics"); + QFile file(filePath); + if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream out(&file); + + // Helper lambda to escape special characters in ICS text fields + auto escapeIcsText = [](const QString &text) { + QString escaped = text; + escaped.replace("\\", "\\\\"); + escaped.replace(";", "\\;"); + escaped.replace(",", "\\,"); + escaped.replace("\n", "\\n"); + return escaped; + }; + + // Helper lambda to format datetime in ICS format (UTC) + auto formatIcsDateTime = [](const QDateTime &dt) { + return dt.toUTC().toString("yyyyMMdd'T'HHmmss'Z'"); + }; + + // Generate a unique UID based on URI or datetime + organizer + QString uid; + if (!uri.isEmpty()) { + uid = uri; + uid.replace("sip:", "").replace("@", "-at-"); + } else { + uid = dateTime.toUTC().toString("yyyyMMddHHmmss") + "-" + organizerAddress; + uid.replace("sip:", "").replace("@", "-at-"); + } + + // Build the ICS content + out << "BEGIN:VCALENDAR\r\n"; + out << "VERSION:2.0\r\n"; + out << "PRODID:-//Titanium Comms//EN\r\n"; + out << "METHOD:REQUEST\r\n"; + out << "BEGIN:VEVENT\r\n"; + + // UID and timestamps + out << "UID:" << uid << "\r\n"; + out << "DTSTAMP:" << formatIcsDateTime(QDateTime::currentDateTimeUtc()) << "\r\n"; + out << "DTSTART:" << formatIcsDateTime(dateTime) << "\r\n"; + out << "DTEND:" << formatIcsDateTime(endDateTime) << "\r\n"; + + // Organizer + if (!organizerAddress.isEmpty()) { + out << "ORGANIZER"; + if (!organizerName.isEmpty()) { + out << ";CN=" << escapeIcsText(organizerName); + } + out << ":" << organizerAddress << "\r\n"; + } + + // Attendees/Participants + for (const QString &address : participantAddresses) { + QString displayName = displayNames.value(address); + out << "ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE"; + if (!displayName.isEmpty()) { + out << ";CN=" << escapeIcsText(displayName); + } + out << ":" << address << "\r\n"; + } + + // Subject/Summary + if (!subject.isEmpty()) { + out << "SUMMARY:" << escapeIcsText(subject) << "\r\n"; + } + + // Description + if (!description.isEmpty()) { + out << "DESCRIPTION:" << escapeIcsText(description) << "\r\n"; + } + + // Location (conference URI) + if (!uri.isEmpty()) { + out << "LOCATION:" << uri << "\r\n"; + out << "URL:" << uri << "\r\n"; + } + + out << "STATUS:CONFIRMED\r\n"; + out << "SEQUENCE:0\r\n"; + out << "END:VEVENT\r\n"; + out << "END:VCALENDAR\r\n"; + + file.close(); + } + QDesktopServices::openUrl(QUrl::fromLocalFile(filePath)); + }); + }); } diff --git a/Linphone/core/conference/ConferenceInfoCore.hpp b/Linphone/core/conference/ConferenceInfoCore.hpp index 1654f423e..9b962fbe2 100644 --- a/Linphone/core/conference/ConferenceInfoCore.hpp +++ b/Linphone/core/conference/ConferenceInfoCore.hpp @@ -135,7 +135,7 @@ public: Q_INVOKABLE bool isAllDayConf() const; - Q_INVOKABLE void exportConferenceToICS() const; + Q_INVOKABLE void exportConferenceToICS(); signals: void dateTimeChanged(); From 909bc986223fe1a5e53284aa07a12914dbff1f04 Mon Sep 17 00:00:00 2001 From: Christophe Deschamps Date: Sat, 24 Jan 2026 08:02:29 +0100 Subject: [PATCH 2/2] Refactor ICS export --- .../core/conference/ConferenceInfoCore.cpp | 225 +++++++----------- 1 file changed, 81 insertions(+), 144 deletions(-) diff --git a/Linphone/core/conference/ConferenceInfoCore.cpp b/Linphone/core/conference/ConferenceInfoCore.cpp index 865bf7237..c6c45a986 100644 --- a/Linphone/core/conference/ConferenceInfoCore.cpp +++ b/Linphone/core/conference/ConferenceInfoCore.cpp @@ -660,7 +660,7 @@ bool ConferenceInfoCore::isAllDayConf() const { } void ConferenceInfoCore::exportConferenceToICS() { - // Collect participant addresses to look up display names + // Collect participant addresses QStringList participantAddresses; for (const auto &participant : mParticipants) { auto map = participant.toMap(); @@ -679,159 +679,96 @@ void ConferenceInfoCore::exportConferenceToICS() { QDateTime dateTime = mDateTime; QDateTime endDateTime = mEndDateTime; - // Look up display names from friend list in model thread + // Generate ICS on model thread (for display name lookup) then open file App::postModelAsync( [participantAddresses, uri, organizerAddress, organizerName, subject, description, dateTime, endDateTime]() { - // Helper to extract phone number from SIP address - auto extractPhoneNumber = [](const QString &address) -> QString { - QString addr = address; - if (addr.startsWith("sip:", Qt::CaseInsensitive)) { - addr = addr.mid(4); - } else if (addr.startsWith("sips:", Qt::CaseInsensitive)) { - addr = addr.mid(5); - } - int atIndex = addr.indexOf('@'); - if (atIndex > 0) { - return addr.left(atIndex); - } - return addr; + // Helper lambda to escape special characters in ICS text fields + auto escapeIcsText = [](const QString &text) { + QString escaped = text; + escaped.replace("\\", "\\\\"); + escaped.replace(";", "\\;"); + escaped.replace(",", "\\,"); + escaped.replace("\n", "\\n"); + return escaped; }; - // Build map of address -> display name - QMap displayNames; - auto appFriendList = ToolModel::getAppFriendList(); + // Helper lambda to format datetime in ICS format (UTC) + auto formatIcsDateTime = [](const QDateTime &dt) { return dt.toUTC().toString("yyyyMMdd'T'HHmmss'Z'"); }; - for (const QString &address : participantAddresses) { - QString displayName; - - // First try standard lookup by address - auto linFriend = ToolModel::findFriendByAddress(address); - if (linFriend) { - displayName = Utils::coreStringToAppString(linFriend->getName()); - } - - // If not found, search by phone number in friend list - if (displayName.isEmpty() && appFriendList) { - QString phoneNumber = extractPhoneNumber(address); - if (!phoneNumber.isEmpty()) { - // Iterate through all friends and check their phone numbers - for (const auto &friendEntry : appFriendList->getFriends()) { - auto friendPhoneNumbers = friendEntry->getPhoneNumbers(); - for (const auto &friendPhone : friendPhoneNumbers) { - QString friendPhoneStr = Utils::coreStringToAppString(friendPhone); - // Normalize both numbers for comparison (digits only) - QString normalizedFriendPhone = friendPhoneStr; - QString normalizedSearchPhone = phoneNumber; - normalizedFriendPhone.remove(QRegularExpression("[^0-9]")); - normalizedSearchPhone.remove(QRegularExpression("[^0-9]")); - // Check if phone numbers match - if (friendPhoneStr == phoneNumber || normalizedFriendPhone == normalizedSearchPhone) { - displayName = Utils::coreStringToAppString(friendEntry->getName()); - break; - } - } - if (!displayName.isEmpty()) break; - } - } - } - - // Fallback to phone number if no display name found - if (displayName.isEmpty()) { - displayName = extractPhoneNumber(address); - } - - displayNames[address] = displayName; + // Generate a unique UID based on URI or datetime + organizer + QString uid; + if (!uri.isEmpty()) { + uid = uri; + uid.replace("sip:", "").replace("@", "-at-"); + } else { + uid = dateTime.toUTC().toString("yyyyMMddHHmmss") + "-" + organizerAddress; + uid.replace("sip:", "").replace("@", "-at-"); } - // Write ICS file in main thread - App::postCoreAsync([participantAddresses, displayNames, uri, organizerAddress, organizerName, subject, - description, dateTime, endDateTime]() { - QString filePath(Paths::getAppLocalDirPath() + "conference.ics"); - QFile file(filePath); - if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { - QTextStream out(&file); + // Build the ICS content + QString icsContent; + QTextStream out(&icsContent); - // Helper lambda to escape special characters in ICS text fields - auto escapeIcsText = [](const QString &text) { - QString escaped = text; - escaped.replace("\\", "\\\\"); - escaped.replace(";", "\\;"); - escaped.replace(",", "\\,"); - escaped.replace("\n", "\\n"); - return escaped; - }; + out << "BEGIN:VCALENDAR\r\n"; + out << "VERSION:2.0\r\n"; + out << "PRODID:-//Titanium Comms//EN\r\n"; + out << "METHOD:REQUEST\r\n"; + out << "BEGIN:VEVENT\r\n"; - // Helper lambda to format datetime in ICS format (UTC) - auto formatIcsDateTime = [](const QDateTime &dt) { - return dt.toUTC().toString("yyyyMMdd'T'HHmmss'Z'"); - }; + // UID and timestamps + out << "UID:" << uid << "\r\n"; + out << "DTSTAMP:" << formatIcsDateTime(QDateTime::currentDateTimeUtc()) << "\r\n"; + out << "DTSTART:" << formatIcsDateTime(dateTime) << "\r\n"; + out << "DTEND:" << formatIcsDateTime(endDateTime) << "\r\n"; - // Generate a unique UID based on URI or datetime + organizer - QString uid; - if (!uri.isEmpty()) { - uid = uri; - uid.replace("sip:", "").replace("@", "-at-"); - } else { - uid = dateTime.toUTC().toString("yyyyMMddHHmmss") + "-" + organizerAddress; - uid.replace("sip:", "").replace("@", "-at-"); - } - - // Build the ICS content - out << "BEGIN:VCALENDAR\r\n"; - out << "VERSION:2.0\r\n"; - out << "PRODID:-//Titanium Comms//EN\r\n"; - out << "METHOD:REQUEST\r\n"; - out << "BEGIN:VEVENT\r\n"; - - // UID and timestamps - out << "UID:" << uid << "\r\n"; - out << "DTSTAMP:" << formatIcsDateTime(QDateTime::currentDateTimeUtc()) << "\r\n"; - out << "DTSTART:" << formatIcsDateTime(dateTime) << "\r\n"; - out << "DTEND:" << formatIcsDateTime(endDateTime) << "\r\n"; - - // Organizer - if (!organizerAddress.isEmpty()) { - out << "ORGANIZER"; - if (!organizerName.isEmpty()) { - out << ";CN=" << escapeIcsText(organizerName); - } - out << ":" << organizerAddress << "\r\n"; - } - - // Attendees/Participants - for (const QString &address : participantAddresses) { - QString displayName = displayNames.value(address); - out << "ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE"; - if (!displayName.isEmpty()) { - out << ";CN=" << escapeIcsText(displayName); - } - out << ":" << address << "\r\n"; - } - - // Subject/Summary - if (!subject.isEmpty()) { - out << "SUMMARY:" << escapeIcsText(subject) << "\r\n"; - } - - // Description - if (!description.isEmpty()) { - out << "DESCRIPTION:" << escapeIcsText(description) << "\r\n"; - } - - // Location (conference URI) - if (!uri.isEmpty()) { - out << "LOCATION:" << uri << "\r\n"; - out << "URL:" << uri << "\r\n"; - } - - out << "STATUS:CONFIRMED\r\n"; - out << "SEQUENCE:0\r\n"; - out << "END:VEVENT\r\n"; - out << "END:VCALENDAR\r\n"; - - file.close(); + // Organizer + if (!organizerAddress.isEmpty()) { + out << "ORGANIZER"; + if (!organizerName.isEmpty()) { + out << ";CN=" << escapeIcsText(organizerName); } - QDesktopServices::openUrl(QUrl::fromLocalFile(filePath)); - }); + out << ":" << organizerAddress << "\r\n"; + } + + // Attendees/Participants + for (const QString &address : participantAddresses) { + QString displayName = ToolModel::getDisplayName(address); + out << "ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE"; + if (!displayName.isEmpty()) { + out << ";CN=" << escapeIcsText(displayName); + } + out << ":" << address << "\r\n"; + } + + // Subject/Summary + if (!subject.isEmpty()) { + out << "SUMMARY:" << escapeIcsText(subject) << "\r\n"; + } + + // Description + if (!description.isEmpty()) { + out << "DESCRIPTION:" << escapeIcsText(description) << "\r\n"; + } + + // Location (conference URI) + if (!uri.isEmpty()) { + out << "LOCATION:" << uri << "\r\n"; + out << "URL:" << uri << "\r\n"; + } + + out << "STATUS:CONFIRMED\r\n"; + out << "SEQUENCE:0\r\n"; + out << "END:VEVENT\r\n"; + out << "END:VCALENDAR\r\n"; + + // Write the file and open it + QString filePath(Paths::getAppLocalDirPath() + "conference.ics"); + QFile file(filePath); + if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream fileOut(&file); + fileOut << icsContent; + file.close(); + } + QDesktopServices::openUrl(QUrl::fromLocalFile(filePath)); }); }