File indexing completed on 2024-11-24 04:42:07
0001 /* 0002 SPDX-FileCopyrightText: 2022 Volker Krause <vkrause@kde.org> 0003 SPDX-License-Identifier: LGPL-2.0-or-later 0004 */ 0005 0006 #include "androidicalconverter.h" 0007 0008 #include "android/eventdata.h" 0009 0010 #include <KAndroidExtras/CalendarContract> 0011 #include <KAndroidExtras/JniArray> 0012 0013 #include <KCalendarCore/ICalFormat> 0014 0015 #include <QDebug> 0016 #include <QJsonArray> 0017 #include <QJsonDocument> 0018 #include <QTimeZone> 0019 0020 #include <libical/ical.h> 0021 0022 namespace ical { 0023 using property_ptr = std::unique_ptr<icalproperty, decltype(&icalproperty_free)>; 0024 } 0025 0026 KCalendarCore::Event::Ptr AndroidIcalConverter::readEvent(const JniEventData& data) 0027 { 0028 if (!KAndroidExtras::Jni::handle(data).isValid()) { 0029 return nullptr; 0030 } 0031 0032 KCalendarCore::ICalFormat format; 0033 KCalendarCore::Event::Ptr ev(new KCalendarCore::Event); 0034 qDebug() << data.title; 0035 0036 ev->setSummary(data.title); 0037 ev->setLocation(data.location); 0038 ev->setDescription(data.description); 0039 ev->setDtStart(QDateTime::fromMSecsSinceEpoch(data.dtStart).toTimeZone(QTimeZone(QString(data.startTimezone).toUtf8()))); 0040 ev->setDtEnd(QDateTime::fromMSecsSinceEpoch(data.dtEnd).toTimeZone(QTimeZone(QString(data.endTimezone).toUtf8()))); 0041 ev->setAllDay(data.allDay); 0042 ev->setUid(data.uid2445); 0043 0044 const QString duration = data.duration; 0045 if (!duration.isEmpty()) { 0046 ev->setDuration(format.durationFromString(duration)); 0047 } 0048 0049 if (!QString(data.originalId).isEmpty()) { 0050 ev->setRecurrenceId(QDateTime::fromMSecsSinceEpoch(data.instanceId)); 0051 } 0052 0053 const jint accessLevel = data.accessLevel; 0054 if (accessLevel == KAndroidExtras::EventsColumns::ACCESS_PRIVATE) { // ### we probably want to cache the constants? 0055 ev->setSecrecy(KCalendarCore::Event::SecrecyPrivate); 0056 } else if (accessLevel == KAndroidExtras::EventsColumns::ACCESS_CONFIDENTIAL) { 0057 ev->setSecrecy(KCalendarCore::Event::SecrecyConfidential); 0058 } 0059 0060 const jint availability = data.availability; 0061 if (availability == KAndroidExtras::EventsColumns::AVAILABILITY_FREE) { 0062 ev->setTransparency(KCalendarCore::Event::Transparent); 0063 } 0064 0065 KCalendarCore::Person organizer; 0066 organizer.setEmail(data.organizer); 0067 0068 // recurrence rules 0069 const QString rrule = data.rrule; 0070 if (!rrule.isEmpty()) { 0071 auto r = new KCalendarCore::RecurrenceRule; 0072 format.fromString(r, rrule); 0073 ev->recurrence()->addRRule(r); 0074 } 0075 if (ev->allDay()) { 0076 ev->recurrence()->setRDates(AndroidIcalConverter::readRDates<QDate>(data.rdate)); 0077 } else { 0078 ev->recurrence()->setRDateTimes(AndroidIcalConverter::readRDates<QDateTime>(data.rdate)); 0079 } 0080 0081 const QString exrule = data.exrule; 0082 if (!exrule.isEmpty()) { 0083 auto r = new KCalendarCore::RecurrenceRule; 0084 format.fromString(r, exrule); 0085 ev->recurrence()->addExRule(r); 0086 } 0087 if (ev->allDay()) { 0088 ev->recurrence()->setExDates(AndroidIcalConverter::readRDates<QDate>(data.exdate)); 0089 } else { 0090 ev->recurrence()->setExDateTimes(AndroidIcalConverter::readRDates<QDateTime>(data.exdate)); 0091 } 0092 0093 // attendees 0094 const KAndroidExtras::Jni::Array<JniAttendeeData> attendeesData = data.attendees; 0095 for (const JniAttendeeData &attendeeData : attendeesData) { 0096 ev->addAttendee(AndroidIcalConverter::readAttendee(attendeeData)); 0097 } 0098 0099 // alarms 0100 const KAndroidExtras::Jni::Array<JniReminderData> remindersData = data.reminders; 0101 for (const JniReminderData &reminderData : remindersData) { 0102 ev->addAlarm(AndroidIcalConverter::readAlarm(reminderData, ev.data())); 0103 } 0104 0105 // extended properties 0106 const KAndroidExtras::Jni::Array<JniExtendedPropertyData> extProperties = data.extendedProperties; 0107 for (const JniExtendedPropertyData &extProperty : extProperties) { 0108 if (QString(extProperty.name) == QLatin1StringView("vnd.android.cursor.item/vnd.ical4android.unknown-property")) { 0109 // DAVx⁵ extended properties: the actual name/value pair is a JSON array in the property value 0110 const auto a = QJsonDocument::fromJson(QString(extProperty.value).toUtf8()).array(); 0111 if (a.size() != 2) { 0112 qWarning() << "Invalid DAVx⁵ extended property data:" << extProperty.value; 0113 continue; 0114 } 0115 AndroidIcalConverter::addExtendedProperty(ev.data(), a[0].toString(), a[1].toString()); 0116 } else { 0117 qInfo() << "Unhandled extended property:" << extProperty.name << extProperty.value; 0118 } 0119 } 0120 0121 ev->resetDirtyFields(); 0122 return ev; 0123 } 0124 0125 JniEventData AndroidIcalConverter::writeEvent(const KCalendarCore::Event::Ptr &event) 0126 { 0127 KCalendarCore::ICalFormat format; 0128 0129 JniEventData data; 0130 data.title = event->summary(); 0131 data.location = event->location(); 0132 data.description = event->description(); 0133 data.dtStart = event->dtStart().toMSecsSinceEpoch(); 0134 data.startTimezone = QString::fromUtf8(event->dtStart().timeZone().id()); 0135 data.dtEnd = event->dtEnd().toMSecsSinceEpoch(); 0136 data.endTimezone = QString::fromUtf8(event->dtEnd().timeZone().id()); 0137 data.allDay = event->allDay(); 0138 data.uid2445 = event->uid(); 0139 0140 if (!event->duration().isNull()) { 0141 data.duration = format.toString(event->duration()); 0142 } 0143 0144 if (event->recurrenceId().isValid()) { 0145 data.originalId = event->uid(); 0146 data.instanceId = event->recurrenceId().toMSecsSinceEpoch(); 0147 } 0148 0149 switch (event->secrecy()) { 0150 case KCalendarCore::Event::SecrecyPrivate: 0151 data.accessLevel = KAndroidExtras::EventsColumns::ACCESS_PRIVATE; 0152 break; 0153 case KCalendarCore::Event::SecrecyConfidential: 0154 data.accessLevel = KAndroidExtras::EventsColumns::ACCESS_CONFIDENTIAL; 0155 break; 0156 case KCalendarCore::Event::SecrecyPublic: 0157 data.accessLevel = KAndroidExtras::EventsColumns::ACCESS_PUBLIC; 0158 break; 0159 } 0160 0161 switch (event->transparency()) { 0162 case KCalendarCore::Event::Transparent: 0163 data.availability = KAndroidExtras::EventsColumns::AVAILABILITY_FREE; 0164 break; 0165 case KCalendarCore::Event::Opaque: 0166 data.availability = KAndroidExtras::EventsColumns::AVAILABILITY_BUSY; 0167 break; 0168 } 0169 0170 data.organizer = event->organizer().email(); 0171 0172 // recurrence rules 0173 if (!event->recurrence()->rRules().isEmpty()) { 0174 data.rrule = format.toString(event->recurrence()->rRules().at(0)); // TODO what about more than one rule? 0175 } 0176 if (event->allDay() && !event->recurrence()->rDates().isEmpty()) { 0177 data.rdate = writeRDates(event->recurrence()->rDates()); 0178 } else if (!event->allDay() && !event->recurrence()->rDateTimes().isEmpty()) { 0179 data.rdate = writeRDates(event->recurrence()->rDateTimes()); 0180 } 0181 if (!event->recurrence()->exRules().isEmpty()) { 0182 data.exrule = format.toString(event->recurrence()->exRules().at(0)); // TODO what about more than one rule? 0183 } 0184 if (event->allDay() && !event->recurrence()->exDates().isEmpty()) { 0185 data.rdate = writeRDates(event->recurrence()->exDates()); 0186 } else if (!event->allDay() && !event->recurrence()->exDateTimes().isEmpty()) { 0187 data.rdate = writeRDates(event->recurrence()->exDateTimes()); 0188 } 0189 0190 // attendees 0191 if (!event->attendees().isEmpty()) { 0192 const auto attendees = event->attendees(); 0193 KAndroidExtras::Jni::Array<JniAttendeeData> attendeeData(attendees.size()); 0194 for (int i = 0; i < attendees.size(); ++i) { 0195 attendeeData[i] = writeAttendee(attendees.at(i)); 0196 } 0197 data.attendees = attendeeData; 0198 } 0199 0200 // alarms 0201 if (!event->alarms().isEmpty()) { 0202 const auto alarms = event->alarms(); 0203 KAndroidExtras::Jni::Array<JniReminderData> reminders(alarms.size()); 0204 for (int i = 0; i < alarms.size(); ++i) { 0205 reminders[i] = writeAlarm(alarms.at(i)); 0206 } 0207 data.reminders = reminders; 0208 } 0209 0210 // extended properties 0211 const auto properties = writeExtendedProperties(event.data()); 0212 if (!properties.empty()) { 0213 KAndroidExtras::Jni::Array<JniExtendedPropertyData> propData(properties.size()); 0214 for (std::size_t i = 0; i < properties.size(); ++i) { 0215 propData[i] = properties[i]; 0216 } 0217 data.extendedProperties = propData; 0218 } 0219 0220 return data; 0221 } 0222 0223 KCalendarCore::Alarm::Ptr AndroidIcalConverter::readAlarm(const JniReminderData &data, KCalendarCore::Incidence *parent) 0224 { 0225 KCalendarCore::Alarm::Ptr alarm(new KCalendarCore::Alarm(parent)); 0226 alarm->setStartOffset(KCalendarCore::Duration(-data.minutes * 60, KCalendarCore::Duration::Seconds)); 0227 if (data.method == KAndroidExtras::RemindersColumns::METHOD_EMAIL || data.method == KAndroidExtras::RemindersColumns::METHOD_SMS) { 0228 alarm->setType(KCalendarCore::Alarm::Email); 0229 } else { 0230 alarm->setType(KCalendarCore::Alarm::Display); 0231 } 0232 return alarm; 0233 } 0234 0235 JniReminderData AndroidIcalConverter::writeAlarm(const KCalendarCore::Alarm::Ptr &alarm) 0236 { 0237 JniReminderData data; 0238 data.minutes = -alarm->startOffset().asSeconds() / 60; 0239 switch (alarm->type()) { 0240 case KCalendarCore::Alarm::Audio: 0241 case KCalendarCore::Alarm::Display: 0242 case KCalendarCore::Alarm::Procedure: 0243 case KCalendarCore::Alarm::Invalid: 0244 data.method = KAndroidExtras::RemindersColumns::METHOD_ALERT; 0245 break; 0246 case KCalendarCore::Alarm::Email: 0247 data.method = KAndroidExtras::RemindersColumns::METHOD_EMAIL; 0248 break; 0249 } 0250 return data; 0251 } 0252 0253 KCalendarCore::Attendee AndroidIcalConverter::readAttendee(const JniAttendeeData& data) 0254 { 0255 KCalendarCore::Attendee attendee(data.name, data.email); 0256 0257 // attendee role (### doesn't map properly, what to do about the remaining values?) 0258 const jint relationship = data.relationship; 0259 if (relationship == KAndroidExtras::AttendeesColumns::RELATIONSHIP_NONE) { 0260 attendee.setRole(KCalendarCore::Attendee::NonParticipant); 0261 } else if (relationship == KAndroidExtras::AttendeesColumns::RELATIONSHIP_ORGANIZER) { 0262 attendee.setRole(KCalendarCore::Attendee::Chair); 0263 } 0264 0265 // attendee status 0266 const jint status = data.status; 0267 if (status == KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_ACCEPTED) { 0268 attendee.setStatus(KCalendarCore::Attendee::Accepted); 0269 } else if (status == KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_DECLINED) { 0270 attendee.setStatus(KCalendarCore::Attendee::Declined); 0271 } else if (status == KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_INVITED) { 0272 attendee.setStatus(KCalendarCore::Attendee::NeedsAction); 0273 } else if (status == KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_NONE) { 0274 attendee.setStatus(KCalendarCore::Attendee::None); 0275 } else if (status == KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_TENTATIVE) { 0276 attendee.setStatus(KCalendarCore::Attendee::Tentative); 0277 } 0278 0279 // attendee type (### doesn't perfectly map either, and partly maps to role?) 0280 const jint type = data.type; 0281 if (type == KAndroidExtras::AttendeesColumns::TYPE_REQUIRED) { 0282 attendee.setRole(KCalendarCore::Attendee::ReqParticipant); 0283 } else if (type == KAndroidExtras::AttendeesColumns::TYPE_OPTIONAL) { 0284 attendee.setRole(KCalendarCore::Attendee::OptParticipant); 0285 } else if (type == KAndroidExtras::AttendeesColumns::TYPE_NONE) { 0286 attendee.setRole(KCalendarCore::Attendee::NonParticipant); 0287 } else if (type == KAndroidExtras::AttendeesColumns::TYPE_RESOURCE) { 0288 attendee.setCuType(KCalendarCore::Attendee::Resource); 0289 } 0290 0291 return attendee; 0292 } 0293 0294 JniAttendeeData AndroidIcalConverter::writeAttendee(const KCalendarCore::Attendee &attendee) 0295 { 0296 JniAttendeeData data; 0297 data.name = attendee.name(); 0298 data.email = attendee.email(); 0299 0300 switch (attendee.cuType()) { 0301 case KCalendarCore::Attendee::Individual: 0302 case KCalendarCore::Attendee::Group: 0303 case KCalendarCore::Attendee::Unknown: 0304 data.relationship = KAndroidExtras::AttendeesColumns::RELATIONSHIP_ATTENDEE; 0305 break; 0306 case KCalendarCore::Attendee::Room: 0307 case KCalendarCore::Attendee::Resource: 0308 data.type = KAndroidExtras::AttendeesColumns::TYPE_RESOURCE; 0309 break; 0310 } 0311 0312 switch (attendee.role()) { 0313 case KCalendarCore::Attendee::ReqParticipant: 0314 data.type = KAndroidExtras::AttendeesColumns::TYPE_REQUIRED; 0315 break; 0316 case KCalendarCore::Attendee::OptParticipant: 0317 data.type = KAndroidExtras::AttendeesColumns::TYPE_OPTIONAL; 0318 break; 0319 case KCalendarCore::Attendee::NonParticipant: 0320 data.type = KAndroidExtras::AttendeesColumns::TYPE_NONE; 0321 break; 0322 case KCalendarCore::Attendee::Chair: 0323 // TODO? 0324 break; 0325 } 0326 0327 switch (attendee.status()) { 0328 case KCalendarCore::Attendee::NeedsAction: 0329 data.status = KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_INVITED; 0330 break; 0331 case KCalendarCore::Attendee::Accepted: 0332 data.status = KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_ACCEPTED; 0333 break; 0334 case KCalendarCore::Attendee::Declined: 0335 data.status = KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_DECLINED; 0336 break; 0337 case KCalendarCore::Attendee::Tentative: 0338 data.status = KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_TENTATIVE; 0339 break; 0340 case KCalendarCore::Attendee::Delegated: 0341 case KCalendarCore::Attendee::InProcess: 0342 case KCalendarCore::Attendee::Completed: 0343 case KCalendarCore::Attendee::None: 0344 data.status = KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_NONE; 0345 break; 0346 } 0347 0348 return data; 0349 } 0350 0351 void AndroidIcalConverter::addExtendedProperty(KCalendarCore::Incidence *incidence, const QString &name, const QString &value) 0352 { 0353 const QByteArray propString = name.toUtf8() + ':' + value.toUtf8(); 0354 ical::property_ptr p(icalproperty_new_from_string(propString.constData()), &icalproperty_free); 0355 if (!p) { 0356 qWarning() << "Failed to parse extended property:" << name << value; 0357 return; 0358 } 0359 0360 // ### we can probably reuse large parts if icalformat_p.cpp with a bit of refactoring after moving to KCalendarCore 0361 switch (icalproperty_isa(p.get())) { 0362 case ICAL_CREATED_PROPERTY: 0363 { 0364 icaldatetimeperiodtype tp; 0365 tp.time = icalproperty_get_created(p.get()); 0366 incidence->setCreated(QDateTime({tp.time.year, tp.time.month, tp.time.day}, {tp.time.hour, tp.time.minute, tp.time.second}, Qt::UTC)); 0367 break; 0368 } 0369 case ICAL_GEO_PROPERTY: 0370 { 0371 icalgeotype geo = icalproperty_get_geo(p.get()); 0372 incidence->setGeoLatitude(geo.lat); 0373 incidence->setGeoLongitude(geo.lon); 0374 break; 0375 } 0376 case ICAL_PRIORITY_PROPERTY: 0377 incidence->setPriority(icalproperty_get_priority(p.get())); 0378 break; 0379 case ICAL_X_PROPERTY: 0380 incidence->setNonKDECustomProperty(name.toUtf8(), value); 0381 break; 0382 default: 0383 qWarning() << "Unhandled property type:" << name << value; 0384 } 0385 } 0386 0387 static JniExtendedPropertyData writeExtendedProperty(const QString &name, const QString &value) 0388 { 0389 JniExtendedPropertyData data; 0390 data.name = QStringLiteral("vnd.android.cursor.item/vnd.ical4android.unknown-property"); 0391 0392 QJsonArray array; 0393 array.push_back(name); 0394 array.push_back(value); 0395 data.value = QString::fromUtf8(QJsonDocument(array).toJson(QJsonDocument::Compact)); 0396 0397 return data; 0398 } 0399 0400 static JniExtendedPropertyData writeExtendedProperty(const ical::property_ptr &prop) 0401 { 0402 const auto name = QString::fromUtf8(icalproperty_kind_to_string(icalproperty_isa(prop.get()))); 0403 const auto icalValue = icalproperty_get_value(prop.get()); 0404 const auto value = QString::fromUtf8(icalvalue_as_ical_string(icalValue)); 0405 return writeExtendedProperty(name, value); 0406 } 0407 0408 std::vector<JniExtendedPropertyData> AndroidIcalConverter::writeExtendedProperties(const KCalendarCore::Incidence *incidence) 0409 { 0410 std::vector<JniExtendedPropertyData> props; 0411 0412 if (incidence->created().isValid()) { 0413 const auto dt = incidence->created().toUTC(); 0414 icaltimetype t = icaltime_null_time(); 0415 t.year = dt.date().year(); 0416 t.month = dt.date().month(); 0417 t.day = dt.date().day(); 0418 t.hour = dt.time().hour(); 0419 t.minute = dt.time().minute(); 0420 t.second = dt.time().second(); 0421 t.zone = nullptr; // zone is NOT set 0422 t = icaltime_convert_to_zone(t, icaltimezone_get_utc_timezone()); 0423 ical::property_ptr p(icalproperty_new_created(t), &icalproperty_free); 0424 props.push_back(::writeExtendedProperty(p)); 0425 } 0426 0427 if (incidence->hasGeo()) { 0428 icalgeotype geo; 0429 geo.lat = incidence->geoLatitude(); 0430 geo.lon = incidence->geoLongitude(); 0431 ical::property_ptr p(icalproperty_new_geo(geo), &icalproperty_free); 0432 props.push_back(::writeExtendedProperty(p)); 0433 } 0434 0435 // TODO all other properties not included in the standard fields 0436 0437 const auto customProps = incidence->customProperties(); 0438 for (auto it = customProps.begin(); it != customProps.end(); ++it) { 0439 props.push_back(::writeExtendedProperty(QString::fromUtf8(it.key()), it.value())); 0440 } 0441 return props; 0442 } 0443 0444 template<typename T> 0445 QList<T> AndroidIcalConverter::readRDates(const QString &data) 0446 { 0447 if (data.isEmpty()) { 0448 return {}; 0449 } 0450 0451 const auto pos = data.indexOf(QLatin1Char(';')); 0452 QTimeZone tz; 0453 QStringView listData; 0454 if (pos > 0) { 0455 listData = QStringView(data).mid(pos + 1); 0456 tz = QTimeZone(QStringView(data).left(pos).toUtf8()); 0457 } else { 0458 listData = QStringView(data); 0459 tz = QTimeZone::utc(); 0460 } 0461 0462 const auto list = listData.split(QLatin1Char(',')); 0463 QList<T> result; 0464 result.reserve(list.size()); 0465 for (const auto &s : list) { 0466 T value; 0467 if constexpr (std::is_same_v<QDate, T>) { 0468 value = QDate::fromString(s.toString(), QStringLiteral("yyyyMMdd")); 0469 } else { 0470 if (s.endsWith(QLatin1Char('Z'))) { 0471 value = QDateTime::fromString(s.toString(), QStringLiteral("yyyyMMddThhmmssZ")); 0472 value.setTimeZone(QTimeZone::utc()); 0473 } else { 0474 value = QDateTime::fromString(s.toString(), QStringLiteral("yyyyMMddThhmmss")); 0475 value.setTimeZone(tz); 0476 } 0477 } 0478 if (!value.isValid()) { 0479 qWarning() << "Failed to parse RDATE data:" << data; 0480 return {}; 0481 } 0482 result.push_back(value); 0483 } 0484 0485 return result; 0486 } 0487 0488 QString AndroidIcalConverter::writeRDates(const QList<QDate> &rdates) 0489 { 0490 QString result; 0491 result.reserve((rdates.size() * 9) - 1); 0492 for (const auto &rdate : rdates) { 0493 if (!result.isEmpty()) { 0494 result += QLatin1Char(','); 0495 } 0496 result += rdate.toString(QStringLiteral("yyyyMMdd")); 0497 } 0498 return result; 0499 } 0500 0501 QString AndroidIcalConverter::writeRDates(const QList<QDateTime> &rdates) 0502 { 0503 if (rdates.isEmpty()) { 0504 return {}; 0505 } 0506 0507 QString result; 0508 const auto tz = rdates.at(0).timeZone(); 0509 const auto isUtc = tz == QTimeZone::utc(); 0510 if (!isUtc) { 0511 result += QString::fromUtf8(tz.id()) + QLatin1Char(';'); 0512 } 0513 0514 result.reserve(result.size() + (rdates.size() * (isUtc ? 14 : 13)) - 1); 0515 for (int i = 0; i < rdates.size(); ++i) { 0516 if (i > 0) { 0517 result += QLatin1Char(','); 0518 } 0519 result += rdates.at(i).toTimeZone(tz).toString(isUtc ? QStringLiteral("yyyyMMddThhmmssZ") : QStringLiteral("yyyyMMddThhmmss")); 0520 } 0521 0522 return result; 0523 }